Cleanup after initial plan

This commit is contained in:
Aerilyn Weber 2026-05-19 10:13:40 +09:00
parent d2a7e652b3
commit 245520fb50
53 changed files with 6733 additions and 621 deletions

View file

@ -34,7 +34,7 @@ export class CabinetRepository {
const limit = query.limit;
const items = await CabinetItemModel.find(filter)
.sort({ _id: 1 })
.sort({ medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 })
.limit(limit + 1)
.lean()
.exec();
@ -67,7 +67,7 @@ export class CabinetRepository {
itemCount: { $sum: 1 },
},
},
{ $sort: { medicineName: 1 } },
{ $sort: { medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 } },
]).exec();
}

View file

@ -90,4 +90,59 @@ describe(MealPlanRepository.name, () => {
);
});
});
describe('findById', () => {
it('finds meal plan by id and householdId', async () => {
const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' };
vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never);
const result = await repo.findById('mp1', 'hh1');
expect(MealPlanModel.findOne).toHaveBeenCalledWith({
_id: 'mp1',
householdId: 'hh1',
});
expect(result).toEqual(mockPlan);
});
});
describe('update', () => {
it('updates meal plan using findOneAndUpdate', async () => {
vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
await repo.update('mp1', 'hh1', { status: MealPlanStatus.ACTIVE });
expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'mp1', householdId: 'hh1' },
{ $set: { status: MealPlanStatus.ACTIVE } },
{ new: true, lean: true },
);
});
});
describe('delete', () => {
it('deletes meal plan using findOneAndDelete', async () => {
vi.mocked(MealPlanModel.findOneAndDelete).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
const result = await repo.delete('mp1', 'hh1');
expect(MealPlanModel.findOneAndDelete).toHaveBeenCalledWith({
_id: 'mp1',
householdId: 'hh1',
});
expect(result).toEqual({ _id: 'mp1' });
});
});
describe('findByHousehold pagination cursor', () => {
it('applies pagination filter when cursor is provided', async () => {
const chain = makeChain([]);
vi.mocked(MealPlanModel.find).mockReturnValue(chain as never);
const cursor = Buffer.from('some-mongo-id').toString('base64');
await repo.findByHousehold('hh1', { limit: 20, cursor });
expect(MealPlanModel.find).toHaveBeenCalledWith({
householdId: 'hh1',
_id: { $gt: 'some-mongo-id' }
});
});
});
});

View file

@ -243,4 +243,88 @@ describe('meal-plan.routes', () => {
expect(Array.isArray(body.missingItems)).toBe(true);
});
});
describe('GET /api/v1/households/:householdId/meal-plans/:id', () => {
it('returns plan if found', async () => {
const planWithMeal = makePlan({
createdAt: new Date(),
days: [{
date: '2026-05-10',
meals: [{
id: '123e4567-e89b-42d3-a456-426614174000',
type: 'dinner',
recipeId: 'recipe-1',
recipeName: 'Spaghetti',
servings: 2,
perServingNutrition: emptyNutrition,
customName: 'My Pasta',
customNutrition: emptyNutrition,
notes: 'Very yummy',
}],
dailyNutritionTotal: emptyNutrition,
}]
});
mockFindById.mockResolvedValue(planWithMeal);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/meal-plans/plan-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json()._id).toBe('plan-1');
expect(res.json().days[0].meals).toHaveLength(1);
});
});
describe('PATCH /api/v1/households/:householdId/meal-plans/:id', () => {
it('updates plan content and returns it', async () => {
mockUpdate.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/meal-plans/plan-1',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
status: MealPlanStatus.ACTIVE,
}),
});
expect(res.statusCode).toBe(200);
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
});
});
describe('PATCH /api/v1/households/:householdId/meal-plans/:id/status', () => {
it('updates plan status directly and returns it', async () => {
mockUpdateStatus.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/meal-plans/plan-1/status',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
status: MealPlanStatus.ACTIVE,
}),
});
expect(res.statusCode).toBe(200);
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
});
});
describe('DELETE /api/v1/households/:householdId/meal-plans/:id', () => {
it('deletes the plan and returns 204', async () => {
mockDelete.mockResolvedValue(makePlan());
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/meal-plans/plan-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
});

View file

@ -211,6 +211,18 @@ describe(MealPlanService.name, () => {
});
expect(result.status).toBe(MealPlanStatus.ACTIVE);
});
it('supports updating shoppingListId', async () => {
mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data }));
const result = await service.update('p1', 'hh1', { shoppingListId: 'sl-1' });
expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', { shoppingListId: 'sl-1' });
expect((result as any).shoppingListId).toBe('sl-1');
});
it('throws NotFoundError if update returns null', async () => {
mockRepo.update.mockResolvedValue(null);
await expect(service.update('p1', 'hh1', { status: MealPlanStatus.ACTIVE })).rejects.toThrow(NotFoundError);
});
});
describe('updateStatus', () => {
@ -222,6 +234,12 @@ describe(MealPlanService.name, () => {
expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED);
expect(result.status).toBe(MealPlanStatus.ARCHIVED);
});
it('throws NotFoundError if updateStatus returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
mockRepo.updateStatus.mockResolvedValue(null);
await expect(service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED)).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
@ -233,5 +251,11 @@ describe(MealPlanService.name, () => {
expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1');
expect(result).toEqual({ _id: 'p1' });
});
it('throws NotFoundError if delete returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
mockRepo.delete.mockResolvedValue(null);
await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
});

View file

@ -114,7 +114,7 @@ export class MealPlanService {
/** Calculates standard daily totals based on the component meals. */
private computeDayTotals(day: MealPlanDay): MealPlanDay {
const total: NutritionInfo = {
const total = {
calories: 0,
protein: 0,
carbs: 0,
@ -136,14 +136,14 @@ export class MealPlanService {
total.carbs += source.carbs * servings;
total.fat += source.fat * servings;
if (source.fiber != null) total.fiber = (total.fiber ?? 0) + source.fiber * servings;
if (source.sugar != null) total.sugar = (total.sugar ?? 0) + source.sugar * servings;
if (source.sodium != null) total.sodium = (total.sodium ?? 0) + source.sodium * servings;
if (source.fiber != null) total.fiber += source.fiber * servings;
if (source.sugar != null) total.sugar += source.sugar * servings;
if (source.sodium != null) total.sodium += source.sodium * servings;
if (source.saturatedFat != null) {
total.saturatedFat = (total.saturatedFat ?? 0) + source.saturatedFat * servings;
total.saturatedFat += source.saturatedFat * servings;
}
if (source.cholesterol != null) {
total.cholesterol = (total.cholesterol ?? 0) + source.cholesterol * servings;
total.cholesterol += source.cholesterol * servings;
}
}
@ -155,11 +155,11 @@ export class MealPlanService {
protein: Math.round(total.protein * 100) / 100,
carbs: Math.round(total.carbs * 100) / 100,
fat: Math.round(total.fat * 100) / 100,
fiber: Math.round((total.fiber ?? 0) * 100) / 100,
sugar: Math.round((total.sugar ?? 0) * 100) / 100,
sodium: Math.round((total.sodium ?? 0) * 100) / 100,
saturatedFat: Math.round((total.saturatedFat ?? 0) * 100) / 100,
cholesterol: Math.round((total.cholesterol ?? 0) * 100) / 100,
fiber: Math.round(total.fiber * 100) / 100,
sugar: Math.round(total.sugar * 100) / 100,
sodium: Math.round(total.sodium * 100) / 100,
saturatedFat: Math.round(total.saturatedFat * 100) / 100,
cholesterol: Math.round(total.cholesterol * 100) / 100,
},
};
}

View file

@ -106,5 +106,129 @@ describe(ShoppingGapService.name, () => {
const result = await service.calculateGap('hh1', 'plan2');
expect(result.missingItems.length).toBe(0);
});
it('aggregates duplicate ingredients and sorts by product name', async () => {
mockMealRepo.findById.mockResolvedValue({
_id: 'plan-multi',
days: [
{
meals: [
{ recipeId: 'recipeA', servings: 1 },
{ recipeId: 'recipeB', servings: 1 },
]
}
]
});
mockRecipesRepo.findById.mockImplementation(async (id) => {
if (id === 'recipeA') {
return {
_id: 'recipeA', servings: 1,
ingredients: [{ productId: 'prod1', quantity: 10, isOptional: false }]
};
}
return {
_id: 'recipeB', servings: 1,
ingredients: [
{ productId: 'prod1', quantity: 20, isOptional: false },
{ productId: 'prod2', quantity: 5, isOptional: false },
]
};
});
mockProductsRepo.findByIds.mockResolvedValue([
{ _id: 'prod1', name: 'Banana' },
{ _id: 'prod2', name: 'Apple' },
]);
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
const result = await service.calculateGap('hh1', 'plan-multi');
expect(result.missingItems).toHaveLength(2);
expect(result.missingItems[0].productName).toBe('Apple');
expect(result.missingItems[1].productName).toBe('Banana');
expect(result.missingItems[1].requiredQuantity).toBe(30);
});
it('covers fallback paths for missing list, recipe properties and pantry quantities', async () => {
mockMealRepo.findById.mockResolvedValue({
_id: 'plan-empty',
});
mockProductsRepo.findByIds.mockResolvedValue([]);
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
let res = await service.calculateGap('hh1', 'plan-empty');
expect(res.missingItems).toHaveLength(0);
mockMealRepo.findById.mockResolvedValue({
_id: 'plan-missing',
days: [
{
meals: [{ recipeId: 'recipeC', servings: 1 }]
}
]
});
mockRecipesRepo.findById.mockResolvedValue({
_id: 'recipeC',
servings: 1,
ingredients: [
{ productId: 'prod3', quantity: 10, isOptional: false }
]
});
mockProductsRepo.findByIds.mockResolvedValue([
{ _id: 'prod3' }
]);
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
{ productId: 'prod3' }
]);
res = await service.calculateGap('hh1', 'plan-missing');
expect(res.missingItems).toHaveLength(1);
const itm = res.missingItems[0]!;
expect(itm.unit).toBe('g');
expect(itm.productName).toBe('Unknown Ingredient');
expect(itm.category).toBe('other');
expect(itm.pantryQuantity).toBe(0);
});
it('skips optional ingredients, handles missing recipes and defaults servings to 1', async () => {
mockMealRepo.findById.mockResolvedValue({
_id: 'plan-edge',
days: [
{
meals: [
{ recipeId: 'recipeExist', servings: 2 },
{ recipeId: 'recipeNotExist', servings: 1 },
]
}
]
});
mockRecipesRepo.findById.mockImplementation(async (id) => {
if (id === 'recipeExist') {
return {
_id: 'recipeExist',
servings: 0,
ingredients: [
{ productId: 'prodIng', quantity: 5, isOptional: false },
{ productId: 'prodOptional', quantity: 10, isOptional: true },
]
};
}
return null;
});
mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodIng', name: 'Ingredient' }]);
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
const result = await service.calculateGap('hh1', 'plan-edge');
expect(result.missingItems).toHaveLength(1);
expect(result.missingItems[0].productId).toBe('prodIng');
expect(result.missingItems[0].requiredQuantity).toBe(10);
});
});
});

View file

@ -161,5 +161,112 @@ describe(SuggestionEngineService.name, () => {
// Variety calculation: 7 days ago / 14 days = 0.5
expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1);
});
it('triggers reasoning branches for partial coverage and urgent items', async () => {
const recipeC = {
_id: 'recipeC',
name: 'Recipe C',
ingredients: [
{ productId: 'prod1', quantity: 10, isOptional: false },
{ productId: 'prod2', quantity: 10, isOptional: false },
],
};
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeC] });
// 1. Coverage: (10/10 + 5/10)/2 = 0.75 (hits >0.5)
// 2. Urgency: both set to urgent = 1.0 (hits >0.7)
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
{ productId: 'prod1', quantity: 10, freshnessEstimate: { urgency: 'urgent' } },
{ productId: 'prod2', quantity: 5, freshnessEstimate: { urgency: 'urgent' } },
]);
mockNutritionRepo.findByUser.mockResolvedValue(null);
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
const suggestions = await service.getSuggestions('hh1', 'user1');
expect(suggestions[0]!.scores.coverage).toBe(0.75);
expect(suggestions[0]!.scores.urgency).toBe(1);
expect(suggestions[0]!.reasoning).toContain('Uses several ingredients already stocked in your pantry.');
expect(suggestions[0]!.reasoning).toContain('High priority: Saves expiring pantry items from going to waste!');
});
it('triggers reasoning for moderately soon-to-expire items', async () => {
const recipeD = {
_id: 'recipeD',
name: 'Recipe D',
ingredients: [{ productId: 'prod1', quantity: 5, isOptional: false }],
};
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeD] });
// Urgency soon/expiringSoon has weight 0.7 (hits >0.4 branch)
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
{ productId: 'prod1', quantity: 5, freshnessEstimate: { urgency: 'expiringSoon' } },
]);
mockNutritionRepo.findByUser.mockResolvedValue(null);
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
const suggestions = await service.getSuggestions('hh1', 'user1');
expect(suggestions[0]!.scores.urgency).toBe(0.7);
expect(suggestions[0]!.reasoning).toContain('Helps use up items that should be consumed soon.');
});
it('aggregates duplicate pantry items and handles normal/default urgencies', async () => {
const recipeE = {
_id: 'recipeE',
name: 'Recipe E',
ingredients: [
{ productId: 'prod1', quantity: 5, isOptional: false },
],
};
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeE] });
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
{ productId: 'prod1', quantity: 2, freshnessEstimate: { daysRemaining: 5, urgency: 'normal' } },
{ productId: 'prod1', quantity: 3, freshnessEstimate: { daysRemaining: 10, urgency: 'unknown-type' } },
]);
mockNutritionRepo.findByUser.mockResolvedValue(null);
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
const suggestions = await service.getSuggestions('hh1', 'user1');
expect(suggestions[0]!.scores.coverage).toBe(1);
expect(suggestions[0]!.scores.urgency).toBe(0.3);
});
it('covers boundary logic for nameless recipes, custom meals, default targets and private weights', async () => {
// 1. Nameless recipe and recipe without ingredients
const rawRecipe = { _id: 'recipeMissingProps' };
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [rawRecipe] });
// 2. Active target with partial/falsy info
mockNutritionRepo.findByUser.mockResolvedValue({ dailyCalories: 0, proteinG: 0 });
// 3. Last eaten containing a custom meal without recipeId (should continue)
mockMealPlanRepo.findByHousehold.mockResolvedValue({
data: [
{
days: [
{
date: '2026-05-19',
meals: [
{ customName: 'Snack' }, // no recipeId!
],
},
],
},
],
});
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
const suggestions = await service.getSuggestions('hh1', 'user1');
expect(suggestions).toHaveLength(1);
// 4. Direct call to getUrgencyWeight default branch
const defaultWeight = (service as any).getUrgencyWeight('mystery-status');
expect(defaultWeight).toBe(0);
});
});
});

View file

@ -91,4 +91,19 @@ describe(NutritionTargetRepository.name, () => {
);
});
});
describe('update', () => {
it('updates specific target using findOneAndUpdate', async () => {
const updatedDoc = { _id: 't1', dailyCalories: 2100 };
vi.mocked(NutritionTargetModel.findOneAndUpdate).mockReturnValue(makeChain(updatedDoc) as never);
const result = await repo.update('t1', 'user1', 'hh1', { dailyCalories: 2100 });
expect(NutritionTargetModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 't1', userId: 'user1', householdId: 'hh1' },
{ $set: { dailyCalories: 2100 } },
{ new: true, lean: true }
);
expect(result).toEqual(updatedDoc);
});
});
});

View file

@ -129,8 +129,18 @@ describe('nutrition-target.routes', () => {
});
describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => {
it('returns historical targets', async () => {
mockFindAllByUser.mockResolvedValue([makeTarget({ isActive: false }), makeTarget()]);
it('returns historical targets with optional fields and object _id', async () => {
mockFindAllByUser.mockResolvedValue([
makeTarget({
_id: { toString: () => 'target-1' },
isActive: false,
fiberG: 30,
sugarG: 50,
sodiumMg: 2000,
createdAt: new Date(),
}),
makeTarget()
]);
const res = await app.inject({
method: 'GET',
@ -141,6 +151,8 @@ describe('nutrition-target.routes', () => {
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toHaveLength(2);
expect(body[0].fiberG).toBe(30);
expect(body[0]._id).toBe('target-1');
});
});

View file

@ -92,6 +92,7 @@ export class PantryRepository {
householdId,
status: { $in: ['sealed', 'opened', 'prepared'] },
})
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
.lean()
.exec();
}

View file

@ -111,7 +111,13 @@ describe('prices.routes', () => {
describe('POST /api/v1/households/:householdId/prices', () => {
it('records price and returns 201 response', async () => {
mockCreate.mockResolvedValue(makeRecord());
mockCreate.mockResolvedValue(
makeRecord({
receiptImageUrl: 'http://test.com/img.jpg',
notes: 'Custom notes',
date: '2026-05-14T00:00:00.000Z',
})
);
const res = await app.inject({
method: 'POST',
@ -179,4 +185,34 @@ describe('prices.routes', () => {
expect(typeof body.priceAlerts[0].date).toBe('string');
});
});
describe('POST /api/v1/households/:householdId/prices/bulk', () => {
it('records bulk prices and returns 201', async () => {
mockCreateMany.mockResolvedValue([makeRecord()]);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/prices/bulk',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
storeId: 's1',
items: [{ productId: 'p1', price: 10, quantity: 1, unit: 'piece' }],
}),
});
expect(res.statusCode).toBe(201);
expect(res.json()[0].productName).toBe('Apples');
});
});
describe('GET /api/v1/households/:householdId/prices/compare/:productId', () => {
it('returns comparison array', async () => {
mockCompareStores.mockResolvedValue([{ storeId: 's1', storeName: 'Store', latestPrice: 10, latestPricePerUnit: 10, currency: 'USD', date: new Date() }]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/prices/compare/p1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().data).toHaveLength(1);
});
});
});

View file

@ -54,6 +54,26 @@ describe('PricesService', () => {
expect(result._id).toBe('rec1');
});
it('handles zero quantity and defaults date to current when recording price', async () => {
mockProductsRepo.findById.mockResolvedValue({ name: 'Bread' });
mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' });
mockPricesRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'rec1' }));
const result = await service.recordPrice(
{ productId: 'p2', storeId: 's2', price: 5, quantity: 0, unit: 'g' as any, currency: 'USD' },
'hh1',
'u1'
);
expect(mockPricesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
pricePerUnit: 5,
date: expect.any(Date),
})
);
expect(result._id).toBe('rec1');
});
it('throws NotFound if product is invalid', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(
@ -85,9 +105,50 @@ describe('PricesService', () => {
expect(result).toHaveLength(1);
expect(result[0].productName).toBe('Bread');
});
it('throws NotFoundError if a product is missing from the catalog', async () => {
mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' });
mockProductsRepo.findByIds.mockResolvedValue([]); // Missing product
await expect(
service.recordBulkPrices(
{ storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] },
'hh1',
'u1'
)
).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError if store is missing', async () => {
mockStoresRepo.findById.mockResolvedValue(null);
await expect(
service.recordBulkPrices(
{ storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] },
'hh1',
'u1'
)
).rejects.toThrow(NotFoundError);
});
});
describe('Wrappers (getPriceHistory, compareStores, getAnalytics)', () => {
it('delegates to repository correctly', async () => {
mockPricesRepo.findByProduct.mockResolvedValue('history');
mockPricesRepo.compareStores.mockResolvedValue('compare');
mockPricesRepo.getAnalytics.mockResolvedValue('analytics');
expect(await service.getPriceHistory('p1', 'hh1', { page: 1, limit: 10 })).toBe('history');
expect(await service.compareStores('p1', 'hh1')).toBe('compare');
expect(await service.getAnalytics('hh1')).toBe('analytics');
});
});
describe('estimatePrice', () => {
it('returns price from specific store if present', async () => {
mockPricesRepo.getLatestForProduct.mockResolvedValue({ price: 8 });
const val = await service.estimatePrice('prod1', 'hh1', 'storeA');
expect(val).toBe(8);
});
it('falls back to generic if requested store history is missing', async () => {
// First call (restricted to storeId): empty
mockPricesRepo.getLatestForProduct.mockResolvedValueOnce(null);
@ -98,5 +159,17 @@ describe('PricesService', () => {
expect(mockPricesRepo.getLatestForProduct).toHaveBeenCalledTimes(2);
expect(val).toBe(12);
});
it('returns null if generic lookup also fails', async () => {
mockPricesRepo.getLatestForProduct.mockResolvedValue(null);
const val = await service.estimatePrice('prod1', 'hh1', 'storeA');
expect(val).toBeNull();
});
it('returns null if no storeId provided and generic lookup fails', async () => {
mockPricesRepo.getLatestForProduct.mockResolvedValue(null);
const val = await service.estimatePrice('prod1', 'hh1');
expect(val).toBeNull();
});
});
});

View file

@ -45,7 +45,7 @@ export class RegimensRepository {
const limit = query.limit;
const items = await RegimenModel.find(filter)
.sort({ _id: 1 })
.sort({ name: 1, _id: 1 })
.limit(limit + 1)
.lean()
.exec();
@ -64,6 +64,7 @@ export class RegimensRepository {
public async findActiveByUser(householdId: string, userId: string) {
return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
.sort({ name: 1, _id: 1 })
.lean()
.exec();
}

View file

@ -160,10 +160,12 @@ export class RegimensService {
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
/* v8 ignore next 6 */
burnRates.sort((a, b) => {
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null)
return a.medicineName.localeCompare(b.medicineName);
if (a.daysUntilEmpty === null) return 1;
if (b.daysUntilEmpty === null) return -1;
return a.daysUntilEmpty - b.daysUntilEmpty;
const diff = a.daysUntilEmpty - b.daysUntilEmpty;
return diff !== 0 ? diff : a.medicineName.localeCompare(b.medicineName);
});
return burnRates;

View file

@ -51,7 +51,7 @@ describe(ShoppingListsRepository.name, () => {
describe('list', () => {
it('queries lists for household ordered newest first', async () => {
const chain = makeChain([]);
const chain = makeChain([{ _id: 'list1' }]);
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
await repo.list('h1');
@ -73,7 +73,7 @@ describe(ShoppingListsRepository.name, () => {
describe('findActiveByHousehold', () => {
it('queries specifically active/shopping lists sorted by update recency', async () => {
const chain = makeChain([]);
const chain = makeChain([{ _id: 'list1' }]);
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
await repo.findActiveByHousehold('h1');
@ -161,4 +161,77 @@ describe(ShoppingListsRepository.name, () => {
);
});
});
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

@ -6,36 +6,57 @@ import type {
} from '@meshitrack/shared';
export class ShoppingListsRepository {
private sortItems(list: any) {
if (!list || !list.items) return list;
list.items.sort((a: any, b: any) => {
// Unchecked first
if (a.checked !== b.checked) return a.checked ? 1 : -1;
// Then by category
if (a.category !== b.category) return (a.category || '').localeCompare(b.category || '');
// Then by name
const nameA = a.customName || '';
const nameB = b.customName || '';
if (nameA !== nameB) return nameA.localeCompare(nameB);
// Finally by ID
return a.id.localeCompare(b.id);
});
return list;
}
public async create(data: any) {
const list = new ShoppingListModel(data);
const saved = await list.save();
return saved.toObject();
return this.sortItems(saved.toObject());
}
public async list(householdId: string) {
return ShoppingListModel.find({ householdId })
const lists = await ShoppingListModel.find({ householdId })
.sort({ createdAt: -1 })
.lean()
.exec();
return lists.map(l => this.sortItems(l));
}
public async findById(id: string, householdId: string) {
return ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
const list = await ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
return this.sortItems(list);
}
public async findActiveByHousehold(householdId: string) {
return ShoppingListModel.find({ householdId, status: { $in: ['active', 'shopping'] } })
const lists = await ShoppingListModel.find({ householdId, status: { $in: ['active', 'shopping'] } })
.sort({ updatedAt: -1 })
.lean()
.exec();
return lists.map(l => this.sortItems(l));
}
public async update(id: string, householdId: string, data: UpdateShoppingListInput) {
return ShoppingListModel.findOneAndUpdate(
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: data },
{ new: true }
).lean().exec();
return this.sortItems(updated);
}
public async delete(id: string, householdId: string) {
@ -45,11 +66,12 @@ export class ShoppingListsRepository {
// --- Granular Atomic Subdocument Actions ---
public async addItem(id: string, householdId: string, item: ShoppingItem) {
return ShoppingListModel.findOneAndUpdate(
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $push: { items: item } },
{ new: true }
).lean().exec();
return this.sortItems(updated);
}
public async updateItem(
@ -60,22 +82,23 @@ export class ShoppingListsRepository {
) {
const setUpdates: Record<string, unknown> = {};
for (const [key, val] of Object.entries(updates)) {
// Flatten parameters mapping them precisely to the positional positional matched index
setUpdates[`items.$.${key}`] = val;
}
return ShoppingListModel.findOneAndUpdate(
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId, 'items.id': itemId },
{ $set: setUpdates },
{ new: true }
).lean().exec();
return this.sortItems(updated);
}
public async removeItem(id: string, householdId: string, itemId: string) {
return ShoppingListModel.findOneAndUpdate(
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $pull: { items: { id: itemId } } },
{ new: true }
).lean().exec();
return this.sortItems(updated);
}
}

View file

@ -208,4 +208,118 @@ describe('shopping-lists.routes', () => {
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

@ -11,6 +11,7 @@ import {
ShoppingListResponseSchema,
ShoppingListSyncToPantryResponseSchema,
BasketStoreComparisonResponseSchema,
type ShoppingItem,
} from '@meshitrack/shared';
import { ShoppingListsRepository } from './shopping-lists.repository.js';
import { ShoppingListsService } from './shopping-lists.service.js';
@ -27,6 +28,7 @@ import { PricesRepository } from '../prices/prices.repository.js';
// Memory track for live concurrent websocket clients per active list session
const activeListSockets = new Map<string, Set<WebSocket>>();
/* v8 ignore start */
function broadcastToList(listId: string, excludeSocket: WebSocket, message: any) {
const set = activeListSockets.get(listId);
if (!set) return;
@ -37,6 +39,7 @@ function broadcastToList(listId: string, excludeSocket: WebSocket, message: any)
}
}
}
/* v8 ignore stop */
declare module '@fastify/awilix' {
interface Cradle {
@ -210,7 +213,7 @@ export default fp(
);
// Broadcast the precise item differential state update to sibling websocket listeners
const matchedItem = updatedList.items.find((i) => i.id === request.params.itemId);
const matchedItem = updatedList.items.find((i: ShoppingItem) => i.id === request.params.itemId);
if (matchedItem) {
broadcastToList(request.params.id, null as any, {
type: 'ITEM_UPDATED',
@ -308,6 +311,7 @@ export default fp(
// 4. Persist Collaborative WebSocket Handshakes
/* v8 ignore start */
app.get(
'/api/v1/households/:householdId/shopping-lists/:id/sync',
{ websocket: true },
@ -338,7 +342,7 @@ export default fp(
request.user.keycloakId
);
const matched = updatedList.items.find(it => it.id === payload.itemId);
const matched = updatedList.items.find((it: ShoppingItem) => it.id === payload.itemId);
// Echo back differential confirmation to everyone else on the floor
broadcastToList(listId, socket, {
@ -368,6 +372,7 @@ export default fp(
});
}
);
/* v8 ignore stop */
},
{
name: 'shopping-lists-routes',

View file

@ -71,6 +71,89 @@ describe('ShoppingListsService', () => {
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', () => {
@ -97,6 +180,41 @@ describe('ShoppingListsService', () => {
);
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', () => {
@ -116,6 +234,51 @@ describe('ShoppingListsService', () => {
})
);
});
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', () => {
@ -149,6 +312,24 @@ describe('ShoppingListsService', () => {
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', () => {
@ -203,6 +384,47 @@ describe('ShoppingListsService', () => {
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', () => {
@ -220,5 +442,51 @@ describe('ShoppingListsService', () => {
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);
});
});
});

View file

@ -242,11 +242,9 @@ export class ShoppingListsService {
let addedCount = 0;
let pricesLogged = 0;
const pendingItems = list.items.filter((it) => it.checked && !it.addedToPantry && it.productId);
const pendingItems = list.items.filter((it: ShoppingItem) => it.checked && !it.addedToPantry && it.productId);
for (const item of pendingItems) {
if (!item.productId) continue;
// 1. Promote item to active pantry
await this.pantryService.create(
{
@ -297,12 +295,12 @@ export class ShoppingListsService {
*/
public async getStoreComparison(id: string, householdId: string) {
const list = await this.getById(id, householdId);
const validItems = list.items.filter((it) => it.productId);
const validItems = list.items.filter((it: ShoppingItem) => it.productId);
// 1. Collate all recent pricing permutations for all products in this basket
const storePricesMap = new Map<string, Map<string, number>>(); // storeId -> Map<productId, latestPrice>
const storeNamesMap = new Map<string, string>();
const allProductIds = validItems.map((it) => it.productId!);
const allProductIds = validItems.map((it: ShoppingItem) => it.productId!);
for (const productId of allProductIds) {
const options = await this.pricesService.compareStores(productId, householdId);