Phase 9
This commit is contained in:
parent
a1801af63b
commit
d2a7e652b3
16 changed files with 113 additions and 92 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -3366,6 +3366,16 @@
|
|||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
|
||||
|
|
@ -10885,6 +10895,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"rimraf": "^6.1.3",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"rimraf": "^6.1.3",
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function toPriceRecordResponse(rawDoc: unknown) {
|
|||
price: doc.price,
|
||||
currency: doc.currency,
|
||||
quantity: doc.quantity,
|
||||
unit: doc.unit,
|
||||
unit: doc.unit as import('@meshitrack/shared').ServingUnit,
|
||||
pricePerUnit: doc.pricePerUnit,
|
||||
date: toIso(doc.date),
|
||||
...(doc.receiptImageUrl ? { receiptImageUrl: doc.receiptImageUrl } : {}),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
} from '@meshitrack/shared';
|
||||
|
||||
export class ShoppingListsRepository {
|
||||
public async create(data: CreateShoppingListInput & { householdId: string; createdBy: string; status: string }) {
|
||||
public async create(data: any) {
|
||||
const list = new ShoppingListModel(data);
|
||||
const saved = await list.save();
|
||||
return saved.toObject();
|
||||
|
|
|
|||
|
|
@ -308,11 +308,11 @@ export default fp(
|
|||
|
||||
// 4. Persist Collaborative WebSocket Handshakes
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/shopping-lists/:id/sync',
|
||||
websocket: true,
|
||||
handler: (socket, request) => {
|
||||
app.get(
|
||||
'/api/v1/households/:householdId/shopping-lists/:id/sync',
|
||||
{ websocket: true },
|
||||
(connection: any, request: any) => {
|
||||
const socket = connection.socket;
|
||||
const listId = request.params.id;
|
||||
|
||||
// Setup connection context
|
||||
|
|
@ -323,7 +323,7 @@ export default fp(
|
|||
|
||||
request.log.info({ listId }, 'Active client joined shopping list sync channel');
|
||||
|
||||
socket.on('message', async (messageBuffer) => {
|
||||
socket.on('message', async (messageBuffer: any) => {
|
||||
try {
|
||||
const payload = JSON.parse(messageBuffer.toString());
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ export default fp(
|
|||
request.log.info({ listId }, 'Client severed sync handshake connection');
|
||||
});
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
{
|
||||
name: 'shopping-lists-routes',
|
||||
|
|
|
|||
|
|
@ -255,8 +255,8 @@ export class ShoppingListsService {
|
|||
quantity: item.quantity,
|
||||
unit: item.unit as ServingUnit,
|
||||
purchasePrice: item.actualPrice || undefined,
|
||||
storeId: item.storeId || list.preferredStoreId || undefined,
|
||||
notes: item.notes,
|
||||
storeId: (item.storeId || list.preferredStoreId || undefined) as string | undefined,
|
||||
notes: item.notes || undefined,
|
||||
},
|
||||
householdId,
|
||||
userId
|
||||
|
|
@ -264,14 +264,14 @@ export class ShoppingListsService {
|
|||
addedCount++;
|
||||
|
||||
// 2. Log final point-in-time pricing ledger entry if user input final price
|
||||
if (item.actualPrice !== undefined && item.actualPrice > 0) {
|
||||
if (item.actualPrice != null && item.actualPrice > 0) {
|
||||
const storeId = item.storeId || list.preferredStoreId;
|
||||
if (storeId) {
|
||||
await this.pricesService.recordPrice(
|
||||
{
|
||||
productId: item.productId,
|
||||
storeId,
|
||||
price: item.actualPrice,
|
||||
storeId: storeId as string,
|
||||
price: item.actualPrice as number,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit as ServingUnit,
|
||||
currency: 'USD',
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@ describe('MealPlan Schemas', () => {
|
|||
type: MealType.DINNER,
|
||||
recipeName: 'Chicken Salad',
|
||||
servings: 2,
|
||||
perServingNutrition: { calories: 300, protein: 30, carbs: 10, fat: 15 }
|
||||
perServingNutrition: { calories: 300, protein: 30, carbs: 10, fat: 15 },
|
||||
};
|
||||
|
||||
const validDay = {
|
||||
date: '2026-05-18',
|
||||
meals: [validMeal],
|
||||
dailyNutritionTotal: { calories: 600, protein: 60, carbs: 20, fat: 30 }
|
||||
dailyNutritionTotal: { calories: 600, protein: 60, carbs: 20, fat: 30 },
|
||||
};
|
||||
|
||||
const validPlan = {
|
||||
weekStartDate: '2026-05-18',
|
||||
days: Array(7).fill(validDay),
|
||||
status: MealPlanStatus.DRAFT
|
||||
status: MealPlanStatus.DRAFT,
|
||||
};
|
||||
|
||||
it('should validate a valid meal plan', () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ describe('NutritionTarget Schemas', () => {
|
|||
proteinG: 150,
|
||||
carbsG: 200,
|
||||
fatG: 70,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
it('should validate a valid target', () => {
|
||||
|
|
|
|||
|
|
@ -16,15 +16,17 @@ export const CreatePriceRecordSchema = z.object({
|
|||
export const BulkPriceRecordInputSchema = z.object({
|
||||
storeId: z.string().min(1),
|
||||
date: z.iso.datetime().optional(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
productId: z.string().min(1),
|
||||
price: z.number().positive(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
})
|
||||
).min(1),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
productId: z.string().min(1),
|
||||
price: z.number().positive(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const PriceHistoryQuerySchema = z.object({
|
||||
|
|
@ -70,7 +72,7 @@ export const PriceAnalyticsResponseSchema = z.object({
|
|||
storeName: z.string(),
|
||||
avgTotal: z.number(),
|
||||
tripCount: z.number(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
priceAlerts: z.array(
|
||||
z.object({
|
||||
|
|
@ -82,20 +84,20 @@ export const PriceAnalyticsResponseSchema = z.object({
|
|||
currentPrice: z.number(),
|
||||
changePercent: z.number(),
|
||||
date: z.string(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
spendingOverTime: z.array(
|
||||
z.object({
|
||||
period: z.string(),
|
||||
total: z.number(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
spendingByCategory: z.array(
|
||||
z.object({
|
||||
category: z.string(),
|
||||
total: z.number(),
|
||||
avgPerItem: z.number(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -22,16 +22,19 @@ export const ShoppingItemSchema = z.object({
|
|||
export const CreateShoppingListSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim(),
|
||||
preferredStoreId: z.string().optional(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
productId: z.string().optional(),
|
||||
customName: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().optional(),
|
||||
category: z.nativeEnum(ProductCategory).optional(),
|
||||
})
|
||||
).optional().default([]),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
productId: z.string().optional(),
|
||||
customName: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().optional(),
|
||||
category: z.nativeEnum(ProductCategory).optional(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const UpdateShoppingListSchema = z.object({
|
||||
|
|
@ -41,17 +44,18 @@ export const UpdateShoppingListSchema = z.object({
|
|||
items: z.array(ShoppingItemSchema).optional(),
|
||||
});
|
||||
|
||||
export const AddShoppingItemSchema = z.object({
|
||||
productId: z.string().optional(),
|
||||
customName: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().max(500).trim().optional(),
|
||||
category: z.nativeEnum(ProductCategory).optional(),
|
||||
}).refine(
|
||||
(data) => data.productId || data.customName,
|
||||
{ message: 'Must provide either a productId or customName' }
|
||||
);
|
||||
export const AddShoppingItemSchema = z
|
||||
.object({
|
||||
productId: z.string().optional(),
|
||||
customName: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().max(500).trim().optional(),
|
||||
category: z.nativeEnum(ProductCategory).optional(),
|
||||
})
|
||||
.refine((data) => data.productId || data.customName, {
|
||||
message: 'Must provide either a productId or customName',
|
||||
});
|
||||
|
||||
export const UpdateShoppingItemSchema = z.object({
|
||||
quantity: z.number().positive().optional(),
|
||||
|
|
@ -68,10 +72,12 @@ export const ShoppingListResponseSchema = z.object({
|
|||
name: z.string(),
|
||||
status: z.nativeEnum(ShoppingListStatus),
|
||||
items: z.array(ShoppingItemSchema),
|
||||
createdFrom: z.object({
|
||||
type: z.nativeEnum(ShoppingListSourceType),
|
||||
referenceId: z.string().optional(),
|
||||
}).optional(),
|
||||
createdFrom: z
|
||||
.object({
|
||||
type: z.nativeEnum(ShoppingListSourceType),
|
||||
referenceId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
mealPlanId: z.string().optional(),
|
||||
totalEstimatedCost: z.number().optional(),
|
||||
preferredStoreId: z.string().optional(),
|
||||
|
|
@ -93,20 +99,22 @@ export const StoreComparisonResultSchema = z.object({
|
|||
estimatedTotal: z.number(),
|
||||
itemsCovered: z.number(),
|
||||
itemsMissing: z.array(z.string()),
|
||||
})
|
||||
}),
|
||||
),
|
||||
splitStoreOption: z.object({
|
||||
stores: z.array(
|
||||
z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
items: z.array(z.string()),
|
||||
subtotal: z.number(),
|
||||
})
|
||||
),
|
||||
estimatedTotal: z.number(),
|
||||
savingsVsBestSingleStore: z.number(),
|
||||
}).optional(),
|
||||
splitStoreOption: z
|
||||
.object({
|
||||
stores: z.array(
|
||||
z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
items: z.array(z.string()),
|
||||
subtotal: z.number(),
|
||||
}),
|
||||
),
|
||||
estimatedTotal: z.number(),
|
||||
savingsVsBestSingleStore: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const BasketStoreComparisonResponseSchema = StoreComparisonResultSchema;
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export function ProductModal({
|
|||
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Basic info */}
|
||||
<div>
|
||||
<label style={labelStyle}>Name *</label>
|
||||
|
|
|
|||
|
|
@ -36,10 +36,9 @@ describe('ImportDialog', () => {
|
|||
expect(screen.getByText('Choose .csv or .json file...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when upload clicked without file', async () => {
|
||||
it('Upload button is disabled without file', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
expect(screen.getByText('Please select a file')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows file name after selection', () => {
|
||||
|
|
|
|||
|
|
@ -108,11 +108,11 @@ describe('ProductModal', () => {
|
|||
|
||||
// Fill nutrition
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
// serving size is index 0, calories=1, protein=2, carbs=3, fat=4
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '165' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '31' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '0' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '3.6' } });
|
||||
// serving size is index 0, density=1, calories=2, protein=3, carbs=4, fat=5
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '165' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '31' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '0' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '3.6' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
|
|
@ -141,10 +141,10 @@ describe('ProductModal', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
|
|
@ -163,10 +163,10 @@ describe('ProductModal', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
|
|
@ -185,10 +185,10 @@ describe('ProductModal', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUsePathname } = vi.hoisted(() => ({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ThemeProvider, useTheme } from '../ThemeProvider';
|
||||
import { Providers } from '../Providers';
|
||||
|
|
|
|||
BIN
packages/web/test-output.txt
Normal file
BIN
packages/web/test-output.txt
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue