Cleanup after initial plan
This commit is contained in:
parent
d2a7e652b3
commit
245520fb50
53 changed files with 6733 additions and 621 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export class PantryRepository {
|
|||
householdId,
|
||||
status: { $in: ['sealed', 'opened', 'prepared'] },
|
||||
})
|
||||
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue