Tests refactor

This commit is contained in:
Aerilyn Weber 2026-05-19 11:06:03 +09:00
parent 245520fb50
commit 99134d8556
165 changed files with 911 additions and 531 deletions

View file

@ -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' });
});
});
});

View file

@ -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();
});
});
});

View file

@ -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);
});
});
});