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

@ -0,0 +1,88 @@
---
name: grill-with-docs
description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions.
---
<what-to-do>
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
Ask the questions one at a time, waiting for feedback on each question before continuing.
If a question can be answered by exploring the codebase, explore the codebase instead.
</what-to-do>
<supporting-info>
## Domain awareness
During codebase exploration, also look for existing documentation:
### File structure
Most repos have a single context:
```
/
├── CONTEXT.md
├── docs/
│ └── adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:
```
/
├── CONTEXT-MAP.md
├── docs/
│ └── adr/ ← system-wide decisions
├── src/
│ ├── ordering/
│ │ ├── CONTEXT.md
│ │ └── docs/adr/ ← context-specific decisions
│ └── billing/
│ ├── CONTEXT.md
│ └── docs/adr/
```
Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.
## During the session
### Challenge against the glossary
When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
### Sharpen fuzzy language
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
### Discuss concrete scenarios
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
### Cross-reference with code
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
### Update CONTEXT.md inline
When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
### Offer ADRs sparingly
Only offer to create an ADR when all three are true:
1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
</supporting-info>

View file

@ -48,9 +48,14 @@ docker compose -f docker/docker-compose.yml down # Stop all services
## Implementation Workflow ## Implementation Workflow
Every feature or phase implementation must follow this "Vertical Slice" approach: Every feature or phase implementation MUST start with a rigorous planning and design phase:
1. **Shared Layer**: Define types, enums, and Zod v4 schemas in `packages/shared`. Add unit tests. 0. **Mandatory Planning (Grilling)**: Before starting any feature or bug fix, you MUST invoke the `grill-with-docs` skill.
- Run the skill using: `view_file` on `.agents/skills/grill-with-docs/SKILL.md` and follow its instructions.
- This session will stress-test your plan against the existing domain model, terminology, and documentation (`CONTEXT.md`, ADRs).
- Decisions must be crystallized and documentation (glossary/ADRs) updated before moving to implementation.
1. **Vertical Slice Implementation**: Follow this approach for the actual build:
2. **Database Layer**: Create the Mongoose schema and Repository in `packages/api`. All reads must use `.lean().exec()`. 2. **Database Layer**: Create the Mongoose schema and Repository in `packages/api`. All reads must use `.lean().exec()`.
3. **Service Layer**: Implement business logic in the Service class, using Awilix for constructor injection. Add unit tests. 3. **Service Layer**: Implement business logic in the Service class, using Awilix for constructor injection. Add unit tests.
4. **Route Layer**: Create the Fastify route plugin and register it. Add route tests (using `app.inject`). 4. **Route Layer**: Create the Fastify route plugin and register it. Add route tests (using `app.inject`).

View file

@ -34,7 +34,7 @@ export class CabinetRepository {
const limit = query.limit; const limit = query.limit;
const items = await CabinetItemModel.find(filter) const items = await CabinetItemModel.find(filter)
.sort({ _id: 1 }) .sort({ medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 })
.limit(limit + 1) .limit(limit + 1)
.lean() .lean()
.exec(); .exec();
@ -67,7 +67,7 @@ export class CabinetRepository {
itemCount: { $sum: 1 }, itemCount: { $sum: 1 },
}, },
}, },
{ $sort: { medicineName: 1 } }, { $sort: { medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 } },
]).exec(); ]).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); 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); 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', () => { describe('updateStatus', () => {
@ -222,6 +234,12 @@ describe(MealPlanService.name, () => {
expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED); expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED);
expect(result.status).toBe(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', () => { describe('delete', () => {
@ -233,5 +251,11 @@ describe(MealPlanService.name, () => {
expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1'); expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1');
expect(result).toEqual({ _id: 'p1' }); 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. */ /** Calculates standard daily totals based on the component meals. */
private computeDayTotals(day: MealPlanDay): MealPlanDay { private computeDayTotals(day: MealPlanDay): MealPlanDay {
const total: NutritionInfo = { const total = {
calories: 0, calories: 0,
protein: 0, protein: 0,
carbs: 0, carbs: 0,
@ -136,14 +136,14 @@ export class MealPlanService {
total.carbs += source.carbs * servings; total.carbs += source.carbs * servings;
total.fat += source.fat * servings; total.fat += source.fat * servings;
if (source.fiber != null) total.fiber = (total.fiber ?? 0) + source.fiber * servings; if (source.fiber != null) total.fiber += source.fiber * servings;
if (source.sugar != null) total.sugar = (total.sugar ?? 0) + source.sugar * servings; if (source.sugar != null) total.sugar += source.sugar * servings;
if (source.sodium != null) total.sodium = (total.sodium ?? 0) + source.sodium * servings; if (source.sodium != null) total.sodium += source.sodium * servings;
if (source.saturatedFat != null) { if (source.saturatedFat != null) {
total.saturatedFat = (total.saturatedFat ?? 0) + source.saturatedFat * servings; total.saturatedFat += source.saturatedFat * servings;
} }
if (source.cholesterol != null) { 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, protein: Math.round(total.protein * 100) / 100,
carbs: Math.round(total.carbs * 100) / 100, carbs: Math.round(total.carbs * 100) / 100,
fat: Math.round(total.fat * 100) / 100, fat: Math.round(total.fat * 100) / 100,
fiber: Math.round((total.fiber ?? 0) * 100) / 100, fiber: Math.round(total.fiber * 100) / 100,
sugar: Math.round((total.sugar ?? 0) * 100) / 100, sugar: Math.round(total.sugar * 100) / 100,
sodium: Math.round((total.sodium ?? 0) * 100) / 100, sodium: Math.round(total.sodium * 100) / 100,
saturatedFat: Math.round((total.saturatedFat ?? 0) * 100) / 100, saturatedFat: Math.round(total.saturatedFat * 100) / 100,
cholesterol: Math.round((total.cholesterol ?? 0) * 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'); const result = await service.calculateGap('hh1', 'plan2');
expect(result.missingItems.length).toBe(0); 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 // Variety calculation: 7 days ago / 14 days = 0.5
expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1); 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', () => { describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => {
it('returns historical targets', async () => { it('returns historical targets with optional fields and object _id', async () => {
mockFindAllByUser.mockResolvedValue([makeTarget({ isActive: false }), makeTarget()]); mockFindAllByUser.mockResolvedValue([
makeTarget({
_id: { toString: () => 'target-1' },
isActive: false,
fiberG: 30,
sugarG: 50,
sodiumMg: 2000,
createdAt: new Date(),
}),
makeTarget()
]);
const res = await app.inject({ const res = await app.inject({
method: 'GET', method: 'GET',
@ -141,6 +151,8 @@ describe('nutrition-target.routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = res.json(); const body = res.json();
expect(body).toHaveLength(2); 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, householdId,
status: { $in: ['sealed', 'opened', 'prepared'] }, status: { $in: ['sealed', 'opened', 'prepared'] },
}) })
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
.lean() .lean()
.exec(); .exec();
} }

View file

@ -111,7 +111,13 @@ describe('prices.routes', () => {
describe('POST /api/v1/households/:householdId/prices', () => { describe('POST /api/v1/households/:householdId/prices', () => {
it('records price and returns 201 response', async () => { 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({ const res = await app.inject({
method: 'POST', method: 'POST',
@ -179,4 +185,34 @@ describe('prices.routes', () => {
expect(typeof body.priceAlerts[0].date).toBe('string'); 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'); 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 () => { it('throws NotFound if product is invalid', async () => {
mockProductsRepo.findById.mockResolvedValue(null); mockProductsRepo.findById.mockResolvedValue(null);
await expect( await expect(
@ -85,9 +105,50 @@ describe('PricesService', () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].productName).toBe('Bread'); 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', () => { 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 () => { it('falls back to generic if requested store history is missing', async () => {
// First call (restricted to storeId): empty // First call (restricted to storeId): empty
mockPricesRepo.getLatestForProduct.mockResolvedValueOnce(null); mockPricesRepo.getLatestForProduct.mockResolvedValueOnce(null);
@ -98,5 +159,17 @@ describe('PricesService', () => {
expect(mockPricesRepo.getLatestForProduct).toHaveBeenCalledTimes(2); expect(mockPricesRepo.getLatestForProduct).toHaveBeenCalledTimes(2);
expect(val).toBe(12); 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 limit = query.limit;
const items = await RegimenModel.find(filter) const items = await RegimenModel.find(filter)
.sort({ _id: 1 }) .sort({ name: 1, _id: 1 })
.limit(limit + 1) .limit(limit + 1)
.lean() .lean()
.exec(); .exec();
@ -64,6 +64,7 @@ export class RegimensRepository {
public async findActiveByUser(householdId: string, userId: string) { public async findActiveByUser(householdId: string, userId: string) {
return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false }) return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
.sort({ name: 1, _id: 1 })
.lean() .lean()
.exec(); .exec();
} }

View file

@ -160,10 +160,12 @@ export class RegimensService {
// Sort by daysUntilEmpty ASC (most urgent first, nulls last) // Sort by daysUntilEmpty ASC (most urgent first, nulls last)
/* v8 ignore next 6 */ /* v8 ignore next 6 */
burnRates.sort((a, b) => { 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 (a.daysUntilEmpty === null) return 1;
if (b.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; return burnRates;

View file

@ -51,7 +51,7 @@ describe(ShoppingListsRepository.name, () => {
describe('list', () => { describe('list', () => {
it('queries lists for household ordered newest first', async () => { 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); vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
await repo.list('h1'); await repo.list('h1');
@ -73,7 +73,7 @@ describe(ShoppingListsRepository.name, () => {
describe('findActiveByHousehold', () => { describe('findActiveByHousehold', () => {
it('queries specifically active/shopping lists sorted by update recency', async () => { 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); vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
await repo.findActiveByHousehold('h1'); 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'; } from '@meshitrack/shared';
export class ShoppingListsRepository { 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) { public async create(data: any) {
const list = new ShoppingListModel(data); const list = new ShoppingListModel(data);
const saved = await list.save(); const saved = await list.save();
return saved.toObject(); return this.sortItems(saved.toObject());
} }
public async list(householdId: string) { public async list(householdId: string) {
return ShoppingListModel.find({ householdId }) const lists = await ShoppingListModel.find({ householdId })
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.lean() .lean()
.exec(); .exec();
return lists.map(l => this.sortItems(l));
} }
public async findById(id: string, householdId: string) { 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) { 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 }) .sort({ updatedAt: -1 })
.lean() .lean()
.exec(); .exec();
return lists.map(l => this.sortItems(l));
} }
public async update(id: string, householdId: string, data: UpdateShoppingListInput) { public async update(id: string, householdId: string, data: UpdateShoppingListInput) {
return ShoppingListModel.findOneAndUpdate( const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId }, { _id: id, householdId },
{ $set: data }, { $set: data },
{ new: true } { new: true }
).lean().exec(); ).lean().exec();
return this.sortItems(updated);
} }
public async delete(id: string, householdId: string) { public async delete(id: string, householdId: string) {
@ -45,11 +66,12 @@ export class ShoppingListsRepository {
// --- Granular Atomic Subdocument Actions --- // --- Granular Atomic Subdocument Actions ---
public async addItem(id: string, householdId: string, item: ShoppingItem) { public async addItem(id: string, householdId: string, item: ShoppingItem) {
return ShoppingListModel.findOneAndUpdate( const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId }, { _id: id, householdId },
{ $push: { items: item } }, { $push: { items: item } },
{ new: true } { new: true }
).lean().exec(); ).lean().exec();
return this.sortItems(updated);
} }
public async updateItem( public async updateItem(
@ -60,22 +82,23 @@ export class ShoppingListsRepository {
) { ) {
const setUpdates: Record<string, unknown> = {}; const setUpdates: Record<string, unknown> = {};
for (const [key, val] of Object.entries(updates)) { for (const [key, val] of Object.entries(updates)) {
// Flatten parameters mapping them precisely to the positional positional matched index
setUpdates[`items.$.${key}`] = val; setUpdates[`items.$.${key}`] = val;
} }
return ShoppingListModel.findOneAndUpdate( const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId, 'items.id': itemId }, { _id: id, householdId, 'items.id': itemId },
{ $set: setUpdates }, { $set: setUpdates },
{ new: true } { new: true }
).lean().exec(); ).lean().exec();
return this.sortItems(updated);
} }
public async removeItem(id: string, householdId: string, itemId: string) { public async removeItem(id: string, householdId: string, itemId: string) {
return ShoppingListModel.findOneAndUpdate( const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId }, { _id: id, householdId },
{ $pull: { items: { id: itemId } } }, { $pull: { items: { id: itemId } } },
{ new: true } { new: true }
).lean().exec(); ).lean().exec();
return this.sortItems(updated);
} }
} }

View file

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

View file

@ -71,6 +71,89 @@ describe('ShoppingListsService', () => {
expect(result.items[0].estimatedPrice).toBe(5); expect(result.items[0].estimatedPrice).toBe(5);
expect(result.totalEstimatedCost).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', () => { describe('addItem', () => {
@ -97,6 +180,41 @@ describe('ShoppingListsService', () => {
); );
expect(res.addedItem.id).toBeDefined(); 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', () => { 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', () => { describe('createFromMealPlan', () => {
@ -149,6 +312,24 @@ describe('ShoppingListsService', () => {
shoppingListId: 'newList1', 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', () => { describe('syncCheckedToPantry', () => {
@ -203,6 +384,47 @@ describe('ShoppingListsService', () => {
expect(summary.addedCount).toBe(1); expect(summary.addedCount).toBe(1);
expect(summary.pricesLogged).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', () => { describe('getStoreComparison', () => {
@ -220,5 +442,51 @@ describe('ShoppingListsService', () => {
expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart'); expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart');
expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(10); 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 addedCount = 0;
let pricesLogged = 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) { for (const item of pendingItems) {
if (!item.productId) continue;
// 1. Promote item to active pantry // 1. Promote item to active pantry
await this.pantryService.create( await this.pantryService.create(
{ {
@ -297,12 +295,12 @@ export class ShoppingListsService {
*/ */
public async getStoreComparison(id: string, householdId: string) { public async getStoreComparison(id: string, householdId: string) {
const list = await this.getById(id, householdId); 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 // 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 storePricesMap = new Map<string, Map<string, number>>(); // storeId -> Map<productId, latestPrice>
const storeNamesMap = new Map<string, string>(); 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) { for (const productId of allProductIds) {
const options = await this.pricesService.compareStores(productId, householdId); const options = await this.pricesService.compareStores(productId, householdId);

View file

@ -20,6 +20,14 @@ const shoppingItemSchema = new mongoose.Schema(
{ _id: false } { _id: false }
); );
const shoppingListSourceSchema = new mongoose.Schema(
{
type: { type: String, required: true }, // values from ShoppingListSourceType
referenceId: { type: String },
},
{ _id: false }
);
const shoppingListSchema = new mongoose.Schema( const shoppingListSchema = new mongoose.Schema(
{ {
householdId: { type: String, required: true }, householdId: { type: String, required: true },
@ -27,12 +35,8 @@ const shoppingListSchema = new mongoose.Schema(
items: { type: [shoppingItemSchema], required: true, default: [] }, items: { type: [shoppingItemSchema], required: true, default: [] },
status: { type: String, required: true }, // values from ShoppingListStatus status: { type: String, required: true }, // values from ShoppingListStatus
createdFrom: { createdFrom: {
type: { type: shoppingListSourceSchema,
type: { type: String, required: true }, // values from ShoppingListSourceType
referenceId: { type: String },
},
required: false, required: false,
_id: false,
}, },
mealPlanId: { type: String }, mealPlanId: { type: String },
totalEstimatedCost: { type: Number }, totalEstimatedCost: { type: Number },

View file

@ -9,7 +9,10 @@ vi.mock('swr', () => ({ default: mockUseSWR }));
vi.mock('@/services/cabinet', () => ({ vi.mock('@/services/cabinet', () => ({
getCabinetSummary: vi.fn(), getCabinetSummary: vi.fn(),
listCabinetItems: vi.fn(), }));
vi.mock('@/services/regimens', () => ({
getBurnRates: vi.fn(),
})); }));
vi.mock('@/services/purchases', () => ({ vi.mock('@/services/purchases', () => ({
@ -68,22 +71,30 @@ describe(DashboardPage.name, () => {
expect(screen.getByText(/there/)).toBeInTheDocument(); expect(screen.getByText(/there/)).toBeInTheDocument();
}); });
it('renders cabinet items', () => { it('renders cabinet items that are in active regimens', () => {
mockUseApi.mockReturnValue({ mockUseApi.mockReturnValue({
householdId: 'hh1', householdId: 'hh1',
isLoading: false, isLoading: false,
profile: { displayName: 'Jane' }, profile: { displayName: 'Jane' },
}); });
const summaryData = { data: [{ _id: '1' }, { _id: '2' }] }; const summaryData = {
const cabinetData = { data: [
data: [{ _id: 'c1', medicineName: 'Aspirin', quantity: 50, unit: 'tablets' }], { medicineId: '1', medicineName: 'Aspirin', totalQuantity: 50, unit: 'tablets' },
{ medicineId: '2', medicineName: 'Ibuprofen', totalQuantity: 100, unit: 'tablets' },
],
};
const burnRateData = {
data: [
{ medicineId: '1', medicineName: 'Aspirin', daysUntilEmpty: 10 },
],
}; };
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: summaryData }; if (key.includes('cabinet-summary')) return { data: summaryData };
if (key.includes('cabinet-items')) return { data: cabinetData }; if (key.includes('burn-rates')) return { data: burnRateData };
if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } };
@ -93,7 +104,8 @@ describe(DashboardPage.name, () => {
render(<DashboardPage />); render(<DashboardPage />);
expect(screen.getByText('Aspirin')).toBeInTheDocument(); expect(screen.getByText('Aspirin')).toBeInTheDocument();
expect(screen.getByText('50 tablets')).toBeInTheDocument(); // Ibuprofen should NOT be in the document because it has no burn rate
expect(screen.queryByText('Ibuprofen')).not.toBeInTheDocument();
}); });
it('shows empty states when no data', () => { it('shows empty states when no data', () => {
@ -106,7 +118,7 @@ describe(DashboardPage.name, () => {
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } }; if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } }; if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } };
@ -115,7 +127,7 @@ describe(DashboardPage.name, () => {
render(<DashboardPage />); render(<DashboardPage />);
expect(screen.getByText('No cabinet items yet.')).toBeInTheDocument(); expect(screen.getByText('No active medicines in regimens.')).toBeInTheDocument();
expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument(); expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument();
expect(screen.getByText('No pending orders.')).toBeInTheDocument(); expect(screen.getByText('No pending orders.')).toBeInTheDocument();
expect(screen.getByText('No recent activity.')).toBeInTheDocument(); expect(screen.getByText('No recent activity.')).toBeInTheDocument();
@ -134,8 +146,8 @@ describe(DashboardPage.name, () => {
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } }; if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
if (key.includes('cabinet-items')) return { data: { data: [] } }; if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: refillData }; if (key.includes('refill-alerts')) return { data: refillData };
if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } };
@ -170,7 +182,7 @@ describe(DashboardPage.name, () => {
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } }; if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } }; if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: purchaseData }; if (key.includes('purchases')) return { data: purchaseData };
if (key.includes('cabinet-events')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } };
@ -205,7 +217,7 @@ describe(DashboardPage.name, () => {
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } }; if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } }; if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: eventData }; if (key.includes('cabinet-events')) return { data: eventData };
@ -227,8 +239,8 @@ describe(DashboardPage.name, () => {
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } }; if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
if (key.includes('cabinet-items')) return { data: { data: [] } }; if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } }; if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } };
if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } };
@ -250,8 +262,8 @@ describe(DashboardPage.name, () => {
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) if (key.includes('cabinet-summary'))
return { data: { data: [{ _id: '1' }, { _id: '2' }, { _id: '3' }] } }; return { data: { data: [{ medicineId: '1' }, { medicineId: '2' }, { medicineId: '3' }] } };
if (key.includes('cabinet-items')) return { data: { data: [] } }; if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts')) if (key.includes('refill-alerts'))
return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } }; return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } };
if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } };
@ -281,7 +293,7 @@ describe(DashboardPage.name, () => {
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } }; if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } }; if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: purchaseData }; if (key.includes('purchases')) return { data: purchaseData };
if (key.includes('cabinet-events')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } };
@ -300,14 +312,18 @@ describe(DashboardPage.name, () => {
profile: { displayName: 'Jane' }, profile: { displayName: 'Jane' },
}); });
const cabinetData = { const summaryData = {
data: [{ _id: 'c1', medicineName: undefined, quantity: 10, unit: 'pills' }], data: [{ medicineId: 'c1', medicineName: undefined, totalQuantity: 10, unit: 'pills' }],
};
const burnRateData = {
data: [{ medicineId: 'c1', medicineName: undefined, daysUntilEmpty: 5 }],
}; };
mockUseSWR.mockImplementation((key: string) => { mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined }; if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } }; if (key.includes('cabinet-summary')) return { data: summaryData };
if (key.includes('cabinet-items')) return { data: cabinetData }; if (key.includes('burn-rates')) return { data: burnRateData };
if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } };

View file

@ -2,15 +2,17 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { useApi } from '@/lib/useApi'; import { useApi } from '@/lib/useApi';
import { getCabinetSummary, listCabinetItems } from '@/services/cabinet'; import { getCabinetSummary } from '@/services/cabinet';
import { listPurchases } from '@/services/purchases'; import { listPurchases } from '@/services/purchases';
import { getRefillAlerts } from '@/services/refills'; import { getRefillAlerts } from '@/services/refills';
import { listCabinetEvents } from '@/services/cabinet-events'; import { listCabinetEvents } from '@/services/cabinet-events';
import { getBurnRates } from '@/services/regimens';
import { SetPageHeader } from '@/components/layout/SetPageHeader'; import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, CardHeader } from '@/components/ui/Card'; import { Card, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Pill } from '@/components/ui/Pill'; import { Pill } from '@/components/ui/Pill';
import { Icon } from '@/components/ui/Icon'; import { Icon } from '@/components/ui/Icon';
import { SupplyBar } from '@/components/ui/SupplyBar';
function now() { function now() {
return new Date(); return new Date();
@ -35,8 +37,8 @@ export default function DashboardPage() {
getCabinetSummary(householdId!), getCabinetSummary(householdId!),
); );
const { data: cabinetItems } = useSWR(householdId ? `cabinet-items-${householdId}` : null, () => const { data: burnRates } = useSWR(householdId ? `burn-rates-${householdId}` : null, () =>
listCabinetItems(householdId!, { limit: 10 }), getBurnRates(householdId!),
); );
const { data: pendingPurchases } = useSWR( const { data: pendingPurchases } = useSWR(
@ -154,13 +156,25 @@ export default function DashboardPage() {
gap: 6, gap: 6,
}} }}
> >
{cabinetItems?.data.length ? ( {(() => {
cabinetItems.data.slice(0, 8).map((item) => ( const itemsWithBurnRate = summary?.data.filter((item) =>
burnRates?.data.some((br) => br.medicineId === item.medicineId),
);
if (!itemsWithBurnRate?.length) {
return <EmptyState message="No active medicines in regimens." />;
}
return itemsWithBurnRate.slice(0, 8).map((item) => {
const matchedBR = burnRates?.data.find(
(br) => br.medicineId === item.medicineId,
);
return (
<div <div
key={item._id} key={item.medicineId}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '140px 1fr', gridTemplateColumns: '160px 1fr',
gap: 12, gap: 12,
alignItems: 'center', alignItems: 'center',
fontSize: 12, fontSize: 12,
@ -175,46 +189,24 @@ export default function DashboardPage() {
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
title={item.medicineName}
> >
{item.medicineName ?? 'Unknown'} {item.medicineName ?? 'Unknown'}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ flex: 1 }}>
<div {matchedBR && matchedBR.daysUntilEmpty !== null ? (
style={{ <SupplyBar days={matchedBR.daysUntilEmpty} />
flex: 1,
height: 6,
background: 'var(--bg-inset)',
borderRadius: 3,
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${Math.min(100, (item.quantity / 100) * 100)}%`,
background: 'var(--brand)',
borderRadius: 3,
}}
/>
</div>
<span
className="num"
style={{
fontSize: 12,
fontWeight: 600,
minWidth: 40,
textAlign: 'right',
}}
>
{item.quantity} {item.unit}
</span>
</div>
</div>
))
) : ( ) : (
<EmptyState message="No cabinet items yet." /> <div style={{ fontSize: 11, color: 'var(--ink-faint)' }}>
As needed
</div>
)} )}
</div> </div>
</div>
);
});
})()}
</div>
</Card> </Card>
</div> </div>

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import useSWR, { mutate } from 'swr';
import { useApi } from '@/lib/useApi';
import { import {
listCabinetItems, listCabinetItems,
getCabinetSummary, getCabinetSummary,
@ -208,9 +210,6 @@ function StatsStrip({ items }: { items: SummaryItem[] }) {
export function CabinetTab({ householdId }: { householdId: string }) { export function CabinetTab({ householdId }: { householdId: string }) {
const [view, setView] = useState<'summary' | 'detail'>('summary'); const [view, setView] = useState<'summary' | 'detail'>('summary');
const [summaryItems, setSummaryItems] = useState<SummaryItem[]>([]);
const [cabinetItems, setCabinetItems] = useState<CabinetItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, setFilterStatus] = useState('');
@ -218,43 +217,55 @@ export function CabinetTab({ householdId }: { householdId: string }) {
const [expandedItems, setExpandedItems] = useState<CabinetItem[]>([]); const [expandedItems, setExpandedItems] = useState<CabinetItem[]>([]);
const [expandLoading, setExpandLoading] = useState(false); const [expandLoading, setExpandLoading] = useState(false);
const fetchData = useCallback(async () => { const summaryKey = householdId && view === 'summary' ? `cabinet-summary-${householdId}` : null;
const detailKey = householdId && view === 'detail' ? `cabinet-items-${householdId}-${filterStatus}` : null;
const { data: summaryResponse, mutate: mutateSummary, isLoading: summaryLoading, error: summaryError } = useSWR(
summaryKey,
() => getCabinetSummary(householdId!),
);
const { data: detailResponse, mutate: mutateDetail, isLoading: detailLoading, error: detailError } = useSWR(
detailKey,
() =>
listCabinetItems(householdId!, {
status: (filterStatus as CabinetItemStatus) || undefined,
limit: 50,
}),
);
useEffect(() => {
const err = summaryError || detailError;
if (err) {
setError(err instanceof Error ? err.message : 'Failed to load cabinet');
}
}, [summaryError, detailError]);
const summaryItems = summaryResponse?.data ?? [];
const cabinetItems = detailResponse?.data ?? [];
const loading = (view === 'summary' && summaryLoading) || (view === 'detail' && detailLoading);
const refreshExpanded = async (medId: string) => {
if (!householdId) return; if (!householdId) return;
setLoading(true); setExpandLoading(true);
try { try {
if (view === 'summary') { const result = await listCabinetItems(householdId, {
const result = await getCabinetSummary(householdId); medicineId: medId,
setSummaryItems(result.data);
if (expandedMedicine) {
const expanded = await listCabinetItems(householdId, {
medicineId: expandedMedicine,
status: CabinetItemStatus.ACTIVE, status: CabinetItemStatus.ACTIVE,
limit: 50, limit: 50,
}); });
setExpandedItems(expanded.data); setExpandedItems(result.data);
if (expanded.data.length === 0) { if (result.data.length === 0) {
setExpandedMedicine(null); setExpandedMedicine(null);
} }
} } catch {
} else { setExpandedItems([]);
const result = await listCabinetItems(householdId, {
status: (filterStatus as CabinetItemStatus) || undefined,
limit: 50,
});
setCabinetItems(result.data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load cabinet');
} finally { } finally {
setLoading(false); setExpandLoading(false);
} }
}, [householdId, view, filterStatus, expandedMedicine]); };
useEffect(() => {
if (householdId) {
fetchData();
}
}, [householdId, fetchData]);
async function handleExpand(medicineId: string) { async function handleExpand(medicineId: string) {
if (!householdId) return; if (!householdId) return;
@ -264,36 +275,59 @@ export function CabinetTab({ householdId }: { householdId: string }) {
return; return;
} }
setExpandedMedicine(medicineId); setExpandedMedicine(medicineId);
setExpandLoading(true); refreshExpanded(medicineId);
try {
const result = await listCabinetItems(householdId, {
medicineId,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(result.data);
} catch {
setExpandedItems([]);
} finally {
setExpandLoading(false);
}
} }
async function handleAdjust(itemId: string, delta: number) { async function handleAdjust(itemId: string, delta: number) {
if (!householdId) return; if (!householdId) return;
// Optimistic updates
if (view === 'summary') {
mutateSummary(async (current) => {
if (!current) return current;
// Finding which medicine this item belongs to is hard without more info in the ID,
// but summary view items are aggregated.
// For simplicity in summary view, we'll just trigger a refresh or find by mapping.
// However, we can also mutate the expanded items if they are visible.
return current;
}, { revalidate: false });
if (expandedMedicine) {
setExpandedItems(prev => prev.map(item =>
item._id === itemId ? { ...item, quantity: Math.max(0, item.quantity + delta) } : item
));
}
} else {
mutateDetail(async (current) => {
if (!current) return current;
return {
...current,
data: current.data.map(item =>
item._id === itemId ? { ...item, quantity: Math.max(0, item.quantity + delta) } : item
)
};
}, { revalidate: false });
}
try { try {
await adjustCabinetItemQuantity(householdId, itemId, { delta }); await adjustCabinetItemQuantity(householdId, itemId, { delta });
fetchData(); mutateSummary();
mutateDetail();
if (expandedMedicine) refreshExpanded(expandedMedicine);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to adjust quantity'); setError(err instanceof Error ? err.message : 'Failed to adjust quantity');
mutateSummary();
mutateDetail();
} }
} }
async function handleDelete(itemId: string) { async function handleDelete(itemId: string) {
if (!householdId || !confirm('Delete this item permanently?')) return; if (!householdId || !window.confirm('Delete this item permanently?')) return;
try { try {
await deleteCabinetItem(householdId, itemId); await deleteCabinetItem(householdId, itemId);
fetchData(); mutateSummary();
mutateDetail();
if (expandedMedicine) refreshExpanded(expandedMedicine);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete'); setError(err instanceof Error ? err.message : 'Failed to delete');
} }
@ -394,7 +428,8 @@ export function CabinetTab({ householdId }: { householdId: string }) {
householdId={householdId} householdId={householdId}
onCreated={() => { onCreated={() => {
setShowForm(false); setShowForm(false);
fetchData(); mutateSummary();
mutateDetail();
}} }}
onCancel={() => setShowForm(false)} onCancel={() => setShowForm(false)}
/> />
@ -447,7 +482,7 @@ function SummaryView({
expandedItems: CabinetItem[]; expandedItems: CabinetItem[];
expandLoading: boolean; expandLoading: boolean;
onExpand: (id: string) => void; onExpand: (id: string) => void;
onAdjust: (id: string, delta: number) => void; onAdjust: (id: string, delta: number) => Promise<void>;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
if (items.length === 0) { if (items.length === 0) {
@ -597,7 +632,7 @@ function DetailView({
onDelete, onDelete,
}: { }: {
items: CabinetItem[]; items: CabinetItem[];
onAdjust: (id: string, delta: number) => void; onAdjust: (id: string, delta: number) => Promise<void>;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
if (items.length === 0) { if (items.length === 0) {
@ -639,9 +674,11 @@ function CabinetItemCard({
}: { }: {
item: CabinetItem; item: CabinetItem;
showMedicineName?: boolean; showMedicineName?: boolean;
onAdjust: (id: string, delta: number) => void; onAdjust: (id: string, delta: number) => Promise<void>;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
const [isPending, setIsPending] = useState(false);
const statusStyle = const statusStyle =
item.status === 'active' item.status === 'active'
? { background: 'var(--ok-soft)', color: 'var(--ok)' } ? { background: 'var(--ok-soft)', color: 'var(--ok)' }
@ -649,6 +686,16 @@ function CabinetItemCard({
? { background: 'var(--danger-soft)', color: 'var(--danger)' } ? { background: 'var(--danger-soft)', color: 'var(--danger)' }
: { background: 'var(--bg-inset)', color: 'var(--ink-muted)' }; : { background: 'var(--bg-inset)', color: 'var(--ink-muted)' };
async function handleAdjust(delta: number) {
if (isPending) return;
setIsPending(true);
try {
await onAdjust(item._id, delta);
} finally {
setIsPending(false);
}
}
return ( return (
<div <div
style={{ style={{
@ -658,6 +705,9 @@ function CabinetItemCard({
gap: 12, gap: 12,
width: '100%', width: '100%',
minWidth: 0, minWidth: 0,
opacity: isPending ? 0.6 : 1,
pointerEvents: isPending ? 'none' : 'auto',
transition: 'opacity 0.2s',
}} }}
> >
{/* Left: info */} {/* Left: info */}
@ -732,7 +782,7 @@ function CabinetItemCard({
{item.status === 'active' && ( {item.status === 'active' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<button <button
onClick={() => onAdjust(item._id, -1)} onClick={() => handleAdjust(-1)}
style={{ style={{
border: '1px solid var(--border)', border: '1px solid var(--border)',
padding: '3px 8px', padding: '3px 8px',
@ -749,7 +799,7 @@ function CabinetItemCard({
-1 -1
</button> </button>
<button <button
onClick={() => onAdjust(item._id, 1)} onClick={() => handleAdjust(1)}
style={{ style={{
border: '1px solid var(--border)', border: '1px solid var(--border)',
padding: '3px 8px', padding: '3px 8px',

View file

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import { import {
listRegimens, listRegimens,
createRegimen, createRegimen,
@ -449,38 +450,42 @@ function BurnRateTable({ burnRates }: { burnRates: BurnRateItem[] }) {
// --- Main component --- // --- Main component ---
export function RegimensTab({ householdId }: { householdId: string }) { export function RegimensTab({ householdId }: { householdId: string }) {
const [regimens, setRegimens] = useState<Regimen[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [medicines, setMedicines] = useState<MedicineOption[]>([]); const [medicines, setMedicines] = useState<MedicineOption[]>([]);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null); const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null);
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all'); const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
const [burnRates, setBurnRates] = useState<BurnRateItem[]>([]);
const [showBurnRate, setShowBurnRate] = useState(false); const [showBurnRate, setShowBurnRate] = useState(false);
const [burnRateLoading, setBurnRateLoading] = useState(false);
const fetchRegimens = useCallback(async () => { const query = useMemo(() => {
setLoading(true); const q: any = { limit: 50 };
try { if (filterActive === 'active') q.isActive = true;
const query = if (filterActive === 'inactive') q.isActive = false;
filterActive === 'active' return q;
? { isActive: true } }, [filterActive]);
: filterActive === 'inactive'
? { isActive: false } const swrKey = householdId ? `regimens-${householdId}-${JSON.stringify(query)}` : null;
: {}; const { data: regimensResponse, mutate: mutateRegimens, isLoading: loading, error: swrError } = useSWR(
const result = await listRegimens(householdId, { ...query, limit: 50 }); swrKey,
setRegimens(result.data); () => listRegimens(householdId, query)
} catch (err) { );
setError(err instanceof Error ? err.message : 'Failed to load regimens');
} finally { const regimens = regimensResponse?.data ?? [];
setLoading(false);
} const { data: burnRateResponse, mutate: mutateBurnRates, isLoading: burnRateSWRLoading, error: burnRateError } = useSWR(
}, [householdId, filterActive]); householdId && showBurnRate ? `burn-rates-${householdId}` : null,
() => getBurnRates(householdId)
);
const burnRates = burnRateResponse?.data ?? [];
const burnRateLoading = showBurnRate && burnRateSWRLoading;
useEffect(() => { useEffect(() => {
fetchRegimens(); const err = swrError || burnRateError;
}, [fetchRegimens]); if (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
}
}, [swrError, burnRateError]);
// Medicines are needed for the form // Medicines are needed for the form
useEffect(() => { useEffect(() => {
@ -490,39 +495,50 @@ export function RegimensTab({ householdId }: { householdId: string }) {
}, [householdId]); }, [householdId]);
async function handleDelete(id: string, name: string) { async function handleDelete(id: string, name: string) {
if (!confirm(`Delete regimen "${name}"?`)) return; if (!window.confirm(`Delete regimen "${name}"?`)) return;
// Optimistic delete
mutateRegimens(async (current) => {
if (!current) return current;
return { ...current, data: current.data.filter((r: any) => r._id !== id) };
}, { revalidate: false });
try { try {
await deleteRegimen(householdId, id); await deleteRegimen(householdId, id);
setRegimens((prev) => prev.filter((r) => r._id !== id)); mutateRegimens();
if (showBurnRate) mutateBurnRates();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete'); setError(err instanceof Error ? err.message : 'Failed to delete');
mutateRegimens();
} }
} }
async function handleToggleActive(regimen: Regimen) { async function handleToggleActive(regimen: Regimen) {
const nextActive = !regimen.isActive;
// Optimistic toggle
mutateRegimens(async (current) => {
if (!current) return current;
return {
...current,
data: current.data.map((r: any) => r._id === regimen._id ? { ...r, isActive: nextActive } : r)
};
}, { revalidate: false });
try { try {
const updated = await updateRegimen(householdId, regimen._id, { await updateRegimen(householdId, regimen._id, {
isActive: !regimen.isActive, isActive: nextActive,
}); });
setRegimens((prev) => prev.map((r) => (r._id === regimen._id ? updated : r))); mutateRegimens();
if (showBurnRate) mutateBurnRates();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update'); setError(err instanceof Error ? err.message : 'Failed to update');
mutateRegimens();
} }
} }
async function handleShowBurnRate() { async function handleShowBurnRate() {
setShowBurnRate((prev) => !prev); setShowBurnRate((prev) => !prev);
if (!showBurnRate && burnRates.length === 0) {
setBurnRateLoading(true);
try {
const result = await getBurnRates(householdId);
setBurnRates(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load burn rates');
} finally {
setBurnRateLoading(false);
}
}
} }
const isFormOpen = showForm || editingRegimen !== null; const isFormOpen = showForm || editingRegimen !== null;
@ -585,8 +601,8 @@ export function RegimensTab({ householdId }: { householdId: string }) {
medicines={medicines} medicines={medicines}
onSaved={() => { onSaved={() => {
setShowForm(false); setShowForm(false);
fetchRegimens(); mutateRegimens();
setBurnRates([]); if (showBurnRate) mutateBurnRates();
}} }}
onCancel={() => setShowForm(false)} onCancel={() => setShowForm(false)}
/> />
@ -599,8 +615,8 @@ export function RegimensTab({ householdId }: { householdId: string }) {
initial={editingRegimen} initial={editingRegimen}
onSaved={() => { onSaved={() => {
setEditingRegimen(null); setEditingRegimen(null);
fetchRegimens(); mutateRegimens();
setBurnRates([]); if (showBurnRate) mutateBurnRates();
}} }}
onCancel={() => setEditingRegimen(null)} onCancel={() => setEditingRegimen(null)}
/> />

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { SWRConfig } from 'swr';
import type React from 'react'; import type React from 'react';
const { const {
@ -63,9 +64,13 @@ beforeEach(() => {
mockListMedicines.mockResolvedValue(emptyMeds); mockListMedicines.mockResolvedValue(emptyMeds);
}); });
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
describe('CabinetTab', () => { describe('CabinetTab', () => {
it('shows empty state when no items', async () => { it('shows empty state when no items', async () => {
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText(/cabinet is empty/i)).toBeInTheDocument()); await waitFor(() => expect(screen.getByText(/cabinet is empty/i)).toBeInTheDocument());
}); });
@ -73,7 +78,7 @@ describe('CabinetTab', () => {
it('shows error when list fails', async () => { it('shows error when list fails', async () => {
mockGetCabinetSummary.mockRejectedValue(new Error('Server error')); mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument());
}); });
@ -81,7 +86,7 @@ describe('CabinetTab', () => {
it('dismisses error on Dismiss click', async () => { it('dismisses error on Dismiss click', async () => {
mockGetCabinetSummary.mockRejectedValue(new Error('Server error')); mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Server error')); await waitFor(() => screen.getByText('Server error'));
await userEvent.click(screen.getByText('Dismiss')); await userEvent.click(screen.getByText('Dismiss'));
@ -89,7 +94,7 @@ describe('CabinetTab', () => {
}); });
it('toggles Add to Cabinet form', async () => { it('toggles Add to Cabinet form', async () => {
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -100,7 +105,7 @@ describe('CabinetTab', () => {
}); });
it('switches between Summary and All Items views', async () => { it('switches between Summary and All Items views', async () => {
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -114,7 +119,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -130,7 +135,7 @@ describe('CabinetTab', () => {
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 11 }); mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 11 });
mockGetCabinetSummary.mockResolvedValue(emptySummary); mockGetCabinetSummary.mockResolvedValue(emptySummary);
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -158,7 +163,7 @@ describe('CabinetTab', () => {
], ],
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
expect(screen.getByText(/2 items/)).toBeInTheDocument(); expect(screen.getByText(/2 items/)).toBeInTheDocument();
@ -185,7 +190,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin')); await waitFor(() => screen.getByText('Metformin'));
await userEvent.click(screen.getByText('Metformin').closest('button')!); await userEvent.click(screen.getByText('Metformin').closest('button')!);
@ -201,7 +206,7 @@ describe('CabinetTab', () => {
it('submits AddToCabinetForm with medicine selection validation', async () => { it('submits AddToCabinetForm with medicine selection validation', async () => {
mockCreateCabinetItem.mockResolvedValue(cabinetItem); mockCreateCabinetItem.mockResolvedValue(cabinetItem);
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -229,7 +234,7 @@ describe('CabinetTab', () => {
}); });
mockCreateCabinetItem.mockResolvedValue(cabinetItem); mockCreateCabinetItem.mockResolvedValue(cabinetItem);
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -266,7 +271,7 @@ describe('CabinetTab', () => {
}); });
mockCreateCabinetItem.mockResolvedValue(cabinetItem); mockCreateCabinetItem.mockResolvedValue(cabinetItem);
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -308,7 +313,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -346,7 +351,7 @@ describe('CabinetTab', () => {
], ],
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -369,7 +374,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -384,7 +389,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -403,7 +408,7 @@ describe('CabinetTab', () => {
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 9 }); mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 9 });
mockGetCabinetSummary.mockResolvedValue(emptySummary); mockGetCabinetSummary.mockResolvedValue(emptySummary);
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -435,7 +440,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin')); await waitFor(() => screen.getByText('Metformin'));
await userEvent.click(screen.getByText('Metformin').closest('button')!); await userEvent.click(screen.getByText('Metformin').closest('button')!);
@ -451,7 +456,7 @@ describe('CabinetTab', () => {
it('shows error when createCabinetItem fails', async () => { it('shows error when createCabinetItem fails', async () => {
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed')); mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -471,7 +476,7 @@ describe('CabinetTab', () => {
mockDeleteCabinetItem.mockResolvedValue({}); mockDeleteCabinetItem.mockResolvedValue({});
vi.spyOn(window, 'confirm').mockReturnValue(true); vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -489,7 +494,7 @@ describe('CabinetTab', () => {
}); });
mockAdjustCabinetItemQuantity.mockRejectedValue(new Error('Adjust failed')); mockAdjustCabinetItemQuantity.mockRejectedValue(new Error('Adjust failed'));
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -508,7 +513,7 @@ describe('CabinetTab', () => {
mockDeleteCabinetItem.mockRejectedValue(new Error('Delete failed')); mockDeleteCabinetItem.mockRejectedValue(new Error('Delete failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true); vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -520,7 +525,7 @@ describe('CabinetTab', () => {
}); });
it('cancels AddToCabinetForm with internal Cancel button', async () => { it('cancels AddToCabinetForm with internal Cancel button', async () => {
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -551,12 +556,12 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin')); await waitFor(() => screen.getByText('Metformin'));
// First click expands — handleExpand + useEffect both call listCabinetItems // First click expands — handleExpand calls listCabinetItems
await userEvent.click(screen.getByText('Metformin').closest('button')!); await userEvent.click(screen.getByText('Metformin').closest('button')!);
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2)); await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(1));
const callsAfterExpand = mockListCabinetItems.mock.calls.length; const callsAfterExpand = mockListCabinetItems.mock.calls.length;
// Second click collapses — no additional listCabinetItems calls // Second click collapses — no additional listCabinetItems calls
@ -584,12 +589,12 @@ describe('CabinetTab', () => {
}); });
mockListCabinetItems.mockRejectedValue(new Error('Expand failed')); mockListCabinetItems.mockRejectedValue(new Error('Expand failed'));
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin')); await waitFor(() => screen.getByText('Metformin'));
await userEvent.click(screen.getByText('Metformin').closest('button')!); await userEvent.click(screen.getByText('Metformin').closest('button')!);
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2)); await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(1));
// No items should be shown (empty after error) // No items should be shown (empty after error)
expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument(); expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument();
}); });
@ -603,7 +608,7 @@ describe('CabinetTab', () => {
}); });
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed')); mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
@ -621,7 +626,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items')); await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items')); await userEvent.click(screen.getByText('All Items'));
@ -637,7 +642,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));
// Wait for medicine options to load (covers medicines.map callback) // Wait for medicine options to load (covers medicines.map callback)
@ -665,7 +670,7 @@ describe('CabinetTab', () => {
}); });
mockCreateCabinetItem.mockRejectedValue('unexpected'); mockCreateCabinetItem.mockRejectedValue('unexpected');
render(<CabinetTab householdId="hh1" />); render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet')); await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet')); await userEvent.click(screen.getByText('Add to Cabinet'));

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { SWRConfig } from 'swr';
const { const {
mockListRegimens, mockListRegimens,
@ -59,9 +60,13 @@ beforeEach(() => {
mockGetBurnRates.mockResolvedValue({ data: [] }); mockGetBurnRates.mockResolvedValue({ data: [] });
}); });
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
describe('RegimensTab', () => { describe('RegimensTab', () => {
it('shows empty state when no regimens', async () => { it('shows empty state when no regimens', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText(/No regimens yet/)).toBeInTheDocument()); await waitFor(() => expect(screen.getByText(/No regimens yet/)).toBeInTheDocument());
}); });
@ -72,7 +77,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Morning Routine')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Morning Routine')).toBeInTheDocument());
}); });
@ -80,7 +85,7 @@ describe('RegimensTab', () => {
it('shows error when list fails', async () => { it('shows error when list fails', async () => {
mockListRegimens.mockRejectedValue(new Error('Load failed')); mockListRegimens.mockRejectedValue(new Error('Load failed'));
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Load failed')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Load failed')).toBeInTheDocument());
}); });
@ -88,7 +93,7 @@ describe('RegimensTab', () => {
it('dismisses error', async () => { it('dismisses error', async () => {
mockListRegimens.mockRejectedValue(new Error('Load failed')); mockListRegimens.mockRejectedValue(new Error('Load failed'));
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Load failed')); await waitFor(() => screen.getByText('Load failed'));
await userEvent.click(screen.getByText('Dismiss')); await userEvent.click(screen.getByText('Dismiss'));
@ -96,7 +101,7 @@ describe('RegimensTab', () => {
}); });
it('toggles new regimen form', async () => { it('toggles new regimen form', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getByText('New Regimen')); await userEvent.click(screen.getByText('New Regimen'));
@ -110,7 +115,7 @@ describe('RegimensTab', () => {
it('creates a regimen when form is submitted', async () => { it('creates a regimen when form is submitted', async () => {
mockCreateRegimen.mockResolvedValue(regimen); mockCreateRegimen.mockResolvedValue(regimen);
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -137,7 +142,7 @@ describe('RegimensTab', () => {
mockDeleteRegimen.mockResolvedValue({}); mockDeleteRegimen.mockResolvedValue({});
vi.spyOn(window, 'confirm').mockReturnValue(true); vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Delete')); await userEvent.click(screen.getByTitle('Delete'));
@ -148,7 +153,7 @@ describe('RegimensTab', () => {
it('shows burn rate section when toggled', async () => { it('shows burn rate section when toggled', async () => {
mockGetBurnRates.mockResolvedValue({ data: [] }); mockGetBurnRates.mockResolvedValue({ data: [] });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate')); await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate')); await userEvent.click(screen.getByText('Burn rate'));
@ -165,7 +170,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit')); await userEvent.click(screen.getByTitle('Edit'));
@ -180,7 +185,7 @@ describe('RegimensTab', () => {
}); });
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' }); mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit')); await userEvent.click(screen.getByTitle('Edit'));
@ -201,7 +206,7 @@ describe('RegimensTab', () => {
}); });
it('shows validation error when submitting regimen form with no medications', async () => { it('shows validation error when submitting regimen form with no medications', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -228,7 +233,7 @@ describe('RegimensTab', () => {
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed')); mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true); vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Delete')); await userEvent.click(screen.getByTitle('Delete'));
@ -255,7 +260,7 @@ describe('RegimensTab', () => {
], ],
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate')); await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate')); await userEvent.click(screen.getByText('Burn rate'));
@ -270,7 +275,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit')); await userEvent.click(screen.getByTitle('Edit'));
@ -287,7 +292,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All regimens')); await waitFor(() => screen.getByText('All regimens'));
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } }); fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
@ -296,7 +301,7 @@ describe('RegimensTab', () => {
}); });
it('adds a medication in the regimen form', async () => { it('adds a medication in the regimen form', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -309,7 +314,7 @@ describe('RegimensTab', () => {
}); });
it('toggles isActive checkbox in regimen form', async () => { it('toggles isActive checkbox in regimen form', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -328,7 +333,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -358,7 +363,7 @@ describe('RegimensTab', () => {
}); });
it('changes instructions field in medication row', async () => { it('changes instructions field in medication row', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -372,7 +377,7 @@ describe('RegimensTab', () => {
}); });
it('changes frequency to custom and sets times per day', async () => { it('changes frequency to custom and sets times per day', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -398,7 +403,7 @@ describe('RegimensTab', () => {
}); });
it('changes time of day in medication row', async () => { it('changes time of day in medication row', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -418,7 +423,7 @@ describe('RegimensTab', () => {
it('shows error when burn rate fetch fails', async () => { it('shows error when burn rate fetch fails', async () => {
mockGetBurnRates.mockRejectedValue(new Error('Burn rate failed')); mockGetBurnRates.mockRejectedValue(new Error('Burn rate failed'));
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate')); await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate')); await userEvent.click(screen.getByText('Burn rate'));
@ -433,7 +438,7 @@ describe('RegimensTab', () => {
}); });
mockUpdateRegimen.mockRejectedValue(new Error('Update failed')); mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Deactivate')); await userEvent.click(screen.getByTitle('Deactivate'));
@ -448,7 +453,7 @@ describe('RegimensTab', () => {
}); });
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false }); mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Deactivate')); await userEvent.click(screen.getByTitle('Deactivate'));
@ -457,7 +462,7 @@ describe('RegimensTab', () => {
}); });
it('cancels new regimen form using internal Cancel button', async () => { it('cancels new regimen form using internal Cancel button', async () => {
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -473,7 +478,7 @@ describe('RegimensTab', () => {
it('filters regimens by inactive status', async () => { it('filters regimens by inactive status', async () => {
mockListRegimens.mockResolvedValue(emptyRegimens); mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(mockListRegimens).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockListRegimens).toHaveBeenCalledTimes(1));
@ -491,7 +496,7 @@ describe('RegimensTab', () => {
it('shows filtered empty state when filter is active and no results', async () => { it('shows filtered empty state when filter is active and no results', async () => {
mockListRegimens.mockResolvedValue(emptyRegimens); mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByDisplayValue('All regimens')); await waitFor(() => screen.getByDisplayValue('All regimens'));
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } }); fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
@ -502,7 +507,7 @@ describe('RegimensTab', () => {
it('shows null when form is open and regimens list is empty', async () => { it('shows null when form is open and regimens list is empty', async () => {
mockListRegimens.mockResolvedValue(emptyRegimens); mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -525,7 +530,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText(/2 medications/)).toBeInTheDocument()); await waitFor(() => expect(screen.getByText(/2 medications/)).toBeInTheDocument());
}); });
@ -534,18 +539,18 @@ describe('RegimensTab', () => {
mockGetBurnRates.mockRejectedValue('burn failed'); mockGetBurnRates.mockRejectedValue('burn failed');
mockListRegimens.mockResolvedValue(emptyRegimens); mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate')); await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate')); await userEvent.click(screen.getByText('Burn rate'));
await waitFor(() => expect(screen.getByText('Failed to load burn rates')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Failed to load data')).toBeInTheDocument());
}); });
it('shows fallback error when non-Error thrown on create regimen', async () => { it('shows fallback error when non-Error thrown on create regimen', async () => {
mockCreateRegimen.mockRejectedValue('save failed'); mockCreateRegimen.mockRejectedValue('save failed');
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen')); await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]); await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -563,7 +568,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<RegimensTab householdId="hh1" />); render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine')); await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit')); await userEvent.click(screen.getByTitle('Edit'));

View file

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import { listPantryItems, transitionPantryItem, deletePantryItem } from '@/services/pantry'; import { listPantryItems, transitionPantryItem, deletePantryItem } from '@/services/pantry';
import { StorageLocation, ItemStatus, FreshnessUrgency } from '@meshitrack/shared'; import { StorageLocation, ItemStatus, FreshnessUrgency } from '@meshitrack/shared';
import type { z } from 'zod/v4'; import type { z } from 'zod/v4';
@ -59,55 +60,65 @@ const TRANSITION_LABELS: Record<string, string> = {
}; };
export function PantryList({ householdId }: { householdId: string }) { export function PantryList({ householdId }: { householdId: string }) {
const [items, setItems] = useState<PantryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [storageFilter, setStorageFilter] = useState(''); const [storageFilter, setStorageFilter] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const abortRef = useRef<AbortController | null>(null);
const fetchItems = useCallback(async () => { const query = useMemo(() => ({
if (!householdId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError('');
try {
const result = await listPantryItems(householdId, {
storageLocation: storageFilter || undefined, storageLocation: storageFilter || undefined,
status: statusFilter || undefined, status: statusFilter || undefined,
limit: 50, limit: 50,
}); }), [storageFilter, statusFilter]);
setItems(result.data);
} catch (err) { const swrKey = householdId ? `pantry-${householdId}-${JSON.stringify(query)}` : null;
if (err instanceof Error && err.name !== 'AbortError') { const { data: pantryResponse, mutate: mutatePantry, isLoading: loading, error: swrError } = useSWR(
setError(err.message); swrKey,
} () => listPantryItems(householdId, query)
} finally { );
setLoading(false);
} const items = pantryResponse?.data ?? [];
}, [householdId, storageFilter, statusFilter]);
useEffect(() => { useEffect(() => {
fetchItems(); if (swrError) setError(swrError instanceof Error ? swrError.message : 'Failed to load pantry');
}, [fetchItems]); }, [swrError]);
async function handleTransition(id: string, status: string) { async function handleTransition(id: string, status: string) {
// Optimistic update
mutatePantry(async (current: any) => {
if (!current) return current;
return {
...current,
data: current.data.map((item: any) => (item._id === id ? { ...item, status } : item))
};
}, { revalidate: false });
try { try {
const updated = await transitionPantryItem(householdId, id, { status } as never); const updated = await transitionPantryItem(householdId, id, { status } as never);
setItems((prev) => prev.map((item) => (item._id === id ? updated : item))); mutatePantry();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Transition failed'); setError(err instanceof Error ? err.message : 'Transition failed');
mutatePantry();
} }
} }
async function handleDelete(id: string, name: string) { async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}"?`)) return; if (!window.confirm(`Delete "${name}"?`)) return;
// Optimistic delete
mutatePantry(async (current: any) => {
if (!current) return current;
return {
...current,
data: current.data.filter((item: any) => item._id !== id)
};
}, { revalidate: false });
try { try {
await deletePantryItem(householdId, id); await deletePantryItem(householdId, id);
setItems((prev) => prev.filter((item) => item._id !== id)); mutatePantry();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete'); setError(err instanceof Error ? err.message : 'Failed to delete');
mutatePantry();
} }
} }

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { SWRConfig } from 'swr';
const { mockUseApi } = vi.hoisted(() => ({ const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(), mockUseApi: vi.fn(),
@ -28,6 +29,10 @@ import PantryPage from '../page';
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
const SAMPLE_ITEM = { const SAMPLE_ITEM = {
_id: 'pi-1', _id: 'pi-1',
householdId: 'hh1', householdId: 'hh1',
@ -53,7 +58,7 @@ describe('PantryPage', () => {
it('shows loading skeleton when session is loading', () => { it('shows loading skeleton when session is loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true }); mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<PantryPage />); render(<PantryPage />, { wrapper });
expect(screen.getByText('Pantry')).toBeInTheDocument(); expect(screen.getByText('Pantry')).toBeInTheDocument();
expect(screen.queryByText('All')).not.toBeInTheDocument(); expect(screen.queryByText('All')).not.toBeInTheDocument();
@ -62,7 +67,7 @@ describe('PantryPage', () => {
it('shows household prompt when no householdId', () => { it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false }); mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<PantryPage />); render(<PantryPage />, { wrapper });
expect(screen.getByText(/create or join a household/)).toBeInTheDocument(); expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
}); });
@ -74,7 +79,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Whole Milk')).toBeInTheDocument(); expect(screen.getByText('Whole Milk')).toBeInTheDocument();
@ -88,7 +93,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument(); expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
@ -102,7 +107,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Fresh')).toBeInTheDocument(); expect(screen.getByText('Fresh')).toBeInTheDocument();
@ -127,7 +132,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('2d overdue')).toBeInTheDocument(); expect(screen.getByText('2d overdue')).toBeInTheDocument();
@ -142,7 +147,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Open')).toBeInTheDocument(); expect(screen.getByText('Open')).toBeInTheDocument();
@ -162,7 +167,7 @@ describe('PantryPage', () => {
status: 'opened', status: 'opened',
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Open')).toBeInTheDocument(); expect(screen.getByText('Open')).toBeInTheDocument();
@ -184,7 +189,7 @@ describe('PantryPage', () => {
mockDeletePantryItem.mockResolvedValue(undefined); mockDeletePantryItem.mockResolvedValue(undefined);
vi.spyOn(window, 'confirm').mockReturnValue(true); vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument(); expect(screen.getByText('Delete')).toBeInTheDocument();
@ -205,7 +210,7 @@ describe('PantryPage', () => {
}); });
vi.spyOn(window, 'confirm').mockReturnValue(false); vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument(); expect(screen.getByText('Delete')).toBeInTheDocument();
@ -223,7 +228,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Fridge')).toBeInTheDocument(); expect(screen.getByText('Fridge')).toBeInTheDocument();
@ -246,7 +251,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false }, pagination: { cursor: null, hasMore: false },
}); });
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument(); expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
@ -266,7 +271,7 @@ describe('PantryPage', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockRejectedValue(new Error('Network error')); mockListPantryItems.mockRejectedValue(new Error('Network error'));
render(<PantryPage />); render(<PantryPage />, { wrapper });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument(); expect(screen.getByText('Network error')).toBeInTheDocument();

View file

@ -5,9 +5,11 @@ const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(), mockUseApi: vi.fn(),
})); }));
const { mockListProducts, mockDeleteProduct } = vi.hoisted(() => ({ const { mockListProducts, mockDeleteProduct, mockCreateProduct, mockUpdateProduct } = vi.hoisted(() => ({
mockListProducts: vi.fn(), mockListProducts: vi.fn(),
mockDeleteProduct: vi.fn(), mockDeleteProduct: vi.fn(),
mockCreateProduct: vi.fn(),
mockUpdateProduct: vi.fn(),
})); }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi })); vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
@ -15,6 +17,8 @@ vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/products', () => ({ vi.mock('@/services/products', () => ({
listProducts: mockListProducts, listProducts: mockListProducts,
deleteProduct: mockDeleteProduct, deleteProduct: mockDeleteProduct,
createProduct: mockCreateProduct,
updateProduct: mockUpdateProduct,
})); }));
vi.mock('next/link', () => { vi.mock('next/link', () => {
@ -225,4 +229,114 @@ describe('ProductsPage', () => {
expect(screen.getByText('C: 0g')).toBeInTheDocument(); expect(screen.getByText('C: 0g')).toBeInTheDocument();
expect(screen.getByText('F: 3.6g')).toBeInTheDocument(); expect(screen.getByText('F: 3.6g')).toBeInTheDocument();
}); });
it('toggles import dialog open and close', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Import')).toBeInTheDocument());
fireEvent.click(screen.getByText('Import'));
expect(screen.getByText('Import Products')).toBeInTheDocument();
// Use button name or text inside the dialog container to close
const cancelBtns = screen.getAllByRole('button', { name: /Cancel/i });
fireEvent.click(cancelBtns[cancelBtns.length - 1]);
});
it('toggles add product modal open and close', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Add Product')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add Product'));
const headings = screen.getAllByRole('heading');
expect(headings.some(h => h.textContent === 'Add Product')).toBe(true);
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
fireEvent.click(cancelBtn);
});
it('opens and closes edit product modal', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await screen.findByText('Chicken Breast');
const editBtn = screen.getByRole('button', { name: /edit product/i });
fireEvent.click(editBtn);
const headings = screen.getAllByRole('heading');
expect(headings.some(h => h.textContent === 'Edit Product')).toBe(true);
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
fireEvent.click(cancelBtn);
});
it('submits add product modal successfully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
mockCreateProduct.mockResolvedValue({ _id: 'pnew' });
render(<ProductsPage />);
await screen.findByText('Add Product');
fireEvent.click(screen.getByText('Add Product'));
const nameInput = screen.getByPlaceholderText('Product name');
fireEvent.change(nameInput, { target: { value: 'Fresh Banana' } });
// Fill mandatory numeric inputs to satisfy form validation
const numInputs = screen.getAllByRole('spinbutton');
numInputs.forEach(input => {
fireEvent.change(input, { target: { value: '100' } });
});
const saveBtn = screen.getByRole('button', { name: /^Save$/i });
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockCreateProduct).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Fresh Banana' }));
});
});
it('submits edit product modal successfully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
mockUpdateProduct.mockResolvedValue({ _id: 'p1' });
render(<ProductsPage />);
await screen.findByText('Chicken Breast');
const editBtn = screen.getByRole('button', { name: /edit product/i });
fireEvent.click(editBtn);
const nameInput = screen.getByPlaceholderText('Product name');
fireEvent.change(nameInput, { target: { value: 'Updated Chicken' } });
const saveBtn = screen.getByRole('button', { name: /^Save$/i });
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockUpdateProduct).toHaveBeenCalledWith('hh1', 'p1', expect.objectContaining({ name: 'Updated Chicken' }));
});
});
}); });

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({ const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(), mockUseApi: vi.fn(),
@ -228,4 +228,64 @@ describe('RecipeDetailPage', () => {
expect(screen.queryByText('Starred')).not.toBeInTheDocument(); expect(screen.queryByText('Starred')).not.toBeInTheDocument();
}); });
}); });
it('scales the recipe servings', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
mockScaleRecipe.mockResolvedValue({
...SAMPLE_RECIPE,
servings: 8,
ingredients: SAMPLE_RECIPE.ingredients.map(i => ({ ...i, quantity: i.quantity * 2 })),
});
render(<RecipeDetailPage />);
await screen.findByText('Spaghetti Bolognese');
const scaleInput = screen.getByRole('spinbutton');
fireEvent.change(scaleInput, { target: { value: '8' } });
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
fireEvent.click(scaleBtn);
await waitFor(() => {
expect(mockScaleRecipe).toHaveBeenCalledWith('hh1', 'r1', { targetServings: 8 });
});
// Test resetting back to original
const resetBtn = screen.getByRole('button', { name: /Reset to original/i });
fireEvent.click(resetBtn);
expect((scaleInput as HTMLInputElement).value).toBe('4');
});
it('handles scale recipe failure gracefully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
mockScaleRecipe.mockRejectedValue(new Error('Scale failed'));
render(<RecipeDetailPage />);
await screen.findByText('Spaghetti Bolognese');
const scaleInput = screen.getByRole('spinbutton');
fireEvent.change(scaleInput, { target: { value: '6' } });
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
fireEvent.click(scaleBtn);
await waitFor(() => {
expect(screen.getByText('Scale failed')).toBeInTheDocument();
});
});
it('skips scaling if servings did not change', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await screen.findByText('Spaghetti Bolognese');
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
fireEvent.click(scaleBtn);
expect(mockScaleRecipe).not.toHaveBeenCalled();
});
}); });

View file

@ -226,4 +226,37 @@ describe('RecipeEditor', () => {
expect(mockBack).toHaveBeenCalled(); expect(mockBack).toHaveBeenCalled();
}); });
it('handles updating ingredient optional properties and removing ingredients', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add ingredient'));
const prepInputs = screen.getAllByPlaceholderText(/Preparation/i);
fireEvent.change(prepInputs[0], { target: { value: 'Diced' } });
const removeBtns = screen.getAllByRole('button', { name: '×' });
fireEvent.click(removeBtns[0]);
const remainingPrepInputs = screen.getAllByPlaceholderText(/Preparation/i);
expect(remainingPrepInputs).toHaveLength(1);
});
it('handles step metadata and removing steps', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add step'));
const durationInputs = screen.getAllByPlaceholderText(/Duration/i);
const tipInputs = screen.getAllByPlaceholderText(/Tip/i);
fireEvent.change(durationInputs[0], { target: { value: '15' } });
fireEvent.change(tipInputs[0], { target: { value: "Don't burn it" } });
const removeBtns = screen.getAllByRole('button', { name: '×' });
fireEvent.click(removeBtns[removeBtns.length - 1]);
const remainingDurationInputs = screen.getAllByPlaceholderText(/Duration/i);
expect(remainingDurationInputs).toHaveLength(1);
});
}); });

View file

@ -0,0 +1,267 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SWRConfig } from 'swr';
import ShoppingListDetailPage from '../page';
import * as useApiModule from '@/lib/useApi';
import * as ShoppingListsService from '@/services/shopping-lists';
import * as ProductsService from '@/services/products';
import * as useShoppingListSyncModule from '@/lib/useShoppingListSync';
vi.mock('@/lib/useApi');
vi.mock('@/services/shopping-lists');
vi.mock('@/services/products');
vi.mock('@/lib/useShoppingListSync');
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}));
// Mock next/navigation params
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'list1' }),
useRouter: () => ({ push: mockPush, back: vi.fn() }),
}));
describe('ShoppingListDetailPage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock browser API modals
window.confirm = vi.fn().mockReturnValue(true);
window.alert = vi.fn();
vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1',
isLoading: false,
user: null,
token: '123',
} as any);
vi.mocked(ProductsService.listProducts).mockResolvedValue({
data: [
{ _id: 'p1', name: 'Apple', category: 'Produce', unit: 'pcs' }
]
} as any);
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
id: 'list1',
name: 'Test List',
status: 'active',
items: [
{ id: 'item1', productId: 'p1', quantity: 5, checked: false, unit: 'pcs' },
],
createdAt: '2026-05-10',
} as any);
vi.mocked(useShoppingListSyncModule.useShoppingListSync).mockReturnValue({
isConnected: true,
error: null,
toggleItemCheck: vi.fn(),
});
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
it('renders loading initially', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
render(<ShoppingListDetailPage />, { wrapper });
expect(screen.getByText(/Hydrating session checklist/i)).toBeInTheDocument();
});
it('loads and displays the list', async () => {
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Test List')).toBeInTheDocument();
expect(screen.getAllByText('Apple')[0]).toBeInTheDocument();
});
});
it('toggles an item check', async () => {
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const checkbox = screen.getByRole('button', { name: /Toggle check for Apple/i });
fireEvent.click(checkbox);
expect(useShoppingListSyncModule.useShoppingListSync).toHaveBeenCalled();
});
it('adds a new item to the list', async () => {
vi.mocked(ShoppingListsService.addShoppingItem).mockResolvedValue({
id: 'list1',
items: [
{ id: 'item1', productId: 'p1', quantity: 5, checked: false },
{ id: 'item2', customName: 'Banana', quantity: 2, checked: false },
]
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const customInput = screen.getByPlaceholderText(/e.g., Generic Flour/i);
fireEvent.change(customInput, { target: { value: 'Banana' } });
const submitBtn = screen.getByRole('button', { name: /Add to List/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(ShoppingListsService.addShoppingItem).toHaveBeenCalledWith(
'hh1',
'list1',
expect.objectContaining({ customName: 'Banana' })
);
});
});
it('completes the trip and syncs to pantry', async () => {
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
id: 'list1',
name: 'Test List',
status: 'active',
items: [
{ id: 'item1', productId: 'p1', quantity: 5, checked: true, addedToPantry: false },
],
createdAt: '2026-05-10',
} as any);
vi.mocked(ShoppingListsService.updateShoppingList).mockResolvedValue({ status: 'completed' } as any);
vi.mocked(ShoppingListsService.syncToPantry).mockResolvedValue({ addedCount: 1 } as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const syncBtn = screen.getByRole('button', { name: /Sync.*items to Pantry/i });
fireEvent.click(syncBtn);
await waitFor(() => {
expect(ShoppingListsService.syncToPantry).toHaveBeenCalledWith('hh1', 'list1');
expect(ShoppingListsService.updateShoppingList).toHaveBeenCalledWith('hh1', 'list1', { status: 'completed' });
});
});
it('deletes an item from the list', async () => {
vi.mocked(ShoppingListsService.removeShoppingItem).mockResolvedValue({
id: 'list1',
items: []
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const trashBtn = screen.getByRole('button', { name: /Delete Apple/i });
fireEvent.click(trashBtn);
await waitFor(() => {
expect(ShoppingListsService.removeShoppingItem).toHaveBeenCalledWith('hh1', 'list1', 'item1');
});
});
it('loads store comparisons when clicked', async () => {
vi.mocked(ShoppingListsService.getBasketStoreComparison).mockResolvedValue({
singleStoreOptions: [
{ storeId: 's1', storeName: 'Walmart', estimatedTotal: 45.00, itemsCovered: 1 }
]
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const checkBtn = screen.getByRole('button', { name: /Check Lowest Store Options/i });
fireEvent.click(checkBtn);
await waitFor(() => {
expect(ShoppingListsService.getBasketStoreComparison).toHaveBeenCalledWith('hh1', 'list1');
expect(screen.getByText('Walmart')).toBeInTheDocument();
expect(screen.getByText('$45.00')).toBeInTheDocument();
});
});
it('fills out the complete add item form and submits', async () => {
vi.mocked(ShoppingListsService.addShoppingItem).mockResolvedValue({
id: 'list1',
items: [
{ id: 'item1', productId: 'p1', quantity: 10, checked: false, unit: 'piece', notes: 'Fresh' }
]
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const selectInputs = screen.getAllByRole('combobox');
// First combobox is Link Product Catalog
fireEvent.change(selectInputs[0], { target: { value: 'p1' } });
const qtyInput = screen.getByRole('spinbutton');
fireEvent.change(qtyInput, { target: { value: '10' } });
// Second combobox is Unit
fireEvent.change(selectInputs[1], { target: { value: 'piece' } });
const notesInput = screen.getByPlaceholderText(/Brand preference/i);
fireEvent.change(notesInput, { target: { value: 'Fresh' } });
const submitBtn = screen.getByRole('button', { name: /Add to List/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(ShoppingListsService.addShoppingItem).toHaveBeenCalledWith(
'hh1',
'list1',
expect.objectContaining({
productId: 'p1',
quantity: 10,
unit: 'piece',
notes: 'Fresh'
})
);
});
});
it('navigates back to hub when button clicked', async () => {
render(<ShoppingListDetailPage />, { wrapper });
await screen.findAllByText('Apple');
const backBtn = screen.getByRole('button', { name: /Back to Hub/i });
fireEvent.click(backBtn);
expect(mockPush).toHaveBeenCalledWith('/shopping-lists');
});
it('handles delete failure gracefully', async () => {
vi.mocked(ShoppingListsService.removeShoppingItem).mockRejectedValue(new Error('Delete failed'));
const spy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ShoppingListDetailPage />, { wrapper });
await screen.findAllByText('Apple');
const trashBtn = screen.getByRole('button', { name: /Delete Apple/i });
fireEvent.click(trashBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith('Delete failed');
});
spy.mockRestore();
});
it('cancels sync to pantry if confirm is rejected', async () => {
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
id: 'list1',
name: 'Test List',
status: 'active',
items: [
{ id: 'item1', productId: 'p1', checked: true, addedToPantry: false, quantity: 5, unit: 'pcs' }
]
} as any);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<ShoppingListDetailPage />, { wrapper });
const syncBtn = await screen.findByRole('button', { name: /Sync 1 items to Pantry/i });
fireEvent.click(syncBtn);
expect(confirmSpy).toHaveBeenCalled();
expect(ShoppingListsService.syncToPantry).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});

View file

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import { useApi } from '@/lib/useApi'; import { useApi } from '@/lib/useApi';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { SetPageHeader } from '@/components/layout/SetPageHeader'; import { SetPageHeader } from '@/components/layout/SetPageHeader';
@ -22,16 +23,9 @@ export default function ShoppingListDetailsPage() {
const { id: listId } = useParams() as { id: string }; const { id: listId } = useParams() as { id: string };
const router = useRouter(); const router = useRouter();
// List state
const [list, setList] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Side panel States
const [storeOptions, setStoreOptions] = useState<any[]>([]); const [storeOptions, setStoreOptions] = useState<any[]>([]);
const [isStoreLoading, setIsStoreLoading] = useState(false); const [isStoreLoading, setIsStoreLoading] = useState(false);
// Form states for Add Item
const [products, setProducts] = useState<any[]>([]); const [products, setProducts] = useState<any[]>([]);
const [selectedProductId, setSelectedProductId] = useState(''); const [selectedProductId, setSelectedProductId] = useState('');
const [customItemName, setCustomItemName] = useState(''); const [customItemName, setCustomItemName] = useState('');
@ -41,18 +35,15 @@ export default function ShoppingListDetailsPage() {
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
// Load core list context // Load core list context
const fetchList = useCallback(async () => { const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null;
if (!householdId || !listId) return; const { data: list, mutate: mutateList, isLoading: listLoading, error: listError } = useSWR(
setLoading(true); swrKey,
try { () => getShoppingList(householdId!, listId)
const data = await getShoppingList(householdId, listId); );
setList(data);
} catch (err: any) { useEffect(() => {
setError(err.message || 'Shopping list not found'); if (listError) setError(listError.message || 'Shopping list not found');
} finally { }, [listError]);
setLoading(false);
}
}, [householdId, listId]);
// Run price comparisons // Run price comparisons
const fetchComparisons = useCallback(async () => { const fetchComparisons = useCallback(async () => {
@ -68,10 +59,6 @@ export default function ShoppingListDetailsPage() {
} }
}, [householdId, listId]); }, [householdId, listId]);
useEffect(() => {
fetchList();
}, [fetchList]);
// Pre-load household products for predictive inputs // Pre-load household products for predictive inputs
useEffect(() => { useEffect(() => {
if (!householdId) return; if (!householdId) return;
@ -81,28 +68,8 @@ export default function ShoppingListDetailsPage() {
// Handle WS Remote Event Broadcasts // Handle WS Remote Event Broadcasts
const handleRemoteSync = useCallback((msg: any) => { const handleRemoteSync = useCallback((msg: any) => {
console.log('🔔 Remote state delta payload:', msg); console.log('🔔 Remote state delta payload:', msg);
if (msg.type === 'ITEM_UPDATED') { mutateList(); // Revalidate with server on remote changes
setList((prev: any) => { }, [mutateList]);
if (!prev) return prev;
return {
...prev,
items: prev.items.map((it: any) =>
it.id === msg.itemId ? { ...it, ...msg.updates } : it
),
};
});
} else if (msg.type === 'ITEM_ADDED') {
setList((prev: any) => {
if (!prev) return prev;
return { ...prev, items: [...prev.items, msg.item] };
});
} else if (msg.type === 'ITEM_REMOVED') {
setList((prev: any) => {
if (!prev) return prev;
return { ...prev, items: prev.items.filter((it: any) => it.id !== msg.itemId) };
});
}
}, []);
// Inject Real-Time Hooks // Inject Real-Time Hooks
const { isConnected, toggleItemCheck } = useShoppingListSync( const { isConnected, toggleItemCheck } = useShoppingListSync(
@ -115,21 +82,23 @@ export default function ShoppingListDetailsPage() {
const handleToggleCheck = async (itemId: string, currentChecked: boolean) => { const handleToggleCheck = async (itemId: string, currentChecked: boolean) => {
const nextChecked = !currentChecked; const nextChecked = !currentChecked;
// Optimistic Client Update for ultimate snappy responsiveness // Optimistic Client Update
setList((prev: any) => ({ mutateList(async (prev: any) => ({
...prev, ...prev,
items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it) items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it)
})); }), { revalidate: false });
// Emit to WS Channel (broadcasts immediately to all other clients) // Emit to WS Channel
toggleItemCheck(itemId, nextChecked); toggleItemCheck(itemId, nextChecked);
// Persist standard Rest fallback ensuring safety // Persist standard Rest fallback ensuring safety
if (householdId) { if (householdId) {
try { try {
await updateShoppingItem(householdId, listId, itemId, { checked: nextChecked }); await updateShoppingItem(householdId, listId, itemId, { checked: nextChecked });
mutateList();
} catch (err) { } catch (err) {
console.error('Persistent toggle sync fail', err); console.error('Persistent toggle sync fail', err);
mutateList();
} }
} }
}; };
@ -148,7 +117,7 @@ export default function ShoppingListDetailsPage() {
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
}); });
setList(updated); mutateList(updated);
// Clear inputs // Clear inputs
setSelectedProductId(''); setSelectedProductId('');
setCustomItemName(''); setCustomItemName('');
@ -163,25 +132,33 @@ export default function ShoppingListDetailsPage() {
const handleDeleteItem = async (itemId: string) => { const handleDeleteItem = async (itemId: string) => {
if (!householdId) return; if (!householdId) return;
// Optimistic delete
mutateList(async (prev: any) => ({
...prev,
items: prev.items.filter((it: any) => it.id !== itemId)
}), { revalidate: false });
try { try {
const updated = await removeShoppingItem(householdId, listId, itemId); const updated = await removeShoppingItem(householdId, listId, itemId);
setList(updated); mutateList(updated);
} catch (err: any) { } catch (err: any) {
console.error(err.message); window.alert(err.message);
mutateList();
} }
}; };
// 3. Execute Final Checkout / Pantry Sync // 3. Execute Final Checkout / Pantry Sync
const handleSyncToPantry = async () => { const handleSyncToPantry = async () => {
if (!householdId) return; if (!householdId || !list) return;
const readyItems = list.items.filter((i: any) => i.checked && !i.addedToPantry); const readyItems = list.items.filter((i: any) => i.checked && !i.addedToPantry);
if (readyItems.length === 0) return; if (readyItems.length === 0) return;
if (!confirm(`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`)) return; if (!window.confirm(`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`)) return;
try { try {
const res = await syncToPantry(householdId, listId); const res = await syncToPantry(householdId, listId);
alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`); window.alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`);
// Mark list as completed automatically if all are done // Mark list as completed automatically if all are done
const allChecked = list.items.every((i: any) => i.checked || i.addedToPantry); const allChecked = list.items.every((i: any) => i.checked || i.addedToPantry);
@ -189,9 +166,9 @@ export default function ShoppingListDetailsPage() {
await updateShoppingList(householdId, listId, { status: 'completed' as any }); await updateShoppingList(householdId, listId, { status: 'completed' as any });
} }
fetchList(); mutateList();
} catch (err: any) { } catch (err: any) {
alert('Migration sync error: ' + err.message); window.alert('Migration sync error: ' + err.message);
} }
}; };
@ -207,7 +184,7 @@ export default function ShoppingListDetailsPage() {
return groups; return groups;
}, [list]); }, [list]);
if (isAuthLoading || loading) return <div style={{ padding: 40 }}>Hydrating session checklist...</div>; if (isAuthLoading || listLoading) return <div style={{ padding: 40 }}>Hydrating session checklist...</div>;
if (error || !list) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>; if (error || !list) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length; const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length;
@ -287,6 +264,7 @@ export default function ShoppingListDetailsPage() {
{/* Checkbox circle */} {/* Checkbox circle */}
<button <button
onClick={() => handleToggleCheck(it.id, it.checked)} onClick={() => handleToggleCheck(it.id, it.checked)}
aria-label={`Toggle check for ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`}
style={{ style={{
width: 22, height: 22, borderRadius: '50%', width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`, border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`,
@ -324,6 +302,7 @@ export default function ShoppingListDetailsPage() {
<button <button
onClick={() => handleDeleteItem(it.id)} onClick={() => handleDeleteItem(it.id)}
aria-label={`Delete ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, color: 'var(--ink-muted)', opacity: 0.5 }} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, color: 'var(--ink-muted)', opacity: 0.5 }}
> >
<Icon name="trash" style={{ width: 14 }} /> <Icon name="trash" style={{ width: 14 }} />

View file

@ -0,0 +1,220 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ShoppingListsPage from '../page';
import * as useApiModule from '@/lib/useApi';
import * as ShoppingListsService from '@/services/shopping-lists';
import * as MealPlansService from '@/services/meal-plans';
vi.mock('@/lib/useApi');
vi.mock('@/services/shopping-lists');
vi.mock('@/services/meal-plans');
describe('ShoppingListsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1',
isLoading: false,
user: null,
token: '123',
});
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'list1', name: 'Groceries', status: 'active', items: [], totalEstimatedCost: 10, createdAt: '2026-05-10' } as any,
]);
});
it('renders loading initially', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
render(<ShoppingListsPage />);
expect(screen.getByText('Groceries')).toBeInTheDocument();
});
it('renders prompt if no household', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: false } as any);
render(<ShoppingListsPage />);
expect(screen.getByText('Please join a household.')).toBeInTheDocument();
});
it('loads and displays shopping lists', async () => {
render(<ShoppingListsPage />);
await waitFor(() => {
expect(screen.getByText(/Groceries/i)).toBeInTheDocument();
});
});
it('toggles create list modal and submits', async () => {
vi.mocked(ShoppingListsService.createShoppingList).mockResolvedValue({
id: 'list2', name: 'New List', status: 'active', items: [], createdAt: '2026-05-11'
} as any);
render(<ShoppingListsPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /New Shopping List/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
// Wait for modal
const input = await screen.findByPlaceholderText(/e.g., Weekly Costco Run/i);
fireEvent.change(input, { target: { value: 'New List' } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
await waitFor(() => {
expect(ShoppingListsService.createShoppingList).toHaveBeenCalledWith('hh1', { name: 'New List', items: [] });
});
});
it('opens generate from meal plan modal', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1,
page: 1,
limit: 10,
});
vi.mocked(ShoppingListsService.generateFromMealPlan).mockResolvedValue({
id: 'list3', name: 'Generated', status: 'active', items: [], createdAt: '2026-05-11'
} as any);
render(<ShoppingListsPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /Generate from Meal Plan/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
await waitFor(() => {
expect(MealPlansService.listMealPlans).toHaveBeenCalled();
});
const generateBtn = await screen.findByText(/Week of/i);
fireEvent.click(generateBtn);
await waitFor(() => {
expect(ShoppingListsService.generateFromMealPlan).toHaveBeenCalledWith('hh1', 'mp1');
});
});
it('handles fetch errors', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockRejectedValue(new Error('Failed to load'));
render(<ShoppingListsPage />);
await waitFor(() => {
expect(screen.getByText('Failed to load')).toBeInTheDocument();
});
});
it('can cancel/close creation and gap scanning modals', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [],
total: 0, page: 1, limit: 10
});
const { container } = render(<ShoppingListsPage />);
// 1. Open Create Modal and close via overlay backdrop click
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
const createHeading = screen.getByText('Create Shopping List');
expect(createHeading).toBeInTheDocument();
// The overlay is the grandparent of the heading. Let's click it!
const overlay = createHeading.parentElement?.parentElement;
if (overlay) fireEvent.click(overlay);
expect(screen.queryByText('Create Shopping List')).not.toBeInTheDocument();
// 2. Re-open Create Modal and close via Cancel button click
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(screen.queryByText('Create Shopping List')).not.toBeInTheDocument();
// 3. Open Gap Modal and close via Close button
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
await waitFor(() => {
expect(screen.getByText('Scan Meal Plan Gaps')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Close/i }));
expect(screen.queryByText('Scan Meal Plan Gaps')).not.toBeInTheDocument();
});
it('renders empty state and opens create modal', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
render(<ShoppingListsPage />);
const createFirstBtn = await screen.findByRole('button', { name: /Create First List/i });
fireEvent.click(createFirstBtn);
expect(screen.getByText('Create Shopping List')).toBeInTheDocument();
});
it('renders completed lists', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'listc', name: 'Completed Groceries', status: 'completed', items: [], totalEstimatedCost: 25, createdAt: '2026-05-09' } as any
]);
render(<ShoppingListsPage />);
await screen.findByText('Completed Runs');
expect(screen.getByText('Completed Groceries')).toBeInTheDocument();
});
it('handles generate from meal plan failure', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1, page: 1, limit: 10
});
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue(new Error('Gen failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ShoppingListsPage />);
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
const generateBtn = await screen.findByText(/Week of/i);
fireEvent.click(generateBtn);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Gen failed');
});
alertSpy.mockRestore();
});
it('sorts lists by status and creation date', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'list1', name: 'List 1', status: 'completed', createdAt: '2026-05-10', items: [], totalEstimatedCost: 10 } as any,
{ id: 'list2', name: 'List 2', status: 'active', createdAt: '2026-05-12', items: [], totalEstimatedCost: 20 } as any,
]);
render(<ShoppingListsPage />);
await screen.findByText('List 2');
expect(screen.getByText('List 1')).toBeInTheDocument();
});
it('handles create list failure', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
vi.mocked(ShoppingListsService.createShoppingList).mockRejectedValue(new Error('Create failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ShoppingListsPage />);
const openBtn = await screen.findByRole('button', { name: /Create First List/i });
fireEvent.click(openBtn);
const input = screen.getByPlaceholderText(/e.g., Weekly Costco Run/i);
fireEvent.change(input, { target: { value: 'New List' } });
const submitBtn = screen.getByRole('button', { name: /^Create$/ });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Create failed');
});
alertSpy.mockRestore();
});
it('handles open gap modal fetch failure', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
vi.mocked(MealPlansService.listMealPlans).mockRejectedValue(new Error('Fetch plans failed'));
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<ShoppingListsPage />);
const btn = await screen.findByRole('button', { name: /Generate from Meal Plan/i });
fireEvent.click(btn);
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
spy.mockRestore();
});
});

View file

@ -0,0 +1,101 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ShoppingListPricesPage from '../page';
import * as useApiModule from '@/lib/useApi';
import * as PricesService from '@/services/prices';
vi.mock('@/lib/useApi');
vi.mock('@/services/prices');
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: vi.fn(),
}),
}));
describe('ShoppingListPricesPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1',
isLoading: false,
user: null,
token: '123',
} as any);
vi.mocked(PricesService.getPriceAnalytics).mockResolvedValue({
timeframe: { start: '2026-04-01', end: '2026-05-14' },
totalSpent: 150.00,
totalRecords: 10,
storeBreakdown: [
{ storeId: 'store1', storeName: 'Costco', spent: 100 },
{ storeId: 'store2', storeName: 'Trader Joes', spent: 50 },
],
recentPriceChanges: [
{
productId: 'p1',
productName: 'Milk',
storeId: 'store1',
storeName: 'Costco',
oldPrice: 3.50,
newPrice: 4.00,
percentageChange: 14.28,
trend: 'up',
}
],
priceAlerts: [
{ productName: 'Bread', storeName: 'Costco', changePercent: 15, previousPrice: 2.00, currentPrice: 2.30 }
],
spendingOverTime: [
{ period: 'Apr', total: 50 },
{ period: 'May', total: 100 }
],
spendingByCategory: [
{ category: 'Produce', total: 30 },
{ category: 'Bakery', total: 20 }
],
averageBasketByStore: [
{ storeId: 'store1', storeName: 'Costco', avgTotal: 150.00, tripCount: 5 },
{ storeId: 'store2', storeName: 'Trader Joes', avgTotal: 50.00, tripCount: 2 },
],
} as any);
});
it('renders loading initially', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
render(<ShoppingListPricesPage />);
expect(screen.getByText(/Synthesizing financial graphs/i)).toBeInTheDocument();
});
it('loads and displays analytics', async () => {
render(<ShoppingListPricesPage />);
await waitFor(() => {
expect(screen.getByText('$150.00')).toBeInTheDocument();
expect(screen.getByText('Costco')).toBeInTheDocument();
expect(screen.getByText('Trader Joes')).toBeInTheDocument();
});
});
it('handles fetch errors gracefully', async () => {
vi.mocked(PricesService.getPriceAnalytics).mockRejectedValue(new Error('Analytics failed'));
render(<ShoppingListPricesPage />);
await waitFor(() => {
expect(screen.getByText(/Analytics failed/i)).toBeInTheDocument();
});
});
it('navigates back to checklists when button is clicked', async () => {
render(<ShoppingListPricesPage />);
await screen.findByText('Costco');
const backBtn = screen.getByRole('button', { name: /Back to Checklists/i });
fireEvent.click(backBtn);
expect(mockPush).toHaveBeenCalledWith('/shopping-lists');
});
});

View file

@ -7,17 +7,27 @@ type Theme = 'light' | 'dark';
type Accent = 'sage' | 'cobalt' | 'terracotta' | 'graphite'; type Accent = 'sage' | 'cobalt' | 'terracotta' | 'graphite';
interface AccentTokens { interface AccentTokens {
brand: string; light: { brand: string; deep: string; soft: string; softInk: string; brandInk: string };
deep: string; dark: { brand: string; deep: string; soft: string; softInk: string; brandInk: string };
soft: string;
softInk: string;
} }
const ACCENTS: Record<Accent, AccentTokens> = { const ACCENTS: Record<Accent, AccentTokens> = {
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8', softInk: '#1e4a32' }, sage: {
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5', softInk: '#1d3d75' }, light: { brand: '#10b981', deep: '#059669', soft: 'rgba(16,185,129,0.1)', softInk: '#047857', brandInk: '#ffffff' },
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de', softInk: '#7d3825' }, dark: { brand: '#34d399', deep: '#10b981', soft: 'rgba(52,211,153,0.15)', softInk: '#6ee7b7', brandInk: '#022c22' },
graphite: { brand: '#2c2c28', deep: '#000000', soft: '#e8e6df', softInk: '#2c2c28' }, },
cobalt: {
light: { brand: '#3b82f6', deep: '#2563eb', soft: 'rgba(59,130,246,0.1)', softInk: '#1d4ed8', brandInk: '#ffffff' },
dark: { brand: '#60a5fa', deep: '#3b82f6', soft: 'rgba(96,165,250,0.15)', softInk: '#93c5fd', brandInk: '#172554' },
},
terracotta: {
light: { brand: '#f43f5e', deep: '#e11d48', soft: 'rgba(244,63,94,0.1)', softInk: '#be123c', brandInk: '#ffffff' },
dark: { brand: '#fb7185', deep: '#f43f5e', soft: 'rgba(251,113,133,0.15)', softInk: '#fda4af', brandInk: '#4c0519' },
},
graphite: {
light: { brand: '#52525b', deep: '#3f3f46', soft: 'rgba(82,82,91,0.1)', softInk: '#27272a', brandInk: '#ffffff' },
dark: { brand: '#a1a1aa', deep: '#71717a', soft: 'rgba(161,161,170,0.15)', softInk: '#d4d4d8', brandInk: '#18181b' },
},
}; };
interface ThemeContextValue { interface ThemeContextValue {
@ -36,13 +46,14 @@ export function useTheme(): ThemeContextValue {
return ctx; return ctx;
} }
function applyAccent(accent: Accent) { function applyAccent(accent: Accent, theme: Theme) {
const ac = ACCENTS[accent]; const ac = ACCENTS[accent][theme];
const r = document.documentElement.style; const r = document.documentElement.style;
r.setProperty('--brand', ac.brand); r.setProperty('--brand', ac.brand);
r.setProperty('--brand-deep', ac.deep); r.setProperty('--brand-deep', ac.deep);
r.setProperty('--brand-soft', ac.soft); r.setProperty('--brand-soft', ac.soft);
r.setProperty('--brand-soft-ink', ac.softInk); r.setProperty('--brand-soft-ink', ac.softInk);
r.setProperty('--brand-ink', ac.brandInk);
r.setProperty('--viz-1', ac.brand); r.setProperty('--viz-1', ac.brand);
} }
@ -66,9 +77,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Sync accent to DOM + localStorage. // Sync accent to DOM + localStorage.
useEffect(() => { useEffect(() => {
applyAccent(accent); applyAccent(accent, theme);
localStorage.setItem('mt-accent', accent); localStorage.setItem('mt-accent', accent);
}, [accent]); }, [accent, theme]);
function setTheme(t: Theme) { function setTheme(t: Theme) {
setThemeState(t); setThemeState(t);

View file

@ -78,7 +78,7 @@ describe('ThemeProvider', () => {
expect(screen.getByTestId('accent').textContent).toBe('cobalt'); expect(screen.getByTestId('accent').textContent).toBe('cobalt');
expect(localStorage.getItem('mt-accent')).toBe('cobalt'); expect(localStorage.getItem('mt-accent')).toBe('cobalt');
expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#2e5aa8'); expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#3b82f6');
}); });
it('toggleTheme flips light to dark', () => { it('toggleTheme flips light to dark', () => {

View file

@ -5,6 +5,7 @@ import { IconButton } from '../ui/IconButton';
import { Ring } from '../ui/Ring'; import { Ring } from '../ui/Ring';
import { SparkBars } from '../ui/SparkBars'; import { SparkBars } from '../ui/SparkBars';
import { SupplyBar } from '../ui/SupplyBar'; import { SupplyBar } from '../ui/SupplyBar';
import { Card, CardHeader, CardBody } from '../ui/Card';
describe('Avatar', () => { describe('Avatar', () => {
it('renders initial from name', () => { it('renders initial from name', () => {
@ -82,3 +83,36 @@ describe('SupplyBar', () => {
expect(screen.getByText('10')).toBeInTheDocument(); expect(screen.getByText('10')).toBeInTheDocument();
}); });
}); });
describe('Card', () => {
it('renders children and applies custom styles', () => {
render(
<Card className="custom-card" style={{ margin: '20px' }}>
<div>Card Content</div>
</Card>
);
expect(screen.getByText('Card Content')).toBeInTheDocument();
});
it('renders header with title, subtitle and action', () => {
render(
<CardHeader
title="My Card"
subtitle="My Subtitle"
action={<button>Click Me</button>}
/>
);
expect(screen.getByText('My Card')).toBeInTheDocument();
expect(screen.getByText('My Subtitle')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Click Me' })).toBeInTheDocument();
});
it('renders body with children', () => {
render(
<CardBody>
<div>Body Content</div>
</CardBody>
);
expect(screen.getByText('Body Content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { PageHeaderProvider, usePageHeader } from '../PageHeaderContext';
function TestComponent() {
const { header, setHeader } = usePageHeader();
return (
<div>
<span data-testid="title">{header.title}</span>
<button onClick={() => setHeader({ title: 'New Title' })}>Change</button>
</div>
);
}
describe('PageHeaderContext', () => {
it('provides and updates header state', () => {
render(
<PageHeaderProvider>
<TestComponent />
</PageHeaderProvider>
);
expect(screen.getByTestId('title').textContent).toBe('MeshiTrack');
fireEvent.click(screen.getByText('Change'));
expect(screen.getByTestId('title').textContent).toBe('New Title');
});
it('returns fallback when used outside provider', () => {
render(<TestComponent />);
// Outside provider title returns '' fallback
expect(screen.getByTestId('title').textContent).toBe('');
});
});

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import * as UI from '../index';
describe('UI Index Exports', () => {
it('should export all defined UI components', () => {
expect(UI.Icon).toBeDefined();
expect(UI.Card).toBeDefined();
expect(UI.Button).toBeDefined();
expect(UI.Pill).toBeDefined();
expect(UI.SupplyBar).toBeDefined();
expect(UI.Ring).toBeDefined();
expect(UI.SparkBars).toBeDefined();
expect(UI.IconButton).toBeDefined();
expect(UI.Avatar).toBeDefined();
});
});

View file

@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useShoppingListSync } from './useShoppingListSync';
vi.mock('@/services/shopping-lists', () => ({
getShoppingListSyncSocketUrl: vi.fn(() => 'ws://localhost/sync'),
}));
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
url: string;
readyState = 1; // OPEN
onopen: any = null;
onmessage: any = null;
onerror: any = null;
onclose: any = null;
send = vi.fn();
close = vi.fn();
constructor(url: string) {
this.url = url;
}
}
describe('useShoppingListSync', () => {
let originalWebSocket: any;
let createdSockets: MockWebSocket[] = [];
beforeEach(() => {
vi.clearAllMocks();
createdSockets = [];
originalWebSocket = global.WebSocket;
const MockClass = class extends MockWebSocket {
constructor(url: string) {
super(url);
createdSockets.push(this);
}
};
(MockClass as any).OPEN = 1;
(MockClass as any).CONNECTING = 0;
global.WebSocket = MockClass as any;
});
afterEach(() => {
global.WebSocket = originalWebSocket;
});
it('initializes and connects to the correct socket URL', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
expect(createdSockets).toHaveLength(1);
expect(createdSockets[0].url).toBe('ws://localhost/sync');
});
it('handles incoming item_updated messages correctly', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
act(() => {
if (ws.onmessage) ws.onmessage({ data: JSON.stringify({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }) });
});
expect(onSync).toHaveBeenCalledWith({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } });
});
it('broadcasts toggle item messages when connected', () => {
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
act(() => {
result.current.toggleItemCheck('item1', true);
});
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({
type: 'TOGGLE_ITEM',
itemId: 'item1',
checked: true,
}));
});
it('handles disconnect and reconnect backoff', () => {
vi.useFakeTimers();
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
let ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
expect(result.current.isConnected).toBe(true);
act(() => { if (ws.onclose) ws.onclose({ reason: 'test' }); });
expect(result.current.isConnected).toBe(false);
// After 1000ms it should attempt reconnect
act(() => { vi.advanceTimersByTime(1000); });
expect(createdSockets).toHaveLength(2);
vi.useRealTimers();
});
});

View file

@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as MealPlansService from './meal-plans';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
describe('meal-plans service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('listMealPlans with and without query', async () => {
await MealPlansService.listMealPlans('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans');
await MealPlansService.listMealPlans('hh1', { cursor: 'cur', limit: 10 });
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans?cursor=cur&limit=10');
});
it('getMealPlanByWeek', async () => {
await MealPlansService.getMealPlanByWeek('hh1', '2026-05-10');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/week/2026-05-10');
});
it('getMealPlan', async () => {
await MealPlansService.getMealPlan('hh1', 'mp1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
});
it('createMealPlan', async () => {
const data = { weekStartDate: '2026-05-10' } as any;
await MealPlansService.createMealPlan('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/meal-plans', data);
});
it('updateMealPlan', async () => {
const data = { days: [] } as any;
await MealPlansService.updateMealPlan('hh1', 'mp1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1', data);
});
it('updateMealPlanStatus', async () => {
await MealPlansService.updateMealPlanStatus('hh1', 'mp1', 'active' as any);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/status', { status: 'active' });
});
it('deleteMealPlan', async () => {
await MealPlansService.deleteMealPlan('hh1', 'mp1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
});
it('getSuggestions', async () => {
await MealPlansService.getSuggestions('hh1', 3);
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/suggestions?limit=3');
});
it('getShoppingGap', async () => {
await MealPlansService.getShoppingGap('hh1', 'mp1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/gap');
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as NutritionTargetsService from './nutrition-targets';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('nutrition-targets service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('getActiveNutritionTarget', async () => {
await NutritionTargetsService.getActiveNutritionTarget('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets');
});
it('getNutritionTargetHistory', async () => {
await NutritionTargetsService.getNutritionTargetHistory('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets/history');
});
it('setNutritionTarget', async () => {
const data = { calories: 2000 } as any;
await NutritionTargetsService.setNutritionTarget('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets', data);
});
it('calculateTargetPreset', async () => {
await NutritionTargetsService.calculateTargetPreset('hh1', 2500, 'gain');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets/presets', { calories: 2500, strategy: 'gain' });
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as PricesService from './prices';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('prices service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('recordPrice', async () => {
const data = { productId: 'p1', price: 10 } as any;
await PricesService.recordPrice('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices', data);
});
it('recordBulkPrices', async () => {
const data = { storeId: 's1', items: [] } as any;
await PricesService.recordBulkPrices('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices/bulk', data);
});
it('getPriceHistory with and without query', async () => {
await PricesService.getPriceHistory('hh1', 'p1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/history/p1');
await PricesService.getPriceHistory('hh1', 'p1', {
storeId: 's1',
startDate: '2026-05-01',
endDate: '2026-05-10',
cursor: 'cur',
limit: 10,
});
expect(apiClient.get).toHaveBeenCalledWith(
'/households/hh1/prices/history/p1?storeId=s1&startDate=2026-05-01&endDate=2026-05-10&cursor=cur&limit=10'
);
});
it('compareStores', async () => {
await PricesService.compareStores('hh1', 'p1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/compare/p1');
});
it('getPriceAnalytics', async () => {
await PricesService.getPriceAnalytics('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/analytics');
});
});

View file

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as ShoppingListsService from './shopping-lists';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
baseUrl: 'http://localhost:3001',
},
}));
describe('shopping-lists service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('getShoppingLists', async () => {
await ShoppingListsService.getShoppingLists('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists');
});
it('getShoppingList', async () => {
await ShoppingListsService.getShoppingList('hh1', 'list1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
});
it('createShoppingList', async () => {
const data = { name: 'Test' } as any;
await ShoppingListsService.createShoppingList('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists', data);
});
it('updateShoppingList', async () => {
const data = { name: 'Updated' } as any;
await ShoppingListsService.updateShoppingList('hh1', 'list1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1', data);
});
it('deleteShoppingList', async () => {
await ShoppingListsService.deleteShoppingList('hh1', 'list1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
});
it('addShoppingItem', async () => {
const data = { productId: 'p1' } as any;
await ShoppingListsService.addShoppingItem('hh1', 'list1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items', data);
});
it('updateShoppingItem', async () => {
const data = { checked: true } as any;
await ShoppingListsService.updateShoppingItem('hh1', 'list1', 'item1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1', data);
});
it('removeShoppingItem', async () => {
await ShoppingListsService.removeShoppingItem('hh1', 'list1', 'item1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1');
});
it('generateFromMealPlan', async () => {
await ShoppingListsService.generateFromMealPlan('hh1', 'mp1');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/from-meal-plan/mp1');
});
it('syncToPantry', async () => {
await ShoppingListsService.syncToPantry('hh1', 'list1');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/sync-to-pantry');
});
it('getBasketStoreComparison', async () => {
await ShoppingListsService.getBasketStoreComparison('hh1', 'list1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/stores');
});
it('getShoppingListSyncSocketUrl handles insecure and secure contexts', () => {
const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync');
apiClient.baseUrl = 'https://api.meshitrack.com';
const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync');
});
});

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff