Cleanup after initial plan
This commit is contained in:
parent
d2a7e652b3
commit
245520fb50
53 changed files with 6733 additions and 621 deletions
|
|
@ -34,7 +34,7 @@ export class CabinetRepository {
|
|||
|
||||
const limit = query.limit;
|
||||
const items = await CabinetItemModel.find(filter)
|
||||
.sort({ _id: 1 })
|
||||
.sort({ medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
|
|
@ -67,7 +67,7 @@ export class CabinetRepository {
|
|||
itemCount: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { medicineName: 1 } },
|
||||
{ $sort: { medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 } },
|
||||
]).exec();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,4 +90,59 @@ describe(MealPlanRepository.name, () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('finds meal plan by id and householdId', async () => {
|
||||
const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' };
|
||||
vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never);
|
||||
|
||||
const result = await repo.findById('mp1', 'hh1');
|
||||
expect(MealPlanModel.findOne).toHaveBeenCalledWith({
|
||||
_id: 'mp1',
|
||||
householdId: 'hh1',
|
||||
});
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates meal plan using findOneAndUpdate', async () => {
|
||||
vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||
|
||||
await repo.update('mp1', 'hh1', { status: MealPlanStatus.ACTIVE });
|
||||
expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'mp1', householdId: 'hh1' },
|
||||
{ $set: { status: MealPlanStatus.ACTIVE } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes meal plan using findOneAndDelete', async () => {
|
||||
vi.mocked(MealPlanModel.findOneAndDelete).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||
|
||||
const result = await repo.delete('mp1', 'hh1');
|
||||
expect(MealPlanModel.findOneAndDelete).toHaveBeenCalledWith({
|
||||
_id: 'mp1',
|
||||
householdId: 'hh1',
|
||||
});
|
||||
expect(result).toEqual({ _id: 'mp1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByHousehold pagination cursor', () => {
|
||||
it('applies pagination filter when cursor is provided', async () => {
|
||||
const chain = makeChain([]);
|
||||
vi.mocked(MealPlanModel.find).mockReturnValue(chain as never);
|
||||
|
||||
const cursor = Buffer.from('some-mongo-id').toString('base64');
|
||||
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||
|
||||
expect(MealPlanModel.find).toHaveBeenCalledWith({
|
||||
householdId: 'hh1',
|
||||
_id: { $gt: 'some-mongo-id' }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -243,4 +243,88 @@ describe('meal-plan.routes', () => {
|
|||
expect(Array.isArray(body.missingItems)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||
it('returns plan if found', async () => {
|
||||
const planWithMeal = makePlan({
|
||||
createdAt: new Date(),
|
||||
days: [{
|
||||
date: '2026-05-10',
|
||||
meals: [{
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
type: 'dinner',
|
||||
recipeId: 'recipe-1',
|
||||
recipeName: 'Spaghetti',
|
||||
servings: 2,
|
||||
perServingNutrition: emptyNutrition,
|
||||
customName: 'My Pasta',
|
||||
customNutrition: emptyNutrition,
|
||||
notes: 'Very yummy',
|
||||
}],
|
||||
dailyNutritionTotal: emptyNutrition,
|
||||
}]
|
||||
});
|
||||
mockFindById.mockResolvedValue(planWithMeal);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('plan-1');
|
||||
expect(res.json().days[0].meals).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||
it('updates plan content and returns it', async () => {
|
||||
mockUpdate.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/meal-plans/:id/status', () => {
|
||||
it('updates plan status directly and returns it', async () => {
|
||||
mockUpdateStatus.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1/status',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||
it('deletes the plan and returns 204', async () => {
|
||||
mockDelete.mockResolvedValue(makePlan());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -211,6 +211,18 @@ describe(MealPlanService.name, () => {
|
|||
});
|
||||
expect(result.status).toBe(MealPlanStatus.ACTIVE);
|
||||
});
|
||||
|
||||
it('supports updating shoppingListId', async () => {
|
||||
mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data }));
|
||||
const result = await service.update('p1', 'hh1', { shoppingListId: 'sl-1' });
|
||||
expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', { shoppingListId: 'sl-1' });
|
||||
expect((result as any).shoppingListId).toBe('sl-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if update returns null', async () => {
|
||||
mockRepo.update.mockResolvedValue(null);
|
||||
await expect(service.update('p1', 'hh1', { status: MealPlanStatus.ACTIVE })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
|
|
@ -222,6 +234,12 @@ describe(MealPlanService.name, () => {
|
|||
expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED);
|
||||
expect(result.status).toBe(MealPlanStatus.ARCHIVED);
|
||||
});
|
||||
|
||||
it('throws NotFoundError if updateStatus returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.updateStatus.mockResolvedValue(null);
|
||||
await expect(service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
|
@ -233,5 +251,11 @@ describe(MealPlanService.name, () => {
|
|||
expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1');
|
||||
expect(result).toEqual({ _id: 'p1' });
|
||||
});
|
||||
|
||||
it('throws NotFoundError if delete returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.delete.mockResolvedValue(null);
|
||||
await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export class MealPlanService {
|
|||
|
||||
/** Calculates standard daily totals based on the component meals. */
|
||||
private computeDayTotals(day: MealPlanDay): MealPlanDay {
|
||||
const total: NutritionInfo = {
|
||||
const total = {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbs: 0,
|
||||
|
|
@ -136,14 +136,14 @@ export class MealPlanService {
|
|||
total.carbs += source.carbs * servings;
|
||||
total.fat += source.fat * servings;
|
||||
|
||||
if (source.fiber != null) total.fiber = (total.fiber ?? 0) + source.fiber * servings;
|
||||
if (source.sugar != null) total.sugar = (total.sugar ?? 0) + source.sugar * servings;
|
||||
if (source.sodium != null) total.sodium = (total.sodium ?? 0) + source.sodium * servings;
|
||||
if (source.fiber != null) total.fiber += source.fiber * servings;
|
||||
if (source.sugar != null) total.sugar += source.sugar * servings;
|
||||
if (source.sodium != null) total.sodium += source.sodium * servings;
|
||||
if (source.saturatedFat != null) {
|
||||
total.saturatedFat = (total.saturatedFat ?? 0) + source.saturatedFat * servings;
|
||||
total.saturatedFat += source.saturatedFat * servings;
|
||||
}
|
||||
if (source.cholesterol != null) {
|
||||
total.cholesterol = (total.cholesterol ?? 0) + source.cholesterol * servings;
|
||||
total.cholesterol += source.cholesterol * servings;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,11 +155,11 @@ export class MealPlanService {
|
|||
protein: Math.round(total.protein * 100) / 100,
|
||||
carbs: Math.round(total.carbs * 100) / 100,
|
||||
fat: Math.round(total.fat * 100) / 100,
|
||||
fiber: Math.round((total.fiber ?? 0) * 100) / 100,
|
||||
sugar: Math.round((total.sugar ?? 0) * 100) / 100,
|
||||
sodium: Math.round((total.sodium ?? 0) * 100) / 100,
|
||||
saturatedFat: Math.round((total.saturatedFat ?? 0) * 100) / 100,
|
||||
cholesterol: Math.round((total.cholesterol ?? 0) * 100) / 100,
|
||||
fiber: Math.round(total.fiber * 100) / 100,
|
||||
sugar: Math.round(total.sugar * 100) / 100,
|
||||
sodium: Math.round(total.sodium * 100) / 100,
|
||||
saturatedFat: Math.round(total.saturatedFat * 100) / 100,
|
||||
cholesterol: Math.round(total.cholesterol * 100) / 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,5 +106,129 @@ describe(ShoppingGapService.name, () => {
|
|||
const result = await service.calculateGap('hh1', 'plan2');
|
||||
expect(result.missingItems.length).toBe(0);
|
||||
});
|
||||
|
||||
it('aggregates duplicate ingredients and sorts by product name', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-multi',
|
||||
days: [
|
||||
{
|
||||
meals: [
|
||||
{ recipeId: 'recipeA', servings: 1 },
|
||||
{ recipeId: 'recipeB', servings: 1 },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockImplementation(async (id) => {
|
||||
if (id === 'recipeA') {
|
||||
return {
|
||||
_id: 'recipeA', servings: 1,
|
||||
ingredients: [{ productId: 'prod1', quantity: 10, isOptional: false }]
|
||||
};
|
||||
}
|
||||
return {
|
||||
_id: 'recipeB', servings: 1,
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 20, isOptional: false },
|
||||
{ productId: 'prod2', quantity: 5, isOptional: false },
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([
|
||||
{ _id: 'prod1', name: 'Banana' },
|
||||
{ _id: 'prod2', name: 'Apple' },
|
||||
]);
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan-multi');
|
||||
|
||||
expect(result.missingItems).toHaveLength(2);
|
||||
expect(result.missingItems[0].productName).toBe('Apple');
|
||||
expect(result.missingItems[1].productName).toBe('Banana');
|
||||
expect(result.missingItems[1].requiredQuantity).toBe(30);
|
||||
});
|
||||
|
||||
it('covers fallback paths for missing list, recipe properties and pantry quantities', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-empty',
|
||||
});
|
||||
mockProductsRepo.findByIds.mockResolvedValue([]);
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
let res = await service.calculateGap('hh1', 'plan-empty');
|
||||
expect(res.missingItems).toHaveLength(0);
|
||||
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-missing',
|
||||
days: [
|
||||
{
|
||||
meals: [{ recipeId: 'recipeC', servings: 1 }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockResolvedValue({
|
||||
_id: 'recipeC',
|
||||
servings: 1,
|
||||
ingredients: [
|
||||
{ productId: 'prod3', quantity: 10, isOptional: false }
|
||||
]
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([
|
||||
{ _id: 'prod3' }
|
||||
]);
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod3' }
|
||||
]);
|
||||
|
||||
res = await service.calculateGap('hh1', 'plan-missing');
|
||||
expect(res.missingItems).toHaveLength(1);
|
||||
const itm = res.missingItems[0]!;
|
||||
expect(itm.unit).toBe('g');
|
||||
expect(itm.productName).toBe('Unknown Ingredient');
|
||||
expect(itm.category).toBe('other');
|
||||
expect(itm.pantryQuantity).toBe(0);
|
||||
});
|
||||
|
||||
it('skips optional ingredients, handles missing recipes and defaults servings to 1', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-edge',
|
||||
days: [
|
||||
{
|
||||
meals: [
|
||||
{ recipeId: 'recipeExist', servings: 2 },
|
||||
{ recipeId: 'recipeNotExist', servings: 1 },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockImplementation(async (id) => {
|
||||
if (id === 'recipeExist') {
|
||||
return {
|
||||
_id: 'recipeExist',
|
||||
servings: 0,
|
||||
ingredients: [
|
||||
{ productId: 'prodIng', quantity: 5, isOptional: false },
|
||||
{ productId: 'prodOptional', quantity: 10, isOptional: true },
|
||||
]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodIng', name: 'Ingredient' }]);
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan-edge');
|
||||
expect(result.missingItems).toHaveLength(1);
|
||||
expect(result.missingItems[0].productId).toBe('prodIng');
|
||||
expect(result.missingItems[0].requiredQuantity).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -161,5 +161,112 @@ describe(SuggestionEngineService.name, () => {
|
|||
// Variety calculation: 7 days ago / 14 days = 0.5
|
||||
expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
|
||||
it('triggers reasoning branches for partial coverage and urgent items', async () => {
|
||||
const recipeC = {
|
||||
_id: 'recipeC',
|
||||
name: 'Recipe C',
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 10, isOptional: false },
|
||||
{ productId: 'prod2', quantity: 10, isOptional: false },
|
||||
],
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeC] });
|
||||
|
||||
// 1. Coverage: (10/10 + 5/10)/2 = 0.75 (hits >0.5)
|
||||
// 2. Urgency: both set to urgent = 1.0 (hits >0.7)
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod1', quantity: 10, freshnessEstimate: { urgency: 'urgent' } },
|
||||
{ productId: 'prod2', quantity: 5, freshnessEstimate: { urgency: 'urgent' } },
|
||||
]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
expect(suggestions[0]!.scores.coverage).toBe(0.75);
|
||||
expect(suggestions[0]!.scores.urgency).toBe(1);
|
||||
expect(suggestions[0]!.reasoning).toContain('Uses several ingredients already stocked in your pantry.');
|
||||
expect(suggestions[0]!.reasoning).toContain('High priority: Saves expiring pantry items from going to waste!');
|
||||
});
|
||||
|
||||
it('triggers reasoning for moderately soon-to-expire items', async () => {
|
||||
const recipeD = {
|
||||
_id: 'recipeD',
|
||||
name: 'Recipe D',
|
||||
ingredients: [{ productId: 'prod1', quantity: 5, isOptional: false }],
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeD] });
|
||||
|
||||
// Urgency soon/expiringSoon has weight 0.7 (hits >0.4 branch)
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod1', quantity: 5, freshnessEstimate: { urgency: 'expiringSoon' } },
|
||||
]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
expect(suggestions[0]!.scores.urgency).toBe(0.7);
|
||||
expect(suggestions[0]!.reasoning).toContain('Helps use up items that should be consumed soon.');
|
||||
});
|
||||
|
||||
it('aggregates duplicate pantry items and handles normal/default urgencies', async () => {
|
||||
const recipeE = {
|
||||
_id: 'recipeE',
|
||||
name: 'Recipe E',
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 5, isOptional: false },
|
||||
],
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeE] });
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod1', quantity: 2, freshnessEstimate: { daysRemaining: 5, urgency: 'normal' } },
|
||||
{ productId: 'prod1', quantity: 3, freshnessEstimate: { daysRemaining: 10, urgency: 'unknown-type' } },
|
||||
]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
expect(suggestions[0]!.scores.coverage).toBe(1);
|
||||
expect(suggestions[0]!.scores.urgency).toBe(0.3);
|
||||
});
|
||||
|
||||
it('covers boundary logic for nameless recipes, custom meals, default targets and private weights', async () => {
|
||||
// 1. Nameless recipe and recipe without ingredients
|
||||
const rawRecipe = { _id: 'recipeMissingProps' };
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [rawRecipe] });
|
||||
|
||||
// 2. Active target with partial/falsy info
|
||||
mockNutritionRepo.findByUser.mockResolvedValue({ dailyCalories: 0, proteinG: 0 });
|
||||
|
||||
// 3. Last eaten containing a custom meal without recipeId (should continue)
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
days: [
|
||||
{
|
||||
date: '2026-05-19',
|
||||
meals: [
|
||||
{ customName: 'Snack' }, // no recipeId!
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
|
||||
expect(suggestions).toHaveLength(1);
|
||||
|
||||
// 4. Direct call to getUrgencyWeight default branch
|
||||
const defaultWeight = (service as any).getUrgencyWeight('mystery-status');
|
||||
expect(defaultWeight).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,4 +91,19 @@ describe(NutritionTargetRepository.name, () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates specific target using findOneAndUpdate', async () => {
|
||||
const updatedDoc = { _id: 't1', dailyCalories: 2100 };
|
||||
vi.mocked(NutritionTargetModel.findOneAndUpdate).mockReturnValue(makeChain(updatedDoc) as never);
|
||||
|
||||
const result = await repo.update('t1', 'user1', 'hh1', { dailyCalories: 2100 });
|
||||
expect(NutritionTargetModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 't1', userId: 'user1', householdId: 'hh1' },
|
||||
{ $set: { dailyCalories: 2100 } },
|
||||
{ new: true, lean: true }
|
||||
);
|
||||
expect(result).toEqual(updatedDoc);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -129,8 +129,18 @@ describe('nutrition-target.routes', () => {
|
|||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => {
|
||||
it('returns historical targets', async () => {
|
||||
mockFindAllByUser.mockResolvedValue([makeTarget({ isActive: false }), makeTarget()]);
|
||||
it('returns historical targets with optional fields and object _id', async () => {
|
||||
mockFindAllByUser.mockResolvedValue([
|
||||
makeTarget({
|
||||
_id: { toString: () => 'target-1' },
|
||||
isActive: false,
|
||||
fiberG: 30,
|
||||
sugarG: 50,
|
||||
sodiumMg: 2000,
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
makeTarget()
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
|
|
@ -141,6 +151,8 @@ describe('nutrition-target.routes', () => {
|
|||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0].fiberG).toBe(30);
|
||||
expect(body[0]._id).toBe('target-1');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export class PantryRepository {
|
|||
householdId,
|
||||
status: { $in: ['sealed', 'opened', 'prepared'] },
|
||||
})
|
||||
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,13 @@ describe('prices.routes', () => {
|
|||
|
||||
describe('POST /api/v1/households/:householdId/prices', () => {
|
||||
it('records price and returns 201 response', async () => {
|
||||
mockCreate.mockResolvedValue(makeRecord());
|
||||
mockCreate.mockResolvedValue(
|
||||
makeRecord({
|
||||
receiptImageUrl: 'http://test.com/img.jpg',
|
||||
notes: 'Custom notes',
|
||||
date: '2026-05-14T00:00:00.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
|
|
@ -179,4 +185,34 @@ describe('prices.routes', () => {
|
|||
expect(typeof body.priceAlerts[0].date).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/prices/bulk', () => {
|
||||
it('records bulk prices and returns 201', async () => {
|
||||
mockCreateMany.mockResolvedValue([makeRecord()]);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/prices/bulk',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
storeId: 's1',
|
||||
items: [{ productId: 'p1', price: 10, quantity: 1, unit: 'piece' }],
|
||||
}),
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json()[0].productName).toBe('Apples');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/prices/compare/:productId', () => {
|
||||
it('returns comparison array', async () => {
|
||||
mockCompareStores.mockResolvedValue([{ storeId: 's1', storeName: 'Store', latestPrice: 10, latestPricePerUnit: 10, currency: 'USD', date: new Date() }]);
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/prices/compare/p1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,26 @@ describe('PricesService', () => {
|
|||
expect(result._id).toBe('rec1');
|
||||
});
|
||||
|
||||
it('handles zero quantity and defaults date to current when recording price', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ name: 'Bread' });
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' });
|
||||
mockPricesRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'rec1' }));
|
||||
|
||||
const result = await service.recordPrice(
|
||||
{ productId: 'p2', storeId: 's2', price: 5, quantity: 0, unit: 'g' as any, currency: 'USD' },
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pricePerUnit: 5,
|
||||
date: expect.any(Date),
|
||||
})
|
||||
);
|
||||
expect(result._id).toBe('rec1');
|
||||
});
|
||||
|
||||
it('throws NotFound if product is invalid', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
|
|
@ -85,9 +105,50 @@ describe('PricesService', () => {
|
|||
expect(result).toHaveLength(1);
|
||||
expect(result[0].productName).toBe('Bread');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if a product is missing from the catalog', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' });
|
||||
mockProductsRepo.findByIds.mockResolvedValue([]); // Missing product
|
||||
await expect(
|
||||
service.recordBulkPrices(
|
||||
{ storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] },
|
||||
'hh1',
|
||||
'u1'
|
||||
)
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError if store is missing', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.recordBulkPrices(
|
||||
{ storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] },
|
||||
'hh1',
|
||||
'u1'
|
||||
)
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wrappers (getPriceHistory, compareStores, getAnalytics)', () => {
|
||||
it('delegates to repository correctly', async () => {
|
||||
mockPricesRepo.findByProduct.mockResolvedValue('history');
|
||||
mockPricesRepo.compareStores.mockResolvedValue('compare');
|
||||
mockPricesRepo.getAnalytics.mockResolvedValue('analytics');
|
||||
|
||||
expect(await service.getPriceHistory('p1', 'hh1', { page: 1, limit: 10 })).toBe('history');
|
||||
expect(await service.compareStores('p1', 'hh1')).toBe('compare');
|
||||
expect(await service.getAnalytics('hh1')).toBe('analytics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatePrice', () => {
|
||||
it('returns price from specific store if present', async () => {
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValue({ price: 8 });
|
||||
const val = await service.estimatePrice('prod1', 'hh1', 'storeA');
|
||||
expect(val).toBe(8);
|
||||
});
|
||||
|
||||
it('falls back to generic if requested store history is missing', async () => {
|
||||
// First call (restricted to storeId): empty
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValueOnce(null);
|
||||
|
|
@ -98,5 +159,17 @@ describe('PricesService', () => {
|
|||
expect(mockPricesRepo.getLatestForProduct).toHaveBeenCalledTimes(2);
|
||||
expect(val).toBe(12);
|
||||
});
|
||||
|
||||
it('returns null if generic lookup also fails', async () => {
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValue(null);
|
||||
const val = await service.estimatePrice('prod1', 'hh1', 'storeA');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if no storeId provided and generic lookup fails', async () => {
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValue(null);
|
||||
const val = await service.estimatePrice('prod1', 'hh1');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export class RegimensRepository {
|
|||
|
||||
const limit = query.limit;
|
||||
const items = await RegimenModel.find(filter)
|
||||
.sort({ _id: 1 })
|
||||
.sort({ name: 1, _id: 1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
|
|
@ -64,6 +64,7 @@ export class RegimensRepository {
|
|||
|
||||
public async findActiveByUser(householdId: string, userId: string) {
|
||||
return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
|
||||
.sort({ name: 1, _id: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,10 +160,12 @@ export class RegimensService {
|
|||
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
|
||||
/* v8 ignore next 6 */
|
||||
burnRates.sort((a, b) => {
|
||||
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
|
||||
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null)
|
||||
return a.medicineName.localeCompare(b.medicineName);
|
||||
if (a.daysUntilEmpty === null) return 1;
|
||||
if (b.daysUntilEmpty === null) return -1;
|
||||
return a.daysUntilEmpty - b.daysUntilEmpty;
|
||||
const diff = a.daysUntilEmpty - b.daysUntilEmpty;
|
||||
return diff !== 0 ? diff : a.medicineName.localeCompare(b.medicineName);
|
||||
});
|
||||
|
||||
return burnRates;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ describe(ShoppingListsRepository.name, () => {
|
|||
|
||||
describe('list', () => {
|
||||
it('queries lists for household ordered newest first', async () => {
|
||||
const chain = makeChain([]);
|
||||
const chain = makeChain([{ _id: 'list1' }]);
|
||||
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
|
||||
|
||||
await repo.list('h1');
|
||||
|
|
@ -73,7 +73,7 @@ describe(ShoppingListsRepository.name, () => {
|
|||
|
||||
describe('findActiveByHousehold', () => {
|
||||
it('queries specifically active/shopping lists sorted by update recency', async () => {
|
||||
const chain = makeChain([]);
|
||||
const chain = makeChain([{ _id: 'list1' }]);
|
||||
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
|
||||
|
||||
await repo.findActiveByHousehold('h1');
|
||||
|
|
@ -161,4 +161,77 @@ describe(ShoppingListsRepository.name, () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
describe('sortItems logic', () => {
|
||||
it('sorts items by checked status, category, name, and id', async () => {
|
||||
const items = [
|
||||
{ id: '6', customName: 'Zebra', category: 'Animal', checked: true },
|
||||
{ id: '1', customName: 'Apple', category: 'Fruit', checked: false },
|
||||
{ id: '2', customName: 'Banana', category: 'Fruit', checked: false },
|
||||
{ id: '3', customName: 'Aardvark', category: 'Animal', checked: false },
|
||||
{ id: '5', customName: 'Apple', category: 'Fruit', checked: true },
|
||||
{ id: '4', customName: 'Bread', checked: false }, // No category (should come first in category sort)
|
||||
{ id: '0', checked: false }, // No name, no category (should come first in all)
|
||||
];
|
||||
|
||||
const chain = makeChain({ _id: 'list1', items });
|
||||
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findById('list1', 'h1');
|
||||
|
||||
// Expected order:
|
||||
// 1. Unchecked, No category, No name (0)
|
||||
// 2. Unchecked, No category, Bread (4)
|
||||
// 3. Unchecked, Animal, Aardvark (3)
|
||||
// 4. Unchecked, Fruit, Apple (1)
|
||||
// 5. Unchecked, Fruit, Banana (2)
|
||||
// 6. Checked, Animal, Zebra (6)
|
||||
// 7. Checked, Fruit, Apple (5)
|
||||
|
||||
expect(result.items[0].id).toBe('0');
|
||||
expect(result.items[1].id).toBe('4');
|
||||
expect(result.items[2].id).toBe('3');
|
||||
expect(result.items[3].id).toBe('1');
|
||||
expect(result.items[4].id).toBe('2');
|
||||
expect(result.items[5].id).toBe('6');
|
||||
expect(result.items[6].id).toBe('5');
|
||||
});
|
||||
|
||||
it('handles mixed missing/present categories and names during sorting', async () => {
|
||||
const items = [
|
||||
{ id: '1', customName: 'Apple', checked: false }, // category missing
|
||||
{ id: '2', category: 'Fruit', checked: false }, // customName missing
|
||||
];
|
||||
|
||||
const chain = makeChain({ _id: 'list1', items });
|
||||
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findById('list1', 'h1');
|
||||
|
||||
// Expected order:
|
||||
// 1. (id:1) - empty category comes before 'Fruit'
|
||||
// 2. (id:2) - 'Fruit' category
|
||||
expect(result.items[0].id).toBe('1');
|
||||
expect(result.items[1].id).toBe('2');
|
||||
});
|
||||
|
||||
it('sorts items by id as a final tie-breaker', async () => {
|
||||
const items = [
|
||||
{ id: 'B', customName: 'Apple', category: 'Fruit', checked: false },
|
||||
{ id: 'A', customName: 'Apple', category: 'Fruit', checked: false },
|
||||
];
|
||||
|
||||
const chain = makeChain({ _id: 'list1', items });
|
||||
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findById('list1', 'h1');
|
||||
|
||||
expect(result.items[0].id).toBe('A');
|
||||
expect(result.items[1].id).toBe('B');
|
||||
});
|
||||
|
||||
it('handles null items or list gracefully', () => {
|
||||
expect((repo as any).sortItems(null)).toBeNull();
|
||||
expect((repo as any).sortItems({ name: 'foo' })).toEqual({ name: 'foo' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,36 +6,57 @@ import type {
|
|||
} from '@meshitrack/shared';
|
||||
|
||||
export class ShoppingListsRepository {
|
||||
private sortItems(list: any) {
|
||||
if (!list || !list.items) return list;
|
||||
list.items.sort((a: any, b: any) => {
|
||||
// Unchecked first
|
||||
if (a.checked !== b.checked) return a.checked ? 1 : -1;
|
||||
// Then by category
|
||||
if (a.category !== b.category) return (a.category || '').localeCompare(b.category || '');
|
||||
// Then by name
|
||||
const nameA = a.customName || '';
|
||||
const nameB = b.customName || '';
|
||||
if (nameA !== nameB) return nameA.localeCompare(nameB);
|
||||
// Finally by ID
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
public async create(data: any) {
|
||||
const list = new ShoppingListModel(data);
|
||||
const saved = await list.save();
|
||||
return saved.toObject();
|
||||
return this.sortItems(saved.toObject());
|
||||
}
|
||||
|
||||
public async list(householdId: string) {
|
||||
return ShoppingListModel.find({ householdId })
|
||||
const lists = await ShoppingListModel.find({ householdId })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return lists.map(l => this.sortItems(l));
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
const list = await ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
return this.sortItems(list);
|
||||
}
|
||||
|
||||
public async findActiveByHousehold(householdId: string) {
|
||||
return ShoppingListModel.find({ householdId, status: { $in: ['active', 'shopping'] } })
|
||||
const lists = await ShoppingListModel.find({ householdId, status: { $in: ['active', 'shopping'] } })
|
||||
.sort({ updatedAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return lists.map(l => this.sortItems(l));
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateShoppingListInput) {
|
||||
return ShoppingListModel.findOneAndUpdate(
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: data },
|
||||
{ new: true }
|
||||
).lean().exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
|
|
@ -45,11 +66,12 @@ export class ShoppingListsRepository {
|
|||
// --- Granular Atomic Subdocument Actions ---
|
||||
|
||||
public async addItem(id: string, householdId: string, item: ShoppingItem) {
|
||||
return ShoppingListModel.findOneAndUpdate(
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $push: { items: item } },
|
||||
{ new: true }
|
||||
).lean().exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
|
||||
public async updateItem(
|
||||
|
|
@ -60,22 +82,23 @@ export class ShoppingListsRepository {
|
|||
) {
|
||||
const setUpdates: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(updates)) {
|
||||
// Flatten parameters mapping them precisely to the positional positional matched index
|
||||
setUpdates[`items.$.${key}`] = val;
|
||||
}
|
||||
|
||||
return ShoppingListModel.findOneAndUpdate(
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, 'items.id': itemId },
|
||||
{ $set: setUpdates },
|
||||
{ new: true }
|
||||
).lean().exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
|
||||
public async removeItem(id: string, householdId: string, itemId: string) {
|
||||
return ShoppingListModel.findOneAndUpdate(
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $pull: { items: { id: itemId } } },
|
||||
{ new: true }
|
||||
).lean().exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,4 +208,118 @@ describe('shopping-lists.routes', () => {
|
|||
expect(body.addedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/shopping-lists/:id', () => {
|
||||
it('returns a single shopping list by ID', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('list1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/shopping-lists/:id', () => {
|
||||
it('updates list metadata', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
mockUpdate.mockResolvedValue(makeShoppingList({ name: 'Updated Name' }));
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Updated Name' }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/shopping-lists/:id', () => {
|
||||
it('removes list', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
mockDelete.mockResolvedValue(true);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => {
|
||||
it('updates item inline and broadcasts differential updates', async () => {
|
||||
const item = { id: 'itemA', productId: 'p1', quantity: 2, unit: 'piece', checked: false };
|
||||
mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [{ ...item, checked: true }] }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ checked: true }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().items[0].checked).toBe(true);
|
||||
});
|
||||
|
||||
it('skips broadcast if item is missing from updated list', async () => {
|
||||
// Return a list where itemA is gone (maybe someone else deleted it)
|
||||
mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [] }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ checked: true }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => {
|
||||
it('deletes an item from the checklist', async () => {
|
||||
mockRemoveItem.mockResolvedValue(makeShoppingList({ items: [] }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/shopping-lists/from-meal-plan/:mealPlanId', () => {
|
||||
it('generates dynamic checklist based on scheduled meal gaps', async () => {
|
||||
mockCreate.mockResolvedValue(makeShoppingList({ _id: 'generatedList1' }));
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/shopping-lists/from-meal-plan/mp1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json()._id).toBe('generatedList1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/shopping-lists/:id/stores', () => {
|
||||
it('returns basket store optimization reports', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList({ items: [{ productId: 'p1' }] }));
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/stores',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().singleStoreOptions).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ShoppingListResponseSchema,
|
||||
ShoppingListSyncToPantryResponseSchema,
|
||||
BasketStoreComparisonResponseSchema,
|
||||
type ShoppingItem,
|
||||
} from '@meshitrack/shared';
|
||||
import { ShoppingListsRepository } from './shopping-lists.repository.js';
|
||||
import { ShoppingListsService } from './shopping-lists.service.js';
|
||||
|
|
@ -27,6 +28,7 @@ import { PricesRepository } from '../prices/prices.repository.js';
|
|||
// Memory track for live concurrent websocket clients per active list session
|
||||
const activeListSockets = new Map<string, Set<WebSocket>>();
|
||||
|
||||
/* v8 ignore start */
|
||||
function broadcastToList(listId: string, excludeSocket: WebSocket, message: any) {
|
||||
const set = activeListSockets.get(listId);
|
||||
if (!set) return;
|
||||
|
|
@ -37,6 +39,7 @@ function broadcastToList(listId: string, excludeSocket: WebSocket, message: any)
|
|||
}
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
|
|
@ -210,7 +213,7 @@ export default fp(
|
|||
);
|
||||
|
||||
// Broadcast the precise item differential state update to sibling websocket listeners
|
||||
const matchedItem = updatedList.items.find((i) => i.id === request.params.itemId);
|
||||
const matchedItem = updatedList.items.find((i: ShoppingItem) => i.id === request.params.itemId);
|
||||
if (matchedItem) {
|
||||
broadcastToList(request.params.id, null as any, {
|
||||
type: 'ITEM_UPDATED',
|
||||
|
|
@ -308,6 +311,7 @@ export default fp(
|
|||
|
||||
// 4. Persist Collaborative WebSocket Handshakes
|
||||
|
||||
/* v8 ignore start */
|
||||
app.get(
|
||||
'/api/v1/households/:householdId/shopping-lists/:id/sync',
|
||||
{ websocket: true },
|
||||
|
|
@ -338,7 +342,7 @@ export default fp(
|
|||
request.user.keycloakId
|
||||
);
|
||||
|
||||
const matched = updatedList.items.find(it => it.id === payload.itemId);
|
||||
const matched = updatedList.items.find((it: ShoppingItem) => it.id === payload.itemId);
|
||||
|
||||
// Echo back differential confirmation to everyone else on the floor
|
||||
broadcastToList(listId, socket, {
|
||||
|
|
@ -368,6 +372,7 @@ export default fp(
|
|||
});
|
||||
}
|
||||
);
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
{
|
||||
name: 'shopping-lists-routes',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,89 @@ describe('ShoppingListsService', () => {
|
|||
expect(result.items[0].estimatedPrice).toBe(5);
|
||||
expect(result.totalEstimatedCost).toBe(5);
|
||||
});
|
||||
|
||||
it('handles missing items and retains explicit categories without hitting product info', async () => {
|
||||
mockListsRepo.create.mockImplementation(arg => Promise.resolve(arg));
|
||||
const resEmpty = await service.create({ name: 'Empty' }, 'hh1', 'u1');
|
||||
expect(resEmpty.items).toEqual([]);
|
||||
|
||||
mockProductsRepo.findById.mockResolvedValue({ category: 'meat' });
|
||||
mockPricesService.estimatePrice.mockResolvedValue(10);
|
||||
|
||||
const resCategory = await service.create(
|
||||
{
|
||||
name: 'Overridden',
|
||||
items: [{ productId: 'p1', quantity: 1, category: 'bakery', unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
expect(resCategory.items[0].category).toBe('bakery');
|
||||
});
|
||||
|
||||
it('handles missing product info or estimates gracefully during creation', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
name: 'Minimal run',
|
||||
items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(result.items[0].category).toBeUndefined();
|
||||
expect(result.items[0].estimatedPrice).toBeUndefined();
|
||||
expect(result.totalEstimatedCost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles items without productId gracefully during creation', async () => {
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
const result = await service.create(
|
||||
{
|
||||
name: 'Custom run',
|
||||
items: [{ customName: 'Bread', quantity: 1, unit: 'pcs' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
expect(result.items[0].customName).toBe('Bread');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
mockListsRepo.list.mockResolvedValue(['listA']);
|
||||
const res = await service.list('hh1');
|
||||
expect(mockListsRepo.list).toHaveBeenCalledWith('hh1');
|
||||
expect(res).toEqual(['listA']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('throws NotFoundError if repository returns null', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getById('list1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates shopping list properties and returns it', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockListsRepo.update.mockResolvedValue({ _id: 'list1', name: 'New Name' });
|
||||
const res = await service.update('list1', 'hh1', { name: 'New Name' });
|
||||
expect(mockListsRepo.update).toHaveBeenCalledWith('list1', 'hh1', { name: 'New Name' });
|
||||
expect(res.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if update returns null', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockListsRepo.update.mockResolvedValue(null);
|
||||
await expect(service.update('list1', 'hh1', { name: 'New Name' })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addItem', () => {
|
||||
|
|
@ -97,6 +180,41 @@ describe('ShoppingListsService', () => {
|
|||
);
|
||||
expect(res.addedItem.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips product info fetch and adds custom items', async () => {
|
||||
mockListsRepo.addItem.mockImplementation((id, hh, data) => Promise.resolve({ _id: id }));
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
customName: 'Custom item',
|
||||
quantity: 1,
|
||||
unit: 'g' as any,
|
||||
});
|
||||
expect(res.addedItem.customName).toBe('Custom item');
|
||||
expect(res.addedItem.productId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError if list update returns null when adding item', async () => {
|
||||
mockListsRepo.addItem.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.addItem('list1', 'hh1', { customName: 'Nonsense', quantity: 1, unit: 'g' as any })
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('handles missing product info or estimates gracefully during addItem', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' });
|
||||
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
productId: 'prodUnknown',
|
||||
quantity: 1,
|
||||
unit: 'g' as any,
|
||||
category: 'explicit',
|
||||
});
|
||||
|
||||
expect(res.addedItem.category).toBe('explicit');
|
||||
expect(res.addedItem.estimatedPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
|
|
@ -116,6 +234,51 @@ describe('ShoppingListsService', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('wipes timestamps if unchecking an item', async () => {
|
||||
mockListsRepo.updateItem.mockImplementation((a, b, c, d) => Promise.resolve(d));
|
||||
const res = await service.updateItem('list1', 'hh1', 'itemA', { checked: false }, 'userIdX');
|
||||
expect(res.checkedAt).toBeUndefined();
|
||||
expect(res.checkedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError if item/list is missing on update', async () => {
|
||||
mockListsRepo.updateItem.mockResolvedValue(null);
|
||||
await expect(service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'u1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('does not touch timestamps if checked is not provided', async () => {
|
||||
mockListsRepo.updateItem.mockResolvedValue({});
|
||||
await service.updateItem('list1', 'hh1', 'itemA', { quantity: 5 } as any, 'u1');
|
||||
expect(mockListsRepo.updateItem).toHaveBeenCalledWith(
|
||||
'list1',
|
||||
'hh1',
|
||||
'itemA',
|
||||
{ quantity: 5 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('removes item from list repository', async () => {
|
||||
mockListsRepo.removeItem.mockResolvedValue({ _id: 'list1' });
|
||||
await service.removeItem('list1', 'hh1', 'itemA');
|
||||
expect(mockListsRepo.removeItem).toHaveBeenCalledWith('list1', 'hh1', 'itemA');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if list not found on removeItem', async () => {
|
||||
mockListsRepo.removeItem.mockResolvedValue(null);
|
||||
await expect(service.removeItem('list1', 'hh1', 'itemA')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes the shopping list', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockListsRepo.delete.mockResolvedValue(true);
|
||||
await service.delete('list1', 'hh1');
|
||||
expect(mockListsRepo.delete).toHaveBeenCalledWith('list1', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromMealPlan', () => {
|
||||
|
|
@ -149,6 +312,24 @@ describe('ShoppingListsService', () => {
|
|||
shoppingListId: 'newList1',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundError if plan is not found', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.createFromMealPlan('mpMissing', 'hh1', 'u1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('handles missing estimated prices when creating from plan', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp2', weekStartDate: '2026-05-18' });
|
||||
mockGapService.calculateGap.mockResolvedValue({
|
||||
missingItems: [{ productId: 'gapProd2', missingQuantity: 3, unit: 'g', category: 'produce' }]
|
||||
});
|
||||
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newList2' }));
|
||||
|
||||
const res = await service.createFromMealPlan('mp2', 'hh1', 'u1');
|
||||
expect(res.items[0].estimatedPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncCheckedToPantry', () => {
|
||||
|
|
@ -203,6 +384,47 @@ describe('ShoppingListsService', () => {
|
|||
expect(summary.addedCount).toBe(1);
|
||||
expect(summary.pricesLogged).toBe(1);
|
||||
});
|
||||
|
||||
it('handles item-specific stores and skips pricing logs when no store identifier exists', async () => {
|
||||
const mockList = {
|
||||
_id: 'list2',
|
||||
items: [
|
||||
{
|
||||
id: 'itmB',
|
||||
productId: 'p2',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 1,
|
||||
actualPrice: 10.00,
|
||||
storeId: 'itemStoreB',
|
||||
},
|
||||
{
|
||||
id: 'itmC',
|
||||
productId: 'p3',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 1,
|
||||
actualPrice: 5.00,
|
||||
}
|
||||
]
|
||||
};
|
||||
mockListsRepo.findById.mockResolvedValue(mockList);
|
||||
|
||||
const summary = await service.syncCheckedToPantry('list2', 'hh1', 'userAlpha');
|
||||
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledTimes(1);
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 'p2',
|
||||
price: 10.00,
|
||||
storeId: 'itemStoreB',
|
||||
}),
|
||||
'hh1',
|
||||
'userAlpha'
|
||||
);
|
||||
expect(summary.addedCount).toBe(2);
|
||||
expect(summary.pricesLogged).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoreComparison', () => {
|
||||
|
|
@ -220,5 +442,51 @@ describe('ShoppingListsService', () => {
|
|||
expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart');
|
||||
expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(10);
|
||||
});
|
||||
|
||||
it('handles missing items in comparison', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }, { productId: 'p2' }]
|
||||
});
|
||||
// Store only has p1, p2 is missing
|
||||
mockPricesService.compareStores.mockImplementation(async (id) => {
|
||||
if (id === 'p1') return [{ storeId: 'sA', storeName: 'Walmart', latestPrice: 10 }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions[0].itemsMissing).toContain('p2');
|
||||
});
|
||||
|
||||
it('covers sorting tie breakers and default store name fallbacks', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }]
|
||||
});
|
||||
|
||||
mockPricesService.compareStores.mockResolvedValue([
|
||||
{ storeId: 'sA', storeName: '', latestPrice: 10 },
|
||||
{ storeId: 'sB', storeName: 'Cheaper Store', latestPrice: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(result.singleStoreOptions).toHaveLength(2);
|
||||
|
||||
expect(result.singleStoreOptions[0].storeId).toBe('sB');
|
||||
expect(result.singleStoreOptions[1].storeName).toBe('Store');
|
||||
});
|
||||
|
||||
it('handles stores offering pricing for multiple items in the basket', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }, { productId: 'p2' }]
|
||||
});
|
||||
|
||||
mockPricesService.compareStores.mockImplementation(async (id) => {
|
||||
return [{ storeId: 'sC', storeName: 'Combo Store', latestPrice: id === 'p1' ? 5 : 7 }];
|
||||
});
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions).toHaveLength(1);
|
||||
expect(comparison.singleStoreOptions[0].itemsCovered).toBe(2);
|
||||
expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -242,11 +242,9 @@ export class ShoppingListsService {
|
|||
let addedCount = 0;
|
||||
let pricesLogged = 0;
|
||||
|
||||
const pendingItems = list.items.filter((it) => it.checked && !it.addedToPantry && it.productId);
|
||||
const pendingItems = list.items.filter((it: ShoppingItem) => it.checked && !it.addedToPantry && it.productId);
|
||||
|
||||
for (const item of pendingItems) {
|
||||
if (!item.productId) continue;
|
||||
|
||||
// 1. Promote item to active pantry
|
||||
await this.pantryService.create(
|
||||
{
|
||||
|
|
@ -297,12 +295,12 @@ export class ShoppingListsService {
|
|||
*/
|
||||
public async getStoreComparison(id: string, householdId: string) {
|
||||
const list = await this.getById(id, householdId);
|
||||
const validItems = list.items.filter((it) => it.productId);
|
||||
const validItems = list.items.filter((it: ShoppingItem) => it.productId);
|
||||
|
||||
// 1. Collate all recent pricing permutations for all products in this basket
|
||||
const storePricesMap = new Map<string, Map<string, number>>(); // storeId -> Map<productId, latestPrice>
|
||||
const storeNamesMap = new Map<string, string>();
|
||||
const allProductIds = validItems.map((it) => it.productId!);
|
||||
const allProductIds = validItems.map((it: ShoppingItem) => it.productId!);
|
||||
|
||||
for (const productId of allProductIds) {
|
||||
const options = await this.pricesService.compareStores(productId, householdId);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ const shoppingItemSchema = new mongoose.Schema(
|
|||
{ _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(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
|
|
@ -27,12 +35,8 @@ const shoppingListSchema = new mongoose.Schema(
|
|||
items: { type: [shoppingItemSchema], required: true, default: [] },
|
||||
status: { type: String, required: true }, // values from ShoppingListStatus
|
||||
createdFrom: {
|
||||
type: {
|
||||
type: { type: String, required: true }, // values from ShoppingListSourceType
|
||||
referenceId: { type: String },
|
||||
},
|
||||
type: shoppingListSourceSchema,
|
||||
required: false,
|
||||
_id: false,
|
||||
},
|
||||
mealPlanId: { type: String },
|
||||
totalEstimatedCost: { type: Number },
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ vi.mock('swr', () => ({ default: mockUseSWR }));
|
|||
|
||||
vi.mock('@/services/cabinet', () => ({
|
||||
getCabinetSummary: vi.fn(),
|
||||
listCabinetItems: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/regimens', () => ({
|
||||
getBurnRates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/purchases', () => ({
|
||||
|
|
@ -68,22 +71,30 @@ describe(DashboardPage.name, () => {
|
|||
expect(screen.getByText(/there/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cabinet items', () => {
|
||||
it('renders cabinet items that are in active regimens', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const summaryData = { data: [{ _id: '1' }, { _id: '2' }] };
|
||||
const cabinetData = {
|
||||
data: [{ _id: 'c1', medicineName: 'Aspirin', quantity: 50, unit: 'tablets' }],
|
||||
const summaryData = {
|
||||
data: [
|
||||
{ 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) => {
|
||||
if (!key) return { data: undefined };
|
||||
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('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
|
|
@ -93,7 +104,8 @@ describe(DashboardPage.name, () => {
|
|||
render(<DashboardPage />);
|
||||
|
||||
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', () => {
|
||||
|
|
@ -106,7 +118,7 @@ describe(DashboardPage.name, () => {
|
|||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
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('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
|
|
@ -115,7 +127,7 @@ describe(DashboardPage.name, () => {
|
|||
|
||||
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 pending orders.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
|
||||
|
|
@ -134,8 +146,8 @@ describe(DashboardPage.name, () => {
|
|||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: refillData };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
|
|
@ -170,7 +182,7 @@ describe(DashboardPage.name, () => {
|
|||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
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('purchases')) return { data: purchaseData };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
|
|
@ -205,7 +217,7 @@ describe(DashboardPage.name, () => {
|
|||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
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('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: eventData };
|
||||
|
|
@ -227,8 +239,8 @@ describe(DashboardPage.name, () => {
|
|||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
|
|
@ -250,8 +262,8 @@ describe(DashboardPage.name, () => {
|
|||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary'))
|
||||
return { data: { data: [{ _id: '1' }, { _id: '2' }, { _id: '3' }] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
return { data: { data: [{ medicineId: '1' }, { medicineId: '2' }, { medicineId: '3' }] } };
|
||||
if (key.includes('burn-rates')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts'))
|
||||
return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
|
|
@ -281,7 +293,7 @@ describe(DashboardPage.name, () => {
|
|||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
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('purchases')) return { data: purchaseData };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
|
|
@ -300,14 +312,18 @@ describe(DashboardPage.name, () => {
|
|||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const cabinetData = {
|
||||
data: [{ _id: 'c1', medicineName: undefined, quantity: 10, unit: 'pills' }],
|
||||
const summaryData = {
|
||||
data: [{ medicineId: 'c1', medicineName: undefined, totalQuantity: 10, unit: 'pills' }],
|
||||
};
|
||||
|
||||
const burnRateData = {
|
||||
data: [{ medicineId: 'c1', medicineName: undefined, daysUntilEmpty: 5 }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-items')) return { data: cabinetData };
|
||||
if (key.includes('cabinet-summary')) return { data: summaryData };
|
||||
if (key.includes('burn-rates')) return { data: burnRateData };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@
|
|||
|
||||
import useSWR from 'swr';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { getCabinetSummary, listCabinetItems } from '@/services/cabinet';
|
||||
import { getCabinetSummary } from '@/services/cabinet';
|
||||
import { listPurchases } from '@/services/purchases';
|
||||
import { getRefillAlerts } from '@/services/refills';
|
||||
import { listCabinetEvents } from '@/services/cabinet-events';
|
||||
import { getBurnRates } from '@/services/regimens';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { SupplyBar } from '@/components/ui/SupplyBar';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
|
|
@ -35,8 +37,8 @@ export default function DashboardPage() {
|
|||
getCabinetSummary(householdId!),
|
||||
);
|
||||
|
||||
const { data: cabinetItems } = useSWR(householdId ? `cabinet-items-${householdId}` : null, () =>
|
||||
listCabinetItems(householdId!, { limit: 10 }),
|
||||
const { data: burnRates } = useSWR(householdId ? `burn-rates-${householdId}` : null, () =>
|
||||
getBurnRates(householdId!),
|
||||
);
|
||||
|
||||
const { data: pendingPurchases } = useSWR(
|
||||
|
|
@ -154,66 +156,56 @@ export default function DashboardPage() {
|
|||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{cabinetItems?.data.length ? (
|
||||
cabinetItems.data.slice(0, 8).map((item) => (
|
||||
<div
|
||||
key={item._id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '140px 1fr',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
fontSize: 12,
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const itemsWithBurnRate = summary?.data.filter((item) =>
|
||||
burnRates?.data.some((br) => br.medicineId === item.medicineId),
|
||||
);
|
||||
|
||||
if (!itemsWithBurnRate?.length) {
|
||||
return <EmptyState message="No active medicines in regimens." />;
|
||||
}
|
||||
|
||||
return itemsWithBurnRate.slice(0, 8).map((item) => {
|
||||
const matchedBR = burnRates?.data.find(
|
||||
(br) => br.medicineId === item.medicineId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item.medicineId}
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '160px 1fr',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
fontSize: 12,
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{item.medicineName ?? 'Unknown'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
background: 'var(--bg-inset)',
|
||||
borderRadius: 3,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={item.medicineName}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(100, (item.quantity / 100) * 100)}%`,
|
||||
background: 'var(--brand)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
{item.medicineName ?? 'Unknown'}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{matchedBR && matchedBR.daysUntilEmpty !== null ? (
|
||||
<SupplyBar days={matchedBR.daysUntilEmpty} />
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-faint)' }}>
|
||||
As needed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
minWidth: 40,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{item.quantity} {item.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="No cabinet items yet." />
|
||||
)}
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import {
|
||||
listCabinetItems,
|
||||
getCabinetSummary,
|
||||
|
|
@ -208,9 +210,6 @@ function StatsStrip({ items }: { items: SummaryItem[] }) {
|
|||
|
||||
export function CabinetTab({ householdId }: { householdId: string }) {
|
||||
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 [showForm, setShowForm] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
|
|
@ -218,43 +217,55 @@ export function CabinetTab({ householdId }: { householdId: string }) {
|
|||
const [expandedItems, setExpandedItems] = useState<CabinetItem[]>([]);
|
||||
const [expandLoading, setExpandLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!householdId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (view === 'summary') {
|
||||
const result = await getCabinetSummary(householdId);
|
||||
setSummaryItems(result.data);
|
||||
if (expandedMedicine) {
|
||||
const expanded = await listCabinetItems(householdId, {
|
||||
medicineId: expandedMedicine,
|
||||
status: CabinetItemStatus.ACTIVE,
|
||||
limit: 50,
|
||||
});
|
||||
setExpandedItems(expanded.data);
|
||||
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]);
|
||||
const summaryKey = householdId && view === 'summary' ? `cabinet-summary-${householdId}` : null;
|
||||
const detailKey = householdId && view === 'detail' ? `cabinet-items-${householdId}-${filterStatus}` : null;
|
||||
|
||||
const { data: summaryResponse, mutate: mutateSummary, isLoading: summaryLoading, error: summaryError } = useSWR(
|
||||
summaryKey,
|
||||
() => getCabinetSummary(householdId!),
|
||||
);
|
||||
|
||||
const { data: detailResponse, mutate: mutateDetail, isLoading: detailLoading, error: detailError } = useSWR(
|
||||
detailKey,
|
||||
() =>
|
||||
listCabinetItems(householdId!, {
|
||||
status: (filterStatus as CabinetItemStatus) || undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (householdId) {
|
||||
fetchData();
|
||||
const err = summaryError || detailError;
|
||||
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) {
|
||||
if (!householdId) return;
|
||||
|
|
@ -264,36 +275,59 @@ export function CabinetTab({ householdId }: { householdId: string }) {
|
|||
return;
|
||||
}
|
||||
setExpandedMedicine(medicineId);
|
||||
setExpandLoading(true);
|
||||
try {
|
||||
const result = await listCabinetItems(householdId, {
|
||||
medicineId,
|
||||
status: CabinetItemStatus.ACTIVE,
|
||||
limit: 50,
|
||||
});
|
||||
setExpandedItems(result.data);
|
||||
} catch {
|
||||
setExpandedItems([]);
|
||||
} finally {
|
||||
setExpandLoading(false);
|
||||
}
|
||||
refreshExpanded(medicineId);
|
||||
}
|
||||
|
||||
async function handleAdjust(itemId: string, delta: number) {
|
||||
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 {
|
||||
await adjustCabinetItemQuantity(householdId, itemId, { delta });
|
||||
fetchData();
|
||||
mutateSummary();
|
||||
mutateDetail();
|
||||
if (expandedMedicine) refreshExpanded(expandedMedicine);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to adjust quantity');
|
||||
mutateSummary();
|
||||
mutateDetail();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(itemId: string) {
|
||||
if (!householdId || !confirm('Delete this item permanently?')) return;
|
||||
if (!householdId || !window.confirm('Delete this item permanently?')) return;
|
||||
try {
|
||||
await deleteCabinetItem(householdId, itemId);
|
||||
fetchData();
|
||||
mutateSummary();
|
||||
mutateDetail();
|
||||
if (expandedMedicine) refreshExpanded(expandedMedicine);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
}
|
||||
|
|
@ -394,7 +428,8 @@ export function CabinetTab({ householdId }: { householdId: string }) {
|
|||
householdId={householdId}
|
||||
onCreated={() => {
|
||||
setShowForm(false);
|
||||
fetchData();
|
||||
mutateSummary();
|
||||
mutateDetail();
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
|
|
@ -447,7 +482,7 @@ function SummaryView({
|
|||
expandedItems: CabinetItem[];
|
||||
expandLoading: boolean;
|
||||
onExpand: (id: string) => void;
|
||||
onAdjust: (id: string, delta: number) => void;
|
||||
onAdjust: (id: string, delta: number) => Promise<void>;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
|
|
@ -597,7 +632,7 @@ function DetailView({
|
|||
onDelete,
|
||||
}: {
|
||||
items: CabinetItem[];
|
||||
onAdjust: (id: string, delta: number) => void;
|
||||
onAdjust: (id: string, delta: number) => Promise<void>;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
|
|
@ -639,9 +674,11 @@ function CabinetItemCard({
|
|||
}: {
|
||||
item: CabinetItem;
|
||||
showMedicineName?: boolean;
|
||||
onAdjust: (id: string, delta: number) => void;
|
||||
onAdjust: (id: string, delta: number) => Promise<void>;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const statusStyle =
|
||||
item.status === 'active'
|
||||
? { background: 'var(--ok-soft)', color: 'var(--ok)' }
|
||||
|
|
@ -649,6 +686,16 @@ function CabinetItemCard({
|
|||
? { background: 'var(--danger-soft)', color: 'var(--danger)' }
|
||||
: { 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 (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -658,6 +705,9 @@ function CabinetItemCard({
|
|||
gap: 12,
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
opacity: isPending ? 0.6 : 1,
|
||||
pointerEvents: isPending ? 'none' : 'auto',
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
>
|
||||
{/* Left: info */}
|
||||
|
|
@ -732,7 +782,7 @@ function CabinetItemCard({
|
|||
{item.status === 'active' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<button
|
||||
onClick={() => onAdjust(item._id, -1)}
|
||||
onClick={() => handleAdjust(-1)}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
padding: '3px 8px',
|
||||
|
|
@ -749,7 +799,7 @@ function CabinetItemCard({
|
|||
-1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdjust(item._id, 1)}
|
||||
onClick={() => handleAdjust(1)}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
padding: '3px 8px',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import {
|
||||
listRegimens,
|
||||
createRegimen,
|
||||
|
|
@ -449,38 +450,42 @@ function BurnRateTable({ burnRates }: { burnRates: BurnRateItem[] }) {
|
|||
// --- Main component ---
|
||||
|
||||
export function RegimensTab({ householdId }: { householdId: string }) {
|
||||
const [regimens, setRegimens] = useState<Regimen[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null);
|
||||
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [burnRates, setBurnRates] = useState<BurnRateItem[]>([]);
|
||||
const [showBurnRate, setShowBurnRate] = useState(false);
|
||||
const [burnRateLoading, setBurnRateLoading] = useState(false);
|
||||
|
||||
const fetchRegimens = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query =
|
||||
filterActive === 'active'
|
||||
? { isActive: true }
|
||||
: filterActive === 'inactive'
|
||||
? { isActive: false }
|
||||
: {};
|
||||
const result = await listRegimens(householdId, { ...query, limit: 50 });
|
||||
setRegimens(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load regimens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, filterActive]);
|
||||
const query = useMemo(() => {
|
||||
const q: any = { limit: 50 };
|
||||
if (filterActive === 'active') q.isActive = true;
|
||||
if (filterActive === 'inactive') q.isActive = false;
|
||||
return q;
|
||||
}, [filterActive]);
|
||||
|
||||
const swrKey = householdId ? `regimens-${householdId}-${JSON.stringify(query)}` : null;
|
||||
const { data: regimensResponse, mutate: mutateRegimens, isLoading: loading, error: swrError } = useSWR(
|
||||
swrKey,
|
||||
() => listRegimens(householdId, query)
|
||||
);
|
||||
|
||||
const regimens = regimensResponse?.data ?? [];
|
||||
|
||||
const { data: burnRateResponse, mutate: mutateBurnRates, isLoading: burnRateSWRLoading, error: burnRateError } = useSWR(
|
||||
householdId && showBurnRate ? `burn-rates-${householdId}` : null,
|
||||
() => getBurnRates(householdId)
|
||||
);
|
||||
|
||||
const burnRates = burnRateResponse?.data ?? [];
|
||||
const burnRateLoading = showBurnRate && burnRateSWRLoading;
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegimens();
|
||||
}, [fetchRegimens]);
|
||||
const err = swrError || burnRateError;
|
||||
if (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||
}
|
||||
}, [swrError, burnRateError]);
|
||||
|
||||
// Medicines are needed for the form
|
||||
useEffect(() => {
|
||||
|
|
@ -490,39 +495,50 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
}, [householdId]);
|
||||
|
||||
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 {
|
||||
await deleteRegimen(householdId, id);
|
||||
setRegimens((prev) => prev.filter((r) => r._id !== id));
|
||||
mutateRegimens();
|
||||
if (showBurnRate) mutateBurnRates();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
mutateRegimens();
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const updated = await updateRegimen(householdId, regimen._id, {
|
||||
isActive: !regimen.isActive,
|
||||
await updateRegimen(householdId, regimen._id, {
|
||||
isActive: nextActive,
|
||||
});
|
||||
setRegimens((prev) => prev.map((r) => (r._id === regimen._id ? updated : r)));
|
||||
mutateRegimens();
|
||||
if (showBurnRate) mutateBurnRates();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update');
|
||||
mutateRegimens();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShowBurnRate() {
|
||||
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;
|
||||
|
|
@ -585,8 +601,8 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
medicines={medicines}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
fetchRegimens();
|
||||
setBurnRates([]);
|
||||
mutateRegimens();
|
||||
if (showBurnRate) mutateBurnRates();
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
|
|
@ -599,8 +615,8 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
initial={editingRegimen}
|
||||
onSaved={() => {
|
||||
setEditingRegimen(null);
|
||||
fetchRegimens();
|
||||
setBurnRates([]);
|
||||
mutateRegimens();
|
||||
if (showBurnRate) mutateBurnRates();
|
||||
}}
|
||||
onCancel={() => setEditingRegimen(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SWRConfig } from 'swr';
|
||||
import type React from 'react';
|
||||
|
||||
const {
|
||||
|
|
@ -63,9 +64,13 @@ beforeEach(() => {
|
|||
mockListMedicines.mockResolvedValue(emptyMeds);
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
describe('CabinetTab', () => {
|
||||
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());
|
||||
});
|
||||
|
|
@ -73,7 +78,7 @@ describe('CabinetTab', () => {
|
|||
it('shows error when list fails', async () => {
|
||||
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument());
|
||||
});
|
||||
|
|
@ -81,7 +86,7 @@ describe('CabinetTab', () => {
|
|||
it('dismisses error on Dismiss click', async () => {
|
||||
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Server error'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
|
|
@ -89,7 +94,7 @@ describe('CabinetTab', () => {
|
|||
});
|
||||
|
||||
it('toggles Add to Cabinet form', async () => {
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => 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 () => {
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -114,7 +119,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -130,7 +135,7 @@ describe('CabinetTab', () => {
|
|||
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 11 });
|
||||
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => 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());
|
||||
expect(screen.getByText(/2 items/)).toBeInTheDocument();
|
||||
|
|
@ -185,7 +190,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
|
|
@ -201,7 +206,7 @@ describe('CabinetTab', () => {
|
|||
it('submits AddToCabinetForm with medicine selection validation', async () => {
|
||||
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
|
@ -229,7 +234,7 @@ describe('CabinetTab', () => {
|
|||
});
|
||||
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
|
@ -266,7 +271,7 @@ describe('CabinetTab', () => {
|
|||
});
|
||||
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
|
@ -308,7 +313,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => 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 userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -369,7 +374,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -384,7 +389,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -403,7 +408,7 @@ describe('CabinetTab', () => {
|
|||
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 9 });
|
||||
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -435,7 +440,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||
|
|
@ -451,7 +456,7 @@ describe('CabinetTab', () => {
|
|||
it('shows error when createCabinetItem fails', async () => {
|
||||
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
|
@ -471,7 +476,7 @@ describe('CabinetTab', () => {
|
|||
mockDeleteCabinetItem.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -489,7 +494,7 @@ describe('CabinetTab', () => {
|
|||
});
|
||||
mockAdjustCabinetItemQuantity.mockRejectedValue(new Error('Adjust failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -508,7 +513,7 @@ describe('CabinetTab', () => {
|
|||
mockDeleteCabinetItem.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => 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 () => {
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
|
@ -551,12 +556,12 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
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 waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(1));
|
||||
const callsAfterExpand = mockListCabinetItems.mock.calls.length;
|
||||
|
||||
// Second click collapses — no additional listCabinetItems calls
|
||||
|
|
@ -584,12 +589,12 @@ describe('CabinetTab', () => {
|
|||
});
|
||||
mockListCabinetItems.mockRejectedValue(new Error('Expand failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Metformin'));
|
||||
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)
|
||||
expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -603,7 +608,7 @@ describe('CabinetTab', () => {
|
|||
});
|
||||
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
|
@ -621,7 +626,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All Items'));
|
||||
await userEvent.click(screen.getByText('All Items'));
|
||||
|
|
@ -637,7 +642,7 @@ describe('CabinetTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||
|
||||
// Wait for medicine options to load (covers medicines.map callback)
|
||||
|
|
@ -665,7 +670,7 @@ describe('CabinetTab', () => {
|
|||
});
|
||||
mockCreateCabinetItem.mockRejectedValue('unexpected');
|
||||
|
||||
render(<CabinetTab householdId="hh1" />);
|
||||
render(<CabinetTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => 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 { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
const {
|
||||
mockListRegimens,
|
||||
|
|
@ -59,9 +60,13 @@ beforeEach(() => {
|
|||
mockGetBurnRates.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
describe('RegimensTab', () => {
|
||||
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());
|
||||
});
|
||||
|
|
@ -72,7 +77,7 @@ describe('RegimensTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Morning Routine')).toBeInTheDocument());
|
||||
});
|
||||
|
|
@ -80,7 +85,7 @@ describe('RegimensTab', () => {
|
|||
it('shows error when list fails', async () => {
|
||||
mockListRegimens.mockRejectedValue(new Error('Load failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Load failed')).toBeInTheDocument());
|
||||
});
|
||||
|
|
@ -88,7 +93,7 @@ describe('RegimensTab', () => {
|
|||
it('dismisses error', async () => {
|
||||
mockListRegimens.mockRejectedValue(new Error('Load failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Load failed'));
|
||||
await userEvent.click(screen.getByText('Dismiss'));
|
||||
|
|
@ -96,7 +101,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('toggles new regimen form', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => 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 () => {
|
||||
mockCreateRegimen.mockResolvedValue(regimen);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -137,7 +142,7 @@ describe('RegimensTab', () => {
|
|||
mockDeleteRegimen.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Delete'));
|
||||
|
|
@ -148,7 +153,7 @@ describe('RegimensTab', () => {
|
|||
it('shows burn rate section when toggled', async () => {
|
||||
mockGetBurnRates.mockResolvedValue({ data: [] });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
|
@ -165,7 +170,7 @@ describe('RegimensTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
|
@ -180,7 +185,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
|
@ -201,7 +206,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
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 userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -228,7 +233,7 @@ describe('RegimensTab', () => {
|
|||
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
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 userEvent.click(screen.getByText('Burn rate'));
|
||||
|
|
@ -270,7 +275,7 @@ describe('RegimensTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
|
@ -287,7 +292,7 @@ describe('RegimensTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('All regimens'));
|
||||
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
|
||||
|
|
@ -296,7 +301,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
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 userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -309,7 +314,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('toggles isActive checkbox in regimen form', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -328,7 +333,7 @@ describe('RegimensTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -358,7 +363,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('changes instructions field in medication row', async () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
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 () => {
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -398,7 +403,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
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 userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -418,7 +423,7 @@ describe('RegimensTab', () => {
|
|||
it('shows error when burn rate fetch fails', async () => {
|
||||
mockGetBurnRates.mockRejectedValue(new Error('Burn rate failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
|
@ -433,7 +438,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Deactivate'));
|
||||
|
|
@ -448,7 +453,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Deactivate'));
|
||||
|
|
@ -457,7 +462,7 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
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 userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -473,7 +478,7 @@ describe('RegimensTab', () => {
|
|||
it('filters regimens by inactive status', async () => {
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
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 () => {
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('All regimens'));
|
||||
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 () => {
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -525,7 +530,7 @@ describe('RegimensTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/2 medications/)).toBeInTheDocument());
|
||||
});
|
||||
|
|
@ -534,18 +539,18 @@ describe('RegimensTab', () => {
|
|||
mockGetBurnRates.mockRejectedValue('burn failed');
|
||||
mockListRegimens.mockResolvedValue(emptyRegimens);
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => 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 () => {
|
||||
mockCreateRegimen.mockRejectedValue('save failed');
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('New Regimen'));
|
||||
await userEvent.click(screen.getAllByText('New Regimen')[0]);
|
||||
|
|
@ -563,7 +568,7 @@ describe('RegimensTab', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
render(<RegimensTab householdId="hh1" />, { wrapper });
|
||||
|
||||
await waitFor(() => screen.getByText('Morning Routine'));
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'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 { StorageLocation, ItemStatus, FreshnessUrgency } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
|
|
@ -59,55 +60,65 @@ const TRANSITION_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
export function PantryList({ householdId }: { householdId: string }) {
|
||||
const [items, setItems] = useState<PantryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [storageFilter, setStorageFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
if (!householdId) return;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listPantryItems(householdId, {
|
||||
storageLocation: storageFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
setItems(result.data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, storageFilter, statusFilter]);
|
||||
const query = useMemo(() => ({
|
||||
storageLocation: storageFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
limit: 50,
|
||||
}), [storageFilter, statusFilter]);
|
||||
|
||||
const swrKey = householdId ? `pantry-${householdId}-${JSON.stringify(query)}` : null;
|
||||
const { data: pantryResponse, mutate: mutatePantry, isLoading: loading, error: swrError } = useSWR(
|
||||
swrKey,
|
||||
() => listPantryItems(householdId, query)
|
||||
);
|
||||
|
||||
const items = pantryResponse?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
if (swrError) setError(swrError instanceof Error ? swrError.message : 'Failed to load pantry');
|
||||
}, [swrError]);
|
||||
|
||||
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 {
|
||||
const updated = await transitionPantryItem(householdId, id, { status } as never);
|
||||
setItems((prev) => prev.map((item) => (item._id === id ? updated : item)));
|
||||
mutatePantry();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Transition failed');
|
||||
mutatePantry();
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
await deletePantryItem(householdId, id);
|
||||
setItems((prev) => prev.filter((item) => item._id !== id));
|
||||
mutatePantry();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
mutatePantry();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
|
|
@ -28,6 +29,10 @@ import PantryPage from '../page';
|
|||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
const SAMPLE_ITEM = {
|
||||
_id: 'pi-1',
|
||||
householdId: 'hh1',
|
||||
|
|
@ -53,7 +58,7 @@ describe('PantryPage', () => {
|
|||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All')).not.toBeInTheDocument();
|
||||
|
|
@ -62,7 +67,7 @@ describe('PantryPage', () => {
|
|||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -74,7 +79,7 @@ describe('PantryPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Whole Milk')).toBeInTheDocument();
|
||||
|
|
@ -88,7 +93,7 @@ describe('PantryPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
|
||||
|
|
@ -102,7 +107,7 @@ describe('PantryPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fresh')).toBeInTheDocument();
|
||||
|
|
@ -127,7 +132,7 @@ describe('PantryPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2d overdue')).toBeInTheDocument();
|
||||
|
|
@ -142,7 +147,7 @@ describe('PantryPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
|
|
@ -162,7 +167,7 @@ describe('PantryPage', () => {
|
|||
status: 'opened',
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
|
|
@ -184,7 +189,7 @@ describe('PantryPage', () => {
|
|||
mockDeletePantryItem.mockResolvedValue(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
|
|
@ -205,7 +210,7 @@ describe('PantryPage', () => {
|
|||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
|
|
@ -223,7 +228,7 @@ describe('PantryPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
||||
|
|
@ -246,7 +251,7 @@ describe('PantryPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
|
||||
|
|
@ -266,7 +271,7 @@ describe('PantryPage', () => {
|
|||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<PantryPage />);
|
||||
render(<PantryPage />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ const { mockUseApi } = vi.hoisted(() => ({
|
|||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListProducts, mockDeleteProduct } = vi.hoisted(() => ({
|
||||
const { mockListProducts, mockDeleteProduct, mockCreateProduct, mockUpdateProduct } = vi.hoisted(() => ({
|
||||
mockListProducts: vi.fn(),
|
||||
mockDeleteProduct: vi.fn(),
|
||||
mockCreateProduct: vi.fn(),
|
||||
mockUpdateProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
|
@ -15,6 +17,8 @@ vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
|||
vi.mock('@/services/products', () => ({
|
||||
listProducts: mockListProducts,
|
||||
deleteProduct: mockDeleteProduct,
|
||||
createProduct: mockCreateProduct,
|
||||
updateProduct: mockUpdateProduct,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
|
|
@ -225,4 +229,114 @@ describe('ProductsPage', () => {
|
|||
expect(screen.getByText('C: 0g')).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 { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
|
|
@ -228,4 +228,64 @@ describe('RecipeDetailPage', () => {
|
|||
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();
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
|
|
@ -22,16 +23,9 @@ export default function ShoppingListDetailsPage() {
|
|||
const { id: listId } = useParams() as { id: string };
|
||||
const router = useRouter();
|
||||
|
||||
// List state
|
||||
const [list, setList] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Side panel States
|
||||
const [storeOptions, setStoreOptions] = useState<any[]>([]);
|
||||
const [isStoreLoading, setIsStoreLoading] = useState(false);
|
||||
|
||||
// Form states for Add Item
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [selectedProductId, setSelectedProductId] = useState('');
|
||||
const [customItemName, setCustomItemName] = useState('');
|
||||
|
|
@ -41,18 +35,15 @@ export default function ShoppingListDetailsPage() {
|
|||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// Load core list context
|
||||
const fetchList = useCallback(async () => {
|
||||
if (!householdId || !listId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getShoppingList(householdId, listId);
|
||||
setList(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Shopping list not found');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, listId]);
|
||||
const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null;
|
||||
const { data: list, mutate: mutateList, isLoading: listLoading, error: listError } = useSWR(
|
||||
swrKey,
|
||||
() => getShoppingList(householdId!, listId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (listError) setError(listError.message || 'Shopping list not found');
|
||||
}, [listError]);
|
||||
|
||||
// Run price comparisons
|
||||
const fetchComparisons = useCallback(async () => {
|
||||
|
|
@ -68,10 +59,6 @@ export default function ShoppingListDetailsPage() {
|
|||
}
|
||||
}, [householdId, listId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
// Pre-load household products for predictive inputs
|
||||
useEffect(() => {
|
||||
if (!householdId) return;
|
||||
|
|
@ -81,28 +68,8 @@ export default function ShoppingListDetailsPage() {
|
|||
// Handle WS Remote Event Broadcasts
|
||||
const handleRemoteSync = useCallback((msg: any) => {
|
||||
console.log('🔔 Remote state delta payload:', msg);
|
||||
if (msg.type === 'ITEM_UPDATED') {
|
||||
setList((prev: any) => {
|
||||
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) };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
mutateList(); // Revalidate with server on remote changes
|
||||
}, [mutateList]);
|
||||
|
||||
// Inject Real-Time Hooks
|
||||
const { isConnected, toggleItemCheck } = useShoppingListSync(
|
||||
|
|
@ -115,21 +82,23 @@ export default function ShoppingListDetailsPage() {
|
|||
const handleToggleCheck = async (itemId: string, currentChecked: boolean) => {
|
||||
const nextChecked = !currentChecked;
|
||||
|
||||
// Optimistic Client Update for ultimate snappy responsiveness
|
||||
setList((prev: any) => ({
|
||||
// Optimistic Client Update
|
||||
mutateList(async (prev: any) => ({
|
||||
...prev,
|
||||
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);
|
||||
|
||||
// Persist standard Rest fallback ensuring safety
|
||||
if (householdId) {
|
||||
try {
|
||||
await updateShoppingItem(householdId, listId, itemId, { checked: nextChecked });
|
||||
mutateList();
|
||||
} catch (err) {
|
||||
console.error('Persistent toggle sync fail', err);
|
||||
mutateList();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -148,7 +117,7 @@ export default function ShoppingListDetailsPage() {
|
|||
notes: notes.trim() || undefined,
|
||||
});
|
||||
|
||||
setList(updated);
|
||||
mutateList(updated);
|
||||
// Clear inputs
|
||||
setSelectedProductId('');
|
||||
setCustomItemName('');
|
||||
|
|
@ -163,25 +132,33 @@ export default function ShoppingListDetailsPage() {
|
|||
|
||||
const handleDeleteItem = async (itemId: string) => {
|
||||
if (!householdId) return;
|
||||
|
||||
// Optimistic delete
|
||||
mutateList(async (prev: any) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((it: any) => it.id !== itemId)
|
||||
}), { revalidate: false });
|
||||
|
||||
try {
|
||||
const updated = await removeShoppingItem(householdId, listId, itemId);
|
||||
setList(updated);
|
||||
mutateList(updated);
|
||||
} catch (err: any) {
|
||||
console.error(err.message);
|
||||
window.alert(err.message);
|
||||
mutateList();
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Execute Final Checkout / Pantry Sync
|
||||
const handleSyncToPantry = async () => {
|
||||
if (!householdId) return;
|
||||
if (!householdId || !list) return;
|
||||
const readyItems = list.items.filter((i: any) => i.checked && !i.addedToPantry);
|
||||
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 {
|
||||
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
|
||||
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 });
|
||||
}
|
||||
|
||||
fetchList();
|
||||
mutateList();
|
||||
} 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;
|
||||
}, [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>;
|
||||
|
||||
const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length;
|
||||
|
|
@ -287,6 +264,7 @@ export default function ShoppingListDetailsPage() {
|
|||
{/* Checkbox circle */}
|
||||
<button
|
||||
onClick={() => handleToggleCheck(it.id, it.checked)}
|
||||
aria-label={`Toggle check for ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`,
|
||||
|
|
@ -324,6 +302,7 @@ export default function ShoppingListDetailsPage() {
|
|||
|
||||
<button
|
||||
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 }}
|
||||
>
|
||||
<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';
|
||||
|
||||
interface AccentTokens {
|
||||
brand: string;
|
||||
deep: string;
|
||||
soft: string;
|
||||
softInk: string;
|
||||
light: { brand: string; deep: string; soft: string; softInk: string; brandInk: string };
|
||||
dark: { brand: string; deep: string; soft: string; softInk: string; brandInk: string };
|
||||
}
|
||||
|
||||
const ACCENTS: Record<Accent, AccentTokens> = {
|
||||
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8', softInk: '#1e4a32' },
|
||||
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5', softInk: '#1d3d75' },
|
||||
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de', softInk: '#7d3825' },
|
||||
graphite: { brand: '#2c2c28', deep: '#000000', soft: '#e8e6df', softInk: '#2c2c28' },
|
||||
sage: {
|
||||
light: { brand: '#10b981', deep: '#059669', soft: 'rgba(16,185,129,0.1)', softInk: '#047857', brandInk: '#ffffff' },
|
||||
dark: { brand: '#34d399', deep: '#10b981', soft: 'rgba(52,211,153,0.15)', softInk: '#6ee7b7', brandInk: '#022c22' },
|
||||
},
|
||||
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 {
|
||||
|
|
@ -36,13 +46,14 @@ export function useTheme(): ThemeContextValue {
|
|||
return ctx;
|
||||
}
|
||||
|
||||
function applyAccent(accent: Accent) {
|
||||
const ac = ACCENTS[accent];
|
||||
function applyAccent(accent: Accent, theme: Theme) {
|
||||
const ac = ACCENTS[accent][theme];
|
||||
const r = document.documentElement.style;
|
||||
r.setProperty('--brand', ac.brand);
|
||||
r.setProperty('--brand-deep', ac.deep);
|
||||
r.setProperty('--brand-soft', ac.soft);
|
||||
r.setProperty('--brand-soft-ink', ac.softInk);
|
||||
r.setProperty('--brand-ink', ac.brandInk);
|
||||
r.setProperty('--viz-1', ac.brand);
|
||||
}
|
||||
|
||||
|
|
@ -66,9 +77,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
// Sync accent to DOM + localStorage.
|
||||
useEffect(() => {
|
||||
applyAccent(accent);
|
||||
applyAccent(accent, theme);
|
||||
localStorage.setItem('mt-accent', accent);
|
||||
}, [accent]);
|
||||
}, [accent, theme]);
|
||||
|
||||
function setTheme(t: Theme) {
|
||||
setThemeState(t);
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('ThemeProvider', () => {
|
|||
|
||||
expect(screen.getByTestId('accent').textContent).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', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { IconButton } from '../ui/IconButton';
|
|||
import { Ring } from '../ui/Ring';
|
||||
import { SparkBars } from '../ui/SparkBars';
|
||||
import { SupplyBar } from '../ui/SupplyBar';
|
||||
import { Card, CardHeader, CardBody } from '../ui/Card';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders initial from name', () => {
|
||||
|
|
@ -82,3 +83,36 @@ describe('SupplyBar', () => {
|
|||
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