Cleanup after initial plan
This commit is contained in:
parent
d2a7e652b3
commit
245520fb50
53 changed files with 6733 additions and 621 deletions
88
.agents/skills/grill-with-docs/SKILL.md
Normal file
88
.agents/skills/grill-with-docs/SKILL.md
Normal 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>
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,4 +90,59 @@ describe(MealPlanRepository.name, () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('finds meal plan by id and householdId', async () => {
|
||||||
|
const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' };
|
||||||
|
vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never);
|
||||||
|
|
||||||
|
const result = await repo.findById('mp1', 'hh1');
|
||||||
|
expect(MealPlanModel.findOne).toHaveBeenCalledWith({
|
||||||
|
_id: 'mp1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockPlan);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates meal plan using findOneAndUpdate', async () => {
|
||||||
|
vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||||
|
|
||||||
|
await repo.update('mp1', 'hh1', { status: MealPlanStatus.ACTIVE });
|
||||||
|
expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ _id: 'mp1', householdId: 'hh1' },
|
||||||
|
{ $set: { status: MealPlanStatus.ACTIVE } },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('deletes meal plan using findOneAndDelete', async () => {
|
||||||
|
vi.mocked(MealPlanModel.findOneAndDelete).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||||
|
|
||||||
|
const result = await repo.delete('mp1', 'hh1');
|
||||||
|
expect(MealPlanModel.findOneAndDelete).toHaveBeenCalledWith({
|
||||||
|
_id: 'mp1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ _id: 'mp1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold pagination cursor', () => {
|
||||||
|
it('applies pagination filter when cursor is provided', async () => {
|
||||||
|
const chain = makeChain([]);
|
||||||
|
vi.mocked(MealPlanModel.find).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
const cursor = Buffer.from('some-mongo-id').toString('base64');
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||||
|
|
||||||
|
expect(MealPlanModel.find).toHaveBeenCalledWith({
|
||||||
|
householdId: 'hh1',
|
||||||
|
_id: { $gt: 'some-mongo-id' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -243,4 +243,88 @@ describe('meal-plan.routes', () => {
|
||||||
expect(Array.isArray(body.missingItems)).toBe(true);
|
expect(Array.isArray(body.missingItems)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||||
|
it('returns plan if found', async () => {
|
||||||
|
const planWithMeal = makePlan({
|
||||||
|
createdAt: new Date(),
|
||||||
|
days: [{
|
||||||
|
date: '2026-05-10',
|
||||||
|
meals: [{
|
||||||
|
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||||
|
type: 'dinner',
|
||||||
|
recipeId: 'recipe-1',
|
||||||
|
recipeName: 'Spaghetti',
|
||||||
|
servings: 2,
|
||||||
|
perServingNutrition: emptyNutrition,
|
||||||
|
customName: 'My Pasta',
|
||||||
|
customNutrition: emptyNutrition,
|
||||||
|
notes: 'Very yummy',
|
||||||
|
}],
|
||||||
|
dailyNutritionTotal: emptyNutrition,
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
mockFindById.mockResolvedValue(planWithMeal);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()._id).toBe('plan-1');
|
||||||
|
expect(res.json().days[0].meals).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||||
|
it('updates plan content and returns it', async () => {
|
||||||
|
mockUpdate.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: MealPlanStatus.ACTIVE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/meal-plans/:id/status', () => {
|
||||||
|
it('updates plan status directly and returns it', async () => {
|
||||||
|
mockUpdateStatus.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/meal-plans/plan-1/status',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: MealPlanStatus.ACTIVE,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||||
|
it('deletes the plan and returns 204', async () => {
|
||||||
|
mockDelete.mockResolvedValue(makePlan());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,18 @@ describe(MealPlanService.name, () => {
|
||||||
});
|
});
|
||||||
expect(result.status).toBe(MealPlanStatus.ACTIVE);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -91,4 +91,19 @@ describe(NutritionTargetRepository.name, () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates specific target using findOneAndUpdate', async () => {
|
||||||
|
const updatedDoc = { _id: 't1', dailyCalories: 2100 };
|
||||||
|
vi.mocked(NutritionTargetModel.findOneAndUpdate).mockReturnValue(makeChain(updatedDoc) as never);
|
||||||
|
|
||||||
|
const result = await repo.update('t1', 'user1', 'hh1', { dailyCalories: 2100 });
|
||||||
|
expect(NutritionTargetModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ _id: 't1', userId: 'user1', householdId: 'hh1' },
|
||||||
|
{ $set: { dailyCalories: 2100 } },
|
||||||
|
{ new: true, lean: true }
|
||||||
|
);
|
||||||
|
expect(result).toEqual(updatedDoc);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,18 @@ describe('nutrition-target.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => {
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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: [] } };
|
||||||
|
|
|
||||||
|
|
@ -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,66 +156,56 @@ export default function DashboardPage() {
|
||||||
gap: 6,
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cabinetItems?.data.length ? (
|
{(() => {
|
||||||
cabinetItems.data.slice(0, 8).map((item) => (
|
const itemsWithBurnRate = summary?.data.filter((item) =>
|
||||||
<div
|
burnRates?.data.some((br) => br.medicineId === item.medicineId),
|
||||||
key={item._id}
|
);
|
||||||
style={{
|
|
||||||
display: 'grid',
|
if (!itemsWithBurnRate?.length) {
|
||||||
gridTemplateColumns: '140px 1fr',
|
return <EmptyState message="No active medicines in regimens." />;
|
||||||
gap: 12,
|
}
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: 12,
|
return itemsWithBurnRate.slice(0, 8).map((item) => {
|
||||||
padding: '4px 0',
|
const matchedBR = burnRates?.data.find(
|
||||||
}}
|
(br) => br.medicineId === item.medicineId,
|
||||||
>
|
);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={item.medicineId}
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 500,
|
display: 'grid',
|
||||||
color: 'var(--ink)',
|
gridTemplateColumns: '160px 1fr',
|
||||||
overflow: 'hidden',
|
gap: 12,
|
||||||
textOverflow: 'ellipsis',
|
alignItems: 'center',
|
||||||
whiteSpace: 'nowrap',
|
fontSize: 12,
|
||||||
|
padding: '4px 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.medicineName ?? 'Unknown'}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
fontWeight: 500,
|
||||||
height: 6,
|
color: 'var(--ink)',
|
||||||
background: 'var(--bg-inset)',
|
|
||||||
borderRadius: 3,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
|
title={item.medicineName}
|
||||||
>
|
>
|
||||||
<div
|
{item.medicineName ?? 'Unknown'}
|
||||||
style={{
|
</div>
|
||||||
height: '100%',
|
<div style={{ flex: 1 }}>
|
||||||
width: `${Math.min(100, (item.quantity / 100) * 100)}%`,
|
{matchedBR && matchedBR.daysUntilEmpty !== null ? (
|
||||||
background: 'var(--brand)',
|
<SupplyBar days={matchedBR.daysUntilEmpty} />
|
||||||
borderRadius: 3,
|
) : (
|
||||||
}}
|
<div style={{ fontSize: 11, color: 'var(--ink-faint)' }}>
|
||||||
/>
|
As needed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
className="num"
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
minWidth: 40,
|
|
||||||
textAlign: 'right',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.quantity} {item.unit}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))
|
});
|
||||||
) : (
|
})()}
|
||||||
<EmptyState message="No cabinet items yet." />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
if (!householdId) return;
|
const detailKey = householdId && view === 'detail' ? `cabinet-items-${householdId}-${filterStatus}` : null;
|
||||||
setLoading(true);
|
|
||||||
try {
|
const { data: summaryResponse, mutate: mutateSummary, isLoading: summaryLoading, error: summaryError } = useSWR(
|
||||||
if (view === 'summary') {
|
summaryKey,
|
||||||
const result = await getCabinetSummary(householdId);
|
() => getCabinetSummary(householdId!),
|
||||||
setSummaryItems(result.data);
|
);
|
||||||
if (expandedMedicine) {
|
|
||||||
const expanded = await listCabinetItems(householdId, {
|
const { data: detailResponse, mutate: mutateDetail, isLoading: detailLoading, error: detailError } = useSWR(
|
||||||
medicineId: expandedMedicine,
|
detailKey,
|
||||||
status: CabinetItemStatus.ACTIVE,
|
() =>
|
||||||
limit: 50,
|
listCabinetItems(householdId!, {
|
||||||
});
|
status: (filterStatus as CabinetItemStatus) || undefined,
|
||||||
setExpandedItems(expanded.data);
|
limit: 50,
|
||||||
if (expanded.data.length === 0) {
|
}),
|
||||||
setExpandedMedicine(null);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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 {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [householdId, view, filterStatus, expandedMedicine]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (householdId) {
|
const err = summaryError || detailError;
|
||||||
fetchData();
|
if (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load cabinet');
|
||||||
}
|
}
|
||||||
}, [householdId, fetchData]);
|
}, [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;
|
||||||
|
setExpandLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await listCabinetItems(householdId, {
|
||||||
|
medicineId: medId,
|
||||||
|
status: CabinetItemStatus.ACTIVE,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
setExpandedItems(result.data);
|
||||||
|
if (result.data.length === 0) {
|
||||||
|
setExpandedMedicine(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setExpandedItems([]);
|
||||||
|
} finally {
|
||||||
|
setExpandLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
storageLocation: storageFilter || undefined,
|
||||||
abortRef.current?.abort();
|
status: statusFilter || undefined,
|
||||||
abortRef.current = new AbortController();
|
limit: 50,
|
||||||
setLoading(true);
|
}), [storageFilter, statusFilter]);
|
||||||
setError('');
|
|
||||||
try {
|
const swrKey = householdId ? `pantry-${householdId}-${JSON.stringify(query)}` : null;
|
||||||
const result = await listPantryItems(householdId, {
|
const { data: pantryResponse, mutate: mutatePantry, isLoading: loading, error: swrError } = useSWR(
|
||||||
storageLocation: storageFilter || undefined,
|
swrKey,
|
||||||
status: statusFilter || undefined,
|
() => listPantryItems(householdId, query)
|
||||||
limit: 50,
|
);
|
||||||
});
|
|
||||||
setItems(result.data);
|
const items = pantryResponse?.data ?? [];
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && err.name !== 'AbortError') {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 }} />
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
packages/web/src/components/ui/__tests__/index.test.ts
Normal file
16
packages/web/src/components/ui/__tests__/index.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
107
packages/web/src/lib/useShoppingListSync.test.ts
Normal file
107
packages/web/src/lib/useShoppingListSync.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
packages/web/src/services/meal-plans.test.ts
Normal file
68
packages/web/src/services/meal-plans.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
packages/web/src/services/nutrition-targets.test.ts
Normal file
37
packages/web/src/services/nutrition-targets.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
54
packages/web/src/services/prices.test.ts
Normal file
54
packages/web/src/services/prices.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
87
packages/web/src/services/shopping-lists.test.ts
Normal file
87
packages/web/src/services/shopping-lists.test.ts
Normal 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
BIN
packages/web/test_results.txt
Normal file
BIN
packages/web/test_results.txt
Normal file
Binary file not shown.
BIN
packages/web/test_results_2.txt
Normal file
BIN
packages/web/test_results_2.txt
Normal file
Binary file not shown.
1891
packages/web/test_results_2_utf8.txt
Normal file
1891
packages/web/test_results_2_utf8.txt
Normal file
File diff suppressed because it is too large
Load diff
1639
packages/web/test_results_utf8.txt
Normal file
1639
packages/web/test_results_utf8.txt
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue