Tests refactor
This commit is contained in:
parent
245520fb50
commit
99134d8556
165 changed files with 911 additions and 531 deletions
|
|
@ -0,0 +1,237 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ShoppingListsRepository } from '../../../src/modules/shopping-lists/shopping-lists.repository.js';
|
||||
|
||||
const { mockSave, MockShoppingListModel } = vi.hoisted(() => {
|
||||
const mockSave = vi.fn();
|
||||
function MockModel(this: { save: typeof mockSave }, data: unknown) {
|
||||
Object.assign(this, data);
|
||||
this.save = mockSave;
|
||||
}
|
||||
Object.assign(MockModel, {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOneAndUpdate: vi.fn(),
|
||||
findOneAndDelete: vi.fn(),
|
||||
});
|
||||
return { mockSave, MockShoppingListModel: MockModel };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/schemas/shopping-list.schema.js', () => ({
|
||||
ShoppingListModel: MockShoppingListModel,
|
||||
}));
|
||||
|
||||
const { ShoppingListModel } = await import('../../../src/schemas/shopping-list.schema.js');
|
||||
|
||||
function makeChain(result: unknown = null) {
|
||||
return {
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
describe(ShoppingListsRepository.name, () => {
|
||||
let repo: ShoppingListsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new ShoppingListsRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves new shopping list model and returns simple object', async () => {
|
||||
const doc = { _id: 'list1', name: 'Test List' };
|
||||
mockSave.mockResolvedValue({ toObject: () => doc });
|
||||
|
||||
const result = await repo.create({ name: 'Test List', householdId: 'h1', createdBy: 'u1', status: 'active' });
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result._id).toBe('list1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('queries lists for household ordered newest first', async () => {
|
||||
const chain = makeChain([{ _id: 'list1' }]);
|
||||
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
|
||||
|
||||
await repo.list('h1');
|
||||
expect(ShoppingListModel.find).toHaveBeenCalledWith({ householdId: 'h1' });
|
||||
expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('queries distinct document by ID and householdId', async () => {
|
||||
const chain = makeChain({ _id: 'list1' });
|
||||
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findById('list1', 'h1');
|
||||
expect(ShoppingListModel.findOne).toHaveBeenCalledWith({ _id: 'list1', householdId: 'h1' });
|
||||
expect(result?._id).toBe('list1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByHousehold', () => {
|
||||
it('queries specifically active/shopping lists sorted by update recency', async () => {
|
||||
const chain = makeChain([{ _id: 'list1' }]);
|
||||
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
|
||||
|
||||
await repo.findActiveByHousehold('h1');
|
||||
expect(ShoppingListModel.find).toHaveBeenCalledWith({
|
||||
householdId: 'h1',
|
||||
status: { $in: ['active', 'shopping'] }
|
||||
});
|
||||
expect(chain.sort).toHaveBeenCalledWith({ updatedAt: -1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('sets top level list variables atomically', async () => {
|
||||
const chain = makeChain({});
|
||||
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
|
||||
|
||||
await repo.update('list1', 'h1', { name: 'New Name' });
|
||||
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'list1', householdId: 'h1' },
|
||||
{ $set: { name: 'New Name' } },
|
||||
{ new: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('executes findOneAndDelete targeting target IDs', async () => {
|
||||
const chain = makeChain({});
|
||||
vi.mocked(ShoppingListModel.findOneAndDelete).mockReturnValue(chain as any);
|
||||
|
||||
await repo.delete('list1', 'h1');
|
||||
expect(ShoppingListModel.findOneAndDelete).toHaveBeenCalledWith({ _id: 'list1', householdId: 'h1' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- Atomic Subdocument Array Actions Tests ---
|
||||
|
||||
describe('addItem', () => {
|
||||
it('executes $push operator targeting list subdocuments', async () => {
|
||||
const chain = makeChain({});
|
||||
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
|
||||
|
||||
const mockItem = { id: 'itm1', quantity: 1, unit: 'g', checked: false, addedToPantry: false };
|
||||
await repo.addItem('list1', 'h1', mockItem as any);
|
||||
|
||||
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'list1', householdId: 'h1' },
|
||||
{ $push: { items: mockItem } },
|
||||
{ new: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('maps partial payload to flattened positional $ keys', async () => {
|
||||
const chain = makeChain({});
|
||||
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
|
||||
|
||||
await repo.updateItem('list1', 'h1', 'itm1', { checked: true, actualPrice: 5.5 });
|
||||
|
||||
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'list1', householdId: 'h1', 'items.id': 'itm1' },
|
||||
{
|
||||
$set: {
|
||||
'items.$.checked': true,
|
||||
'items.$.actualPrice': 5.5
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('executes $pull operator matching inner item tracking id', async () => {
|
||||
const chain = makeChain({});
|
||||
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
|
||||
|
||||
await repo.removeItem('list1', 'h1', 'itm1');
|
||||
|
||||
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'list1', householdId: 'h1' },
|
||||
{ $pull: { items: { id: 'itm1' } } },
|
||||
{ new: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('sortItems logic', () => {
|
||||
it('sorts items by checked status, category, name, and id', async () => {
|
||||
const items = [
|
||||
{ id: '6', customName: 'Zebra', category: 'Animal', checked: true },
|
||||
{ id: '1', customName: 'Apple', category: 'Fruit', checked: false },
|
||||
{ id: '2', customName: 'Banana', category: 'Fruit', checked: false },
|
||||
{ id: '3', customName: 'Aardvark', category: 'Animal', checked: false },
|
||||
{ id: '5', customName: 'Apple', category: 'Fruit', checked: true },
|
||||
{ id: '4', customName: 'Bread', checked: false }, // No category (should come first in category sort)
|
||||
{ id: '0', checked: false }, // No name, no category (should come first in all)
|
||||
];
|
||||
|
||||
const chain = makeChain({ _id: 'list1', items });
|
||||
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findById('list1', 'h1');
|
||||
|
||||
// Expected order:
|
||||
// 1. Unchecked, No category, No name (0)
|
||||
// 2. Unchecked, No category, Bread (4)
|
||||
// 3. Unchecked, Animal, Aardvark (3)
|
||||
// 4. Unchecked, Fruit, Apple (1)
|
||||
// 5. Unchecked, Fruit, Banana (2)
|
||||
// 6. Checked, Animal, Zebra (6)
|
||||
// 7. Checked, Fruit, Apple (5)
|
||||
|
||||
expect(result.items[0].id).toBe('0');
|
||||
expect(result.items[1].id).toBe('4');
|
||||
expect(result.items[2].id).toBe('3');
|
||||
expect(result.items[3].id).toBe('1');
|
||||
expect(result.items[4].id).toBe('2');
|
||||
expect(result.items[5].id).toBe('6');
|
||||
expect(result.items[6].id).toBe('5');
|
||||
});
|
||||
|
||||
it('handles mixed missing/present categories and names during sorting', async () => {
|
||||
const items = [
|
||||
{ id: '1', customName: 'Apple', checked: false }, // category missing
|
||||
{ id: '2', category: 'Fruit', checked: false }, // customName missing
|
||||
];
|
||||
|
||||
const chain = makeChain({ _id: 'list1', items });
|
||||
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findById('list1', 'h1');
|
||||
|
||||
// Expected order:
|
||||
// 1. (id:1) - empty category comes before 'Fruit'
|
||||
// 2. (id:2) - 'Fruit' category
|
||||
expect(result.items[0].id).toBe('1');
|
||||
expect(result.items[1].id).toBe('2');
|
||||
});
|
||||
|
||||
it('sorts items by id as a final tie-breaker', async () => {
|
||||
const items = [
|
||||
{ id: 'B', customName: 'Apple', category: 'Fruit', checked: false },
|
||||
{ id: 'A', customName: 'Apple', category: 'Fruit', checked: false },
|
||||
];
|
||||
|
||||
const chain = makeChain({ _id: 'list1', items });
|
||||
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findById('list1', 'h1');
|
||||
|
||||
expect(result.items[0].id).toBe('A');
|
||||
expect(result.items[1].id).toBe('B');
|
||||
});
|
||||
|
||||
it('handles null items or list gracefully', () => {
|
||||
expect((repo as any).sortItems(null)).toBeNull();
|
||||
expect((repo as any).sortItems({ name: 'foo' })).toEqual({ name: 'foo' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: {},
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockList = vi.fn();
|
||||
const mockFindById = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
const mockAddItem = vi.fn();
|
||||
const mockUpdateItem = vi.fn();
|
||||
const mockRemoveItem = vi.fn();
|
||||
|
||||
vi.mock('../../../src/modules/shopping-lists/shopping-lists.repository.js', () => ({
|
||||
ShoppingListsRepository: class {
|
||||
list = mockList;
|
||||
findById = mockFindById;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
delete = mockDelete;
|
||||
addItem = mockAddItem;
|
||||
updateItem = mockUpdateItem;
|
||||
removeItem = mockRemoveItem;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/meal-plans/shopping-gap.service.js', () => ({
|
||||
ShoppingGapService: class {
|
||||
calculateGap = vi.fn().mockResolvedValue({ missingItems: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/pantry/pantry.service.js', () => ({
|
||||
PantryService: class {
|
||||
create = vi.fn().mockResolvedValue({ _id: 'pant1' });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findById = vi.fn().mockResolvedValue({ category: 'dairy' });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/prices/prices.service.js', () => ({
|
||||
PricesService: class {
|
||||
estimatePrice = vi.fn().mockResolvedValue(5.0);
|
||||
recordPrice = vi.fn().mockResolvedValue({});
|
||||
compareStores = vi.fn().mockResolvedValue([]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/meal-plans/meal-plans.repository.js', () => ({
|
||||
MealPlanRepository: class {
|
||||
findById = vi.fn().mockResolvedValue({ _id: 'mp1', weekStartDate: new Date() });
|
||||
update = vi.fn().mockResolvedValue({});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import shoppingListsRoutes from '../../../src/modules/shopping-lists/shopping-lists.routes.js';
|
||||
|
||||
describe('shopping-lists.routes', () => {
|
||||
let app: any;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(shoppingListsRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function makeShoppingList(overrides = {}) {
|
||||
return {
|
||||
_id: 'list1',
|
||||
householdId: 'hh1',
|
||||
name: 'Weekly Checklist',
|
||||
items: [],
|
||||
status: 'active',
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GET /api/v1/households/:householdId/shopping-lists', () => {
|
||||
it('returns all lists belonging to household', async () => {
|
||||
mockList.mockResolvedValue([makeShoppingList()]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/shopping-lists',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/shopping-lists', () => {
|
||||
it('persists metadata and returns 201 response', async () => {
|
||||
mockCreate.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newListA', createdAt: new Date(), updatedAt: new Date() }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/shopping-lists',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Costco Run',
|
||||
items: []
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().name).toBe('Costco Run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/shopping-lists/:id/items', () => {
|
||||
it('adds new checklist subdocument item generating tracking UUIDs', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
const updated = makeShoppingList({
|
||||
items: [{ id: 'itemuuid123', productId: 'p1', quantity: 1, unit: 'piece', checked: false, addedToPantry: false }]
|
||||
});
|
||||
mockAddItem.mockResolvedValue(updated);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/items',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId: 'p1',
|
||||
quantity: 1,
|
||||
unit: 'piece'
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0].productId).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/shopping-lists/:id/sync-to-pantry', () => {
|
||||
it('executes batch synchronized promotions resulting in completed summaries', async () => {
|
||||
const populatedList = makeShoppingList({
|
||||
items: [{ id: 'itemA', productId: 'p1', checked: true, addedToPantry: false, quantity: 1, unit: 'piece' }]
|
||||
});
|
||||
mockFindById.mockResolvedValue(populatedList);
|
||||
mockUpdateItem.mockResolvedValue({});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/sync-to-pantry',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.addedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/shopping-lists/:id', () => {
|
||||
it('returns a single shopping list by ID', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('list1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/shopping-lists/:id', () => {
|
||||
it('updates list metadata', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
mockUpdate.mockResolvedValue(makeShoppingList({ name: 'Updated Name' }));
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Updated Name' }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/shopping-lists/:id', () => {
|
||||
it('removes list', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
mockDelete.mockResolvedValue(true);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => {
|
||||
it('updates item inline and broadcasts differential updates', async () => {
|
||||
const item = { id: 'itemA', productId: 'p1', quantity: 2, unit: 'piece', checked: false };
|
||||
mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [{ ...item, checked: true }] }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ checked: true }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().items[0].checked).toBe(true);
|
||||
});
|
||||
|
||||
it('skips broadcast if item is missing from updated list', async () => {
|
||||
// Return a list where itemA is gone (maybe someone else deleted it)
|
||||
mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [] }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ checked: true }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => {
|
||||
it('deletes an item from the checklist', async () => {
|
||||
mockRemoveItem.mockResolvedValue(makeShoppingList({ items: [] }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/shopping-lists/from-meal-plan/:mealPlanId', () => {
|
||||
it('generates dynamic checklist based on scheduled meal gaps', async () => {
|
||||
mockCreate.mockResolvedValue(makeShoppingList({ _id: 'generatedList1' }));
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/shopping-lists/from-meal-plan/mp1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json()._id).toBe('generatedList1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/shopping-lists/:id/stores', () => {
|
||||
it('returns basket store optimization reports', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList({ items: [{ productId: 'p1' }] }));
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/stores',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().singleStoreOptions).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,492 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ShoppingListsService } from '../../../src/modules/shopping-lists/shopping-lists.service.js';
|
||||
import { NotFoundError } from '../../../src/common/errors.js';
|
||||
|
||||
describe('ShoppingListsService', () => {
|
||||
let service: ShoppingListsService;
|
||||
|
||||
const mockListsRepo = {
|
||||
list: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGapService = {
|
||||
calculateGap: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPantryService = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPricesService = {
|
||||
estimatePrice: vi.fn(),
|
||||
recordPrice: vi.fn(),
|
||||
compareStores: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMealPlanRepo = {
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ShoppingListsService({
|
||||
shoppingListsRepository: mockListsRepo as any,
|
||||
shoppingGapService: mockGapService as any,
|
||||
pantryService: mockPantryService as any,
|
||||
productsRepository: mockProductsRepo as any,
|
||||
pricesService: mockPricesService as any,
|
||||
mealPlanRepository: mockMealPlanRepo as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('populates initial estimates and auto-generates internal tracking UUIDs', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ category: 'produce' });
|
||||
mockPricesService.estimatePrice.mockResolvedValue(5);
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
name: 'Weekly run',
|
||||
items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBeDefined();
|
||||
expect(result.items[0].estimatedPrice).toBe(5);
|
||||
expect(result.totalEstimatedCost).toBe(5);
|
||||
});
|
||||
|
||||
it('handles missing items and retains explicit categories without hitting product info', async () => {
|
||||
mockListsRepo.create.mockImplementation(arg => Promise.resolve(arg));
|
||||
const resEmpty = await service.create({ name: 'Empty' }, 'hh1', 'u1');
|
||||
expect(resEmpty.items).toEqual([]);
|
||||
|
||||
mockProductsRepo.findById.mockResolvedValue({ category: 'meat' });
|
||||
mockPricesService.estimatePrice.mockResolvedValue(10);
|
||||
|
||||
const resCategory = await service.create(
|
||||
{
|
||||
name: 'Overridden',
|
||||
items: [{ productId: 'p1', quantity: 1, category: 'bakery', unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
expect(resCategory.items[0].category).toBe('bakery');
|
||||
});
|
||||
|
||||
it('handles missing product info or estimates gracefully during creation', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
name: 'Minimal run',
|
||||
items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(result.items[0].category).toBeUndefined();
|
||||
expect(result.items[0].estimatedPrice).toBeUndefined();
|
||||
expect(result.totalEstimatedCost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles items without productId gracefully during creation', async () => {
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
const result = await service.create(
|
||||
{
|
||||
name: 'Custom run',
|
||||
items: [{ customName: 'Bread', quantity: 1, unit: 'pcs' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
expect(result.items[0].customName).toBe('Bread');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
mockListsRepo.list.mockResolvedValue(['listA']);
|
||||
const res = await service.list('hh1');
|
||||
expect(mockListsRepo.list).toHaveBeenCalledWith('hh1');
|
||||
expect(res).toEqual(['listA']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('throws NotFoundError if repository returns null', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getById('list1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates shopping list properties and returns it', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockListsRepo.update.mockResolvedValue({ _id: 'list1', name: 'New Name' });
|
||||
const res = await service.update('list1', 'hh1', { name: 'New Name' });
|
||||
expect(mockListsRepo.update).toHaveBeenCalledWith('list1', 'hh1', { name: 'New Name' });
|
||||
expect(res.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if update returns null', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockListsRepo.update.mockResolvedValue(null);
|
||||
await expect(service.update('list1', 'hh1', { name: 'New Name' })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addItem', () => {
|
||||
it('hydrates single product pricing and pushes to list repository', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockProductsRepo.findById.mockResolvedValue({ category: 'meat' });
|
||||
mockPricesService.estimatePrice.mockResolvedValue(10);
|
||||
mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' });
|
||||
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
productId: 'prodA',
|
||||
quantity: 1,
|
||||
unit: 'g' as any,
|
||||
});
|
||||
|
||||
expect(mockListsRepo.addItem).toHaveBeenCalledWith(
|
||||
'list1',
|
||||
'hh1',
|
||||
expect.objectContaining({
|
||||
productId: 'prodA',
|
||||
estimatedPrice: 10,
|
||||
category: 'meat',
|
||||
})
|
||||
);
|
||||
expect(res.addedItem.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips product info fetch and adds custom items', async () => {
|
||||
mockListsRepo.addItem.mockImplementation((id, hh, data) => Promise.resolve({ _id: id }));
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
customName: 'Custom item',
|
||||
quantity: 1,
|
||||
unit: 'g' as any,
|
||||
});
|
||||
expect(res.addedItem.customName).toBe('Custom item');
|
||||
expect(res.addedItem.productId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError if list update returns null when adding item', async () => {
|
||||
mockListsRepo.addItem.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.addItem('list1', 'hh1', { customName: 'Nonsense', quantity: 1, unit: 'g' as any })
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('handles missing product info or estimates gracefully during addItem', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' });
|
||||
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
productId: 'prodUnknown',
|
||||
quantity: 1,
|
||||
unit: 'g' as any,
|
||||
category: 'explicit',
|
||||
});
|
||||
|
||||
expect(res.addedItem.category).toBe('explicit');
|
||||
expect(res.addedItem.estimatedPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('injects correct checked timestamps on check-off state mutations', async () => {
|
||||
mockListsRepo.updateItem.mockResolvedValue({});
|
||||
|
||||
await service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'userIdX');
|
||||
|
||||
expect(mockListsRepo.updateItem).toHaveBeenCalledWith(
|
||||
'list1',
|
||||
'hh1',
|
||||
'itemA',
|
||||
expect.objectContaining({
|
||||
checked: true,
|
||||
checkedBy: 'userIdX',
|
||||
checkedAt: expect.any(Date),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('wipes timestamps if unchecking an item', async () => {
|
||||
mockListsRepo.updateItem.mockImplementation((a, b, c, d) => Promise.resolve(d));
|
||||
const res = await service.updateItem('list1', 'hh1', 'itemA', { checked: false }, 'userIdX');
|
||||
expect(res.checkedAt).toBeUndefined();
|
||||
expect(res.checkedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError if item/list is missing on update', async () => {
|
||||
mockListsRepo.updateItem.mockResolvedValue(null);
|
||||
await expect(service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'u1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('does not touch timestamps if checked is not provided', async () => {
|
||||
mockListsRepo.updateItem.mockResolvedValue({});
|
||||
await service.updateItem('list1', 'hh1', 'itemA', { quantity: 5 } as any, 'u1');
|
||||
expect(mockListsRepo.updateItem).toHaveBeenCalledWith(
|
||||
'list1',
|
||||
'hh1',
|
||||
'itemA',
|
||||
{ quantity: 5 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('removes item from list repository', async () => {
|
||||
mockListsRepo.removeItem.mockResolvedValue({ _id: 'list1' });
|
||||
await service.removeItem('list1', 'hh1', 'itemA');
|
||||
expect(mockListsRepo.removeItem).toHaveBeenCalledWith('list1', 'hh1', 'itemA');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if list not found on removeItem', async () => {
|
||||
mockListsRepo.removeItem.mockResolvedValue(null);
|
||||
await expect(service.removeItem('list1', 'hh1', 'itemA')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes the shopping list', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockListsRepo.delete.mockResolvedValue(true);
|
||||
await service.delete('list1', 'hh1');
|
||||
expect(mockListsRepo.delete).toHaveBeenCalledWith('list1', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromMealPlan', () => {
|
||||
it('runs shopping gap report and populates distinct grocery array linked back to source plan', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp1', weekStartDate: '2026-05-18' });
|
||||
mockGapService.calculateGap.mockResolvedValue({
|
||||
missingItems: [
|
||||
{ productId: 'gapProd', missingQuantity: 5, unit: 'g', category: 'dairy' }
|
||||
]
|
||||
});
|
||||
mockPricesService.estimatePrice.mockResolvedValue(2);
|
||||
mockListsRepo.create.mockResolvedValue({ _id: 'newList1' });
|
||||
|
||||
const res = await service.createFromMealPlan('mp1', 'hh1', 'userIdZ');
|
||||
|
||||
expect(mockGapService.calculateGap).toHaveBeenCalledWith('hh1', 'mp1');
|
||||
expect(mockListsRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mealPlanId: 'mp1',
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
productId: 'gapProd',
|
||||
quantity: 5,
|
||||
estimatedPrice: 2,
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
// Assert link-back invocation
|
||||
expect(mockMealPlanRepo.update).toHaveBeenCalledWith('mp1', 'hh1', {
|
||||
shoppingListId: 'newList1',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundError if plan is not found', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.createFromMealPlan('mpMissing', 'hh1', 'u1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('handles missing estimated prices when creating from plan', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp2', weekStartDate: '2026-05-18' });
|
||||
mockGapService.calculateGap.mockResolvedValue({
|
||||
missingItems: [{ productId: 'gapProd2', missingQuantity: 3, unit: 'g', category: 'produce' }]
|
||||
});
|
||||
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newList2' }));
|
||||
|
||||
const res = await service.createFromMealPlan('mp2', 'hh1', 'u1');
|
||||
expect(res.items[0].estimatedPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncCheckedToPantry', () => {
|
||||
it('iterates checked items, creating pantry items and recording actual prices in ledger', async () => {
|
||||
const mockList = {
|
||||
_id: 'list1',
|
||||
preferredStoreId: 'storeA',
|
||||
items: [
|
||||
{
|
||||
id: 'itmA',
|
||||
productId: 'p1',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 2,
|
||||
unit: 'g',
|
||||
actualPrice: 15.50,
|
||||
}
|
||||
]
|
||||
};
|
||||
mockListsRepo.findById.mockResolvedValue(mockList);
|
||||
|
||||
const summary = await service.syncCheckedToPantry('list1', 'hh1', 'userAlpha');
|
||||
|
||||
// 1. Verify pantry promotion
|
||||
expect(mockPantryService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 'p1',
|
||||
quantity: 2,
|
||||
purchasePrice: 15.50,
|
||||
storeId: 'storeA',
|
||||
}),
|
||||
'hh1',
|
||||
'userAlpha'
|
||||
);
|
||||
|
||||
// 2. Verify point-in-time ledger price logging
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 'p1',
|
||||
price: 15.50,
|
||||
storeId: 'storeA',
|
||||
}),
|
||||
'hh1',
|
||||
'userAlpha'
|
||||
);
|
||||
|
||||
// 3. Verify completion bit toggled in list subdocument
|
||||
expect(mockListsRepo.updateItem).toHaveBeenCalledWith('list1', 'hh1', 'itmA', {
|
||||
addedToPantry: true,
|
||||
});
|
||||
|
||||
expect(summary.addedCount).toBe(1);
|
||||
expect(summary.pricesLogged).toBe(1);
|
||||
});
|
||||
|
||||
it('handles item-specific stores and skips pricing logs when no store identifier exists', async () => {
|
||||
const mockList = {
|
||||
_id: 'list2',
|
||||
items: [
|
||||
{
|
||||
id: 'itmB',
|
||||
productId: 'p2',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 1,
|
||||
actualPrice: 10.00,
|
||||
storeId: 'itemStoreB',
|
||||
},
|
||||
{
|
||||
id: 'itmC',
|
||||
productId: 'p3',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 1,
|
||||
actualPrice: 5.00,
|
||||
}
|
||||
]
|
||||
};
|
||||
mockListsRepo.findById.mockResolvedValue(mockList);
|
||||
|
||||
const summary = await service.syncCheckedToPantry('list2', 'hh1', 'userAlpha');
|
||||
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledTimes(1);
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 'p2',
|
||||
price: 10.00,
|
||||
storeId: 'itemStoreB',
|
||||
}),
|
||||
'hh1',
|
||||
'userAlpha'
|
||||
);
|
||||
expect(summary.addedCount).toBe(2);
|
||||
expect(summary.pricesLogged).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoreComparison', () => {
|
||||
it('collates individual store deviation lists to rank optimized single store trips', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }]
|
||||
});
|
||||
mockPricesService.compareStores.mockResolvedValue([
|
||||
{ storeId: 'sA', storeName: 'Walmart', latestPrice: 10 },
|
||||
{ storeId: 'sB', storeName: 'Whole Foods', latestPrice: 18 },
|
||||
]);
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions).toHaveLength(2);
|
||||
expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart');
|
||||
expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(10);
|
||||
});
|
||||
|
||||
it('handles missing items in comparison', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }, { productId: 'p2' }]
|
||||
});
|
||||
// Store only has p1, p2 is missing
|
||||
mockPricesService.compareStores.mockImplementation(async (id) => {
|
||||
if (id === 'p1') return [{ storeId: 'sA', storeName: 'Walmart', latestPrice: 10 }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions[0].itemsMissing).toContain('p2');
|
||||
});
|
||||
|
||||
it('covers sorting tie breakers and default store name fallbacks', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }]
|
||||
});
|
||||
|
||||
mockPricesService.compareStores.mockResolvedValue([
|
||||
{ storeId: 'sA', storeName: '', latestPrice: 10 },
|
||||
{ storeId: 'sB', storeName: 'Cheaper Store', latestPrice: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(result.singleStoreOptions).toHaveLength(2);
|
||||
|
||||
expect(result.singleStoreOptions[0].storeId).toBe('sB');
|
||||
expect(result.singleStoreOptions[1].storeName).toBe('Store');
|
||||
});
|
||||
|
||||
it('handles stores offering pricing for multiple items in the basket', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }, { productId: 'p2' }]
|
||||
});
|
||||
|
||||
mockPricesService.compareStores.mockImplementation(async (id) => {
|
||||
return [{ storeId: 'sC', storeName: 'Combo Store', latestPrice: id === 'p1' ? 5 : 7 }];
|
||||
});
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions).toHaveLength(1);
|
||||
expect(comparison.singleStoreOptions[0].itemsCovered).toBe(2);
|
||||
expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue