Full tests coverage

This commit is contained in:
Aerilyn Weber 2026-05-19 14:09:03 +09:00
parent 99134d8556
commit 02d782c3da
157 changed files with 1074 additions and 34670 deletions

View file

@ -233,5 +233,24 @@ describe(ShoppingListsRepository.name, () => {
expect((repo as any).sortItems(null)).toBeNull();
expect((repo as any).sortItems({ name: 'foo' })).toEqual({ name: 'foo' });
});
it('handles missing customName for a and b to achieve 100% branch coverage', () => {
const items = [
{ id: '2', category: 'Fruit', customName: 'Banana', checked: false },
{ id: '1', category: 'Fruit', customName: undefined, checked: false },
];
const result = (repo as any).sortItems({ items });
expect(result.items[0].id).toBe('1'); // undefined/null customName comes before 'Banana'
});
it('handles both customName undefined to tie-break by id', () => {
const items = [
{ id: 'B', category: 'Fruit', customName: undefined, checked: false },
{ id: 'A', category: 'Fruit', customName: undefined, checked: false },
];
const result = (repo as any).sortItems({ items });
expect(result.items[0].id).toBe('A');
});
});
});

View file

@ -39,36 +39,9 @@ vi.mock('../../../src/modules/shopping-lists/shopping-lists.repository.js', () =
},
}));
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/stores/stores.repository.js', () => ({
StoresRepository: class {
list = vi.fn();
},
}));
@ -189,26 +162,6 @@ describe('shopping-lists.routes', () => {
});
});
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());
@ -296,30 +249,4 @@ describe('shopping-lists.routes', () => {
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

@ -16,45 +16,15 @@ describe('ShoppingListsService', () => {
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);
it('populates initial items and auto-generates internal tracking UUIDs', async () => {
mockListsRepo.create.mockImplementation(arg => arg);
const result = await service.create(
@ -68,59 +38,13 @@ describe('ShoppingListsService', () => {
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBeDefined();
expect(result.items[0].estimatedPrice).toBe(5);
expect(result.totalEstimatedCost).toBe(5);
expect(result.items[0].productId).toBe('p1');
});
it('handles missing items and retains explicit categories without hitting product info', async () => {
it('handles missing items gracefully during creation', 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');
});
});
@ -138,6 +62,12 @@ describe('ShoppingListsService', () => {
mockListsRepo.findById.mockResolvedValue(null);
await expect(service.getById('list1', 'hh1')).rejects.toThrow(NotFoundError);
});
it('returns the list if found', async () => {
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
const res = await service.getById('list1', 'hh1');
expect(res).toEqual({ _id: 'list1' });
});
});
describe('update', () => {
@ -157,10 +87,8 @@ describe('ShoppingListsService', () => {
});
describe('addItem', () => {
it('hydrates single product pricing and pushes to list repository', async () => {
it('pushes new item 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', {
@ -174,47 +102,17 @@ describe('ShoppingListsService', () => {
'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', () => {
@ -280,213 +178,4 @@ describe('ShoppingListsService', () => {
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);
});
});
});