diff --git a/docs/adr/0023-database-normalization-and-referential-integrity.md b/docs/adr/0023-database-normalization-and-referential-integrity.md new file mode 100644 index 0000000..652e7b0 --- /dev/null +++ b/docs/adr/0023-database-normalization-and-referential-integrity.md @@ -0,0 +1,352 @@ +# ADR-023: Database Normalization and Referential Integrity + +**Date:** 2026-01-19 +**Status:** Accepted +**Context:** API design violates database normalization principles + +## Problem Statement + +The application's API layer currently accepts string-based references (category names) instead of numerical IDs when creating relationships between entities. This violates database normalization principles and creates a brittle, error-prone API contract. + +**Example of Current Problem:** + +```typescript +// API accepts string: +POST /api/users/watched-items +{ "itemName": "Milk", "category": "Dairy & Eggs" } // ❌ String reference + +// But database uses normalized foreign keys: +CREATE TABLE master_grocery_items ( + category_id BIGINT REFERENCES categories(category_id) -- ✅ Proper FK +) +``` + +This mismatch forces the service layer to perform string lookups on every request: + +```typescript +// Service must do string matching: +const categoryRes = await client.query( + 'SELECT category_id FROM categories WHERE name = $1', + [categoryName], // ❌ Error-prone string matching +); +``` + +## Database Normal Forms (In Order of Importance) + +### 1. First Normal Form (1NF) ✅ Currently Satisfied + +**Rule:** Each column contains atomic values; no repeating groups. + +**Status:** ✅ **Compliant** + +- All columns contain single values +- No arrays or delimited strings in columns +- Each row is uniquely identifiable + +**Example:** + +```sql +-- ✅ Good: Atomic values +CREATE TABLE master_grocery_items ( + master_grocery_item_id BIGINT PRIMARY KEY, + name TEXT, + category_id BIGINT +); + +-- ❌ Bad: Non-atomic values (violates 1NF) +CREATE TABLE items ( + id BIGINT, + categories TEXT -- "Dairy,Frozen,Snacks" (comma-delimited) +); +``` + +### 2. Second Normal Form (2NF) ✅ Currently Satisfied + +**Rule:** No partial dependencies; all non-key columns depend on the entire primary key. + +**Status:** ✅ **Compliant** + +- All tables use single-column primary keys (no composite keys) +- All non-key columns depend on the entire primary key + +**Example:** + +```sql +-- ✅ Good: All columns depend on full primary key +CREATE TABLE flyer_items ( + flyer_item_id BIGINT PRIMARY KEY, + flyer_id BIGINT, -- Depends on flyer_item_id + master_item_id BIGINT, -- Depends on flyer_item_id + price_in_cents INT -- Depends on flyer_item_id +); + +-- ❌ Bad: Partial dependency (violates 2NF) +CREATE TABLE flyer_items ( + flyer_id BIGINT, + item_id BIGINT, + store_name TEXT, -- Depends only on flyer_id, not (flyer_id, item_id) + PRIMARY KEY (flyer_id, item_id) +); +``` + +### 3. Third Normal Form (3NF) ⚠️ VIOLATED IN API LAYER + +**Rule:** No transitive dependencies; non-key columns depend only on the primary key, not on other non-key columns. + +**Status:** ⚠️ **Database is compliant, but API layer violates this principle** + +**Database Schema (Correct):** + +```sql +-- ✅ Categories are normalized +CREATE TABLE categories ( + category_id BIGINT PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE master_grocery_items ( + master_grocery_item_id BIGINT PRIMARY KEY, + name TEXT, + category_id BIGINT REFERENCES categories(category_id) -- Direct reference +); +``` + +**API Layer (Violates 3NF Principle):** + +```typescript +// ❌ API accepts category name instead of ID +POST /api/users/watched-items +{ + "itemName": "Milk", + "category": "Dairy & Eggs" // String! Should be category_id +} + +// Service layer must denormalize by doing lookup: +SELECT category_id FROM categories WHERE name = $1 +``` + +This creates a **transitive dependency** in the application layer: + +- `watched_item` → `category_name` → `category_id` +- Instead of direct: `watched_item` → `category_id` + +### 4. Boyce-Codd Normal Form (BCNF) ✅ Currently Satisfied + +**Rule:** Every determinant is a candidate key (stricter version of 3NF). + +**Status:** ✅ **Compliant** + +- All foreign key references use primary keys +- No non-trivial functional dependencies where determinant is not a superkey + +### 5. Fourth Normal Form (4NF) ✅ Currently Satisfied + +**Rule:** No multi-valued dependencies; a record should not contain independent multi-valued facts. + +**Status:** ✅ **Compliant** + +- Junction tables properly separate many-to-many relationships +- Examples: `user_watched_items`, `shopping_list_items`, `recipe_ingredients` + +### 6. Fifth Normal Form (5NF) ✅ Currently Satisfied + +**Rule:** No join dependencies; tables cannot be decomposed further without loss of information. + +**Status:** ✅ **Compliant** (as far as schema design goes) + +## Impact of API Violation + +### 1. Brittleness + +```typescript +// Test fails because of exact string matching: +addWatchedItem('Milk', 'Dairy'); // ❌ Fails - not exact match +addWatchedItem('Milk', 'Dairy & Eggs'); // ✅ Works - exact match +addWatchedItem('Milk', 'dairy & eggs'); // ❌ Fails - case sensitive +``` + +### 2. No Discovery Mechanism + +- No API endpoint to list available categories +- Frontend cannot dynamically populate dropdowns +- Clients must hardcode category names + +### 3. Performance Penalty + +```sql +-- Current: String lookup on every request +SELECT category_id FROM categories WHERE name = $1; -- Full table scan or index scan + +-- Should be: Direct ID reference (no lookup needed) +INSERT INTO master_grocery_items (name, category_id) VALUES ($1, $2); +``` + +### 4. Impossible Localization + +- Cannot translate category names without breaking API +- Category names are hardcoded in English + +### 5. Maintenance Burden + +- Renaming a category breaks all API clients +- Must coordinate name changes across frontend, tests, and documentation + +## Decision + +**We adopt the following principles for all API design:** + +### 1. Use Numerical IDs for All Foreign Key References + +**Rule:** APIs MUST accept numerical IDs when creating relationships between entities. + +```typescript +// ✅ CORRECT: Use IDs +POST /api/users/watched-items +{ + "itemName": "Milk", + "category_id": 3 // Numerical ID +} + +// ❌ INCORRECT: Use strings +POST /api/users/watched-items +{ + "itemName": "Milk", + "category": "Dairy & Eggs" // String name +} +``` + +### 2. Provide Discovery Endpoints + +**Rule:** For any entity referenced by ID, provide a GET endpoint to list available options. + +```typescript +// Required: Category discovery endpoint +GET / api / categories; +Response: [ + { category_id: 1, name: 'Fruits & Vegetables' }, + { category_id: 2, name: 'Meat & Seafood' }, + { category_id: 3, name: 'Dairy & Eggs' }, +]; +``` + +### 3. Support Lookup by Name (Optional) + +**Rule:** If convenient, provide query parameters for name-based lookup, but use IDs internally. + +```typescript +// Optional: Convenience endpoint +GET /api/categories?name=Dairy%20%26%20Eggs +Response: { "category_id": 3, "name": "Dairy & Eggs" } +``` + +### 4. Return Full Objects in Responses + +**Rule:** API responses SHOULD include denormalized data for convenience, but inputs MUST use IDs. + +```typescript +// ✅ Response includes category details +GET / api / users / watched - items; +Response: [ + { + master_grocery_item_id: 42, + name: 'Milk', + category_id: 3, + category: { + // ✅ Include full object in response + category_id: 3, + name: 'Dairy & Eggs', + }, + }, +]; +``` + +## Affected Areas + +### Immediate Violations (Must Fix) + +1. **User Watched Items** ([src/routes/user.routes.ts:76](../../src/routes/user.routes.ts)) + - Currently: `category: string` + - Should be: `category_id: number` + +2. **Service Layer** ([src/services/db/personalization.db.ts:175](../../src/services/db/personalization.db.ts)) + - Currently: `categoryName: string` + - Should be: `categoryId: number` + +3. **API Client** ([src/services/apiClient.ts:436](../../src/services/apiClient.ts)) + - Currently: `category: string` + - Should be: `category_id: number` + +4. **Frontend Hooks** ([src/hooks/mutations/useAddWatchedItemMutation.ts:9](../../src/hooks/mutations/useAddWatchedItemMutation.ts)) + - Currently: `category?: string` + - Should be: `category_id: number` + +### Potential Violations (Review Required) + +1. **UPC/Barcode System** ([src/types/upc.ts:85](../../src/types/upc.ts)) + - Uses `category: string | null` + - May be appropriate if category is free-form user input + +2. **AI Extraction** ([src/types/ai.ts:21](../../src/types/ai.ts)) + - Uses `category_name: z.string()` + - AI extracts category names, needs mapping to IDs + +3. **Flyer Data Transformer** ([src/services/flyerDataTransformer.ts:40](../../src/services/flyerDataTransformer.ts)) + - Uses `category_name: string` + - May need category matching/creation logic + +## Migration Strategy + +See [research-category-id-migration.md](../research-category-id-migration.md) for detailed migration plan. + +**High-level approach:** + +1. **Phase 1: Add category discovery endpoint** (non-breaking) + - `GET /api/categories` + - No API changes yet + +2. **Phase 2: Support both formats** (non-breaking) + - Accept both `category` (string) and `category_id` (number) + - Deprecate string format with warning logs + +3. **Phase 3: Remove string support** (breaking change, major version bump) + - Only accept `category_id` + - Update all clients and tests + +## Consequences + +### Positive + +- ✅ API matches database schema design +- ✅ More robust (no typo-based failures) +- ✅ Better performance (no string lookups) +- ✅ Enables localization +- ✅ Discoverable via REST API +- ✅ Follows REST best practices + +### Negative + +- ⚠️ Breaking change for existing API consumers +- ⚠️ Requires client updates +- ⚠️ More complex migration path + +### Neutral + +- Frontend must fetch categories before displaying form +- Slightly more initial API calls (one-time category fetch) + +## References + +- [Database Normalization (Wikipedia)](https://en.wikipedia.org/wiki/Database_normalization) +- [REST API Design Best Practices](https://stackoverflow.blog/2020/03/02/best-practices-for-rest-api-design/) +- [PostgreSQL Foreign Keys](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK) + +## Related Decisions + +- [ADR-001: Database Schema Design](./0001-database-schema-design.md) (if exists) +- [ADR-014: Containerization and Deployment Strategy](./0014-containerization-and-deployment-strategy.md) + +## Approval + +- **Proposed by:** Claude Code (via user observation) +- **Date:** 2026-01-19 +- **Status:** Accepted (pending implementation) diff --git a/docs/research-category-id-migration.md b/docs/research-category-id-migration.md new file mode 100644 index 0000000..5fc4834 --- /dev/null +++ b/docs/research-category-id-migration.md @@ -0,0 +1,1029 @@ +# Research: Migration Plan for Category ID References + +**Date:** 2026-01-19 +**Status:** Planning +**Related:** [ADR-023: Database Normalization and Referential Integrity](adr/0023-database-normalization-and-referential-integrity.md) + +## Executive Summary + +The API currently accepts category names (strings) instead of category IDs (numbers) when creating watched items. This violates database normalization principles (3NF) and creates a brittle API. This document outlines a comprehensive, phased migration plan to fix this issue across the entire codebase. + +**Estimated Effort:** 2-3 weeks (including testing and deployment) +**Breaking Changes:** Yes (API contract changes) +**Recommended Version Bump:** Major (0.11.14 → 1.0.0 or 0.12.0) + +--- + +## Current State Analysis + +### Files Using String-Based Category References + +| File | Line | Current Type | Usage | +| -------------------------------------------------- | ------------ | ------------------------------- | ------------------------ | +| `src/routes/user.routes.ts` | 76 | `category: string` | API validation schema | +| `src/services/db/personalization.db.ts` | 175 | `categoryName: string` | Service method parameter | +| `src/services/apiClient.ts` | 436 | `category: string` | API client function | +| `src/hooks/mutations/useAddWatchedItemMutation.ts` | 9 | `category?: string` | Frontend mutation hook | +| `src/types/upc.ts` | 85, 107, 201 | `category: string \| null` | UPC types | +| `src/types/ai.ts` | 21 | `category_name: z.string()` | AI extraction schema | +| `src/services/flyerDataTransformer.ts` | 40 | `category_name: string` | Flyer processing | +| `src/services/db/expiry.db.ts` | 51 | `category_name: string \| null` | Expiry tracking | +| `src/services/db/upc.db.ts` | 36, 347 | `category: string \| null` | UPC database | + +### Database Schema (Current - Correct) + +```sql +-- Categories table (normalized) +CREATE TABLE categories ( + category_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +-- Master items reference category by ID (correct) +CREATE TABLE master_grocery_items ( + master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + category_id BIGINT REFERENCES categories(category_id), -- ✅ Proper FK + -- ... +); +``` + +### Current API Behavior + +```typescript +// API Request (current - problematic) +POST /api/users/watched-items +{ + "itemName": "Milk", + "category": "Dairy & Eggs" // ❌ String +} + +// Service performs string lookup +const categoryRes = await client.query( + 'SELECT category_id FROM categories WHERE name = $1', + [categoryName] // ❌ String matching on every request +); + +// Then uses ID internally +INSERT INTO master_grocery_items (name, category_id) VALUES ($1, $2) +``` + +--- + +## Migration Plan + +### Phase 1: Add Category Discovery (Non-Breaking) + +**Duration:** 2-3 days +**Risk:** Low +**Breaking:** No + +#### 1.1. Create Categories API Route + +**File:** `src/routes/category.routes.ts` (new file) + +```typescript +// src/routes/category.routes.ts +import { Router } from 'express'; +import { CategoryDbService } from '../services/db/category.db'; +import { asyncHandler } from '../middleware/asyncHandler'; +import { getLogger } from '../services/logger.server'; + +const router = Router(); +const logger = getLogger(); + +/** + * @swagger + * /api/categories: + * get: + * summary: List all available grocery categories + * tags: [Categories] + * responses: + * 200: + * description: List of categories + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * type: object + * properties: + * category_id: + * type: integer + * name: + * type: string + */ +router.get( + '/', + asyncHandler(async (req, res) => { + const categories = await CategoryDbService.getAllCategories(logger); + + res.json({ + success: true, + data: categories, + }); + }), +); + +/** + * @swagger + * /api/categories/{id}: + * get: + * summary: Get a specific category by ID + * tags: [Categories] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Category details + * 404: + * description: Category not found + */ +router.get( + '/:id', + asyncHandler(async (req, res) => { + const categoryId = parseInt(req.params.id); + const category = await CategoryDbService.getCategoryById(categoryId, logger); + + if (!category) { + return res.status(404).json({ + success: false, + error: 'Category not found', + }); + } + + res.json({ + success: true, + data: category, + }); + }), +); + +/** + * @swagger + * /api/categories/lookup: + * get: + * summary: Lookup category by name (for migration support) + * tags: [Categories] + * parameters: + * - in: query + * name: name + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Category found + * 404: + * description: Category not found + */ +router.get( + '/lookup', + asyncHandler(async (req, res) => { + const name = req.query.name as string; + + if (!name) { + return res.status(400).json({ + success: false, + error: 'Query parameter "name" is required', + }); + } + + const category = await CategoryDbService.getCategoryByName(name, logger); + + if (!category) { + return res.status(404).json({ + success: false, + error: `Category '${name}' not found`, + }); + } + + res.json({ + success: true, + data: category, + }); + }), +); + +export default router; +``` + +#### 1.2. Create Category Database Service + +**File:** `src/services/db/category.db.ts` (new file) + +```typescript +// src/services/db/category.db.ts +import { Logger } from 'pino'; +import { getPool } from './connection.db'; +import { handleDbError } from '../errorHandler'; + +export interface Category { + category_id: number; + name: string; + created_at: Date; + updated_at: Date; +} + +export class CategoryDbService { + /** + * Get all categories ordered by name + */ + static async getAllCategories(logger: Logger): Promise { + const pool = getPool(); + + try { + const result = await pool.query( + `SELECT category_id, name, created_at, updated_at + FROM public.categories + ORDER BY name ASC`, + ); + + return result.rows; + } catch (error) { + handleDbError(error, logger, 'Error fetching all categories'); + throw error; + } + } + + /** + * Get category by ID + */ + static async getCategoryById(categoryId: number, logger: Logger): Promise { + const pool = getPool(); + + try { + const result = await pool.query( + `SELECT category_id, name, created_at, updated_at + FROM public.categories + WHERE category_id = $1`, + [categoryId], + ); + + return result.rows[0] || null; + } catch (error) { + handleDbError(error, logger, 'Error fetching category by ID', { categoryId }); + throw error; + } + } + + /** + * Get category by name (case-insensitive) + */ + static async getCategoryByName(name: string, logger: Logger): Promise { + const pool = getPool(); + + try { + const result = await pool.query( + `SELECT category_id, name, created_at, updated_at + FROM public.categories + WHERE LOWER(name) = LOWER($1)`, + [name], + ); + + return result.rows[0] || null; + } catch (error) { + handleDbError(error, logger, 'Error fetching category by name', { name }); + throw error; + } + } +} +``` + +#### 1.3. Register Category Routes + +**File:** `server.ts` + +```typescript +// Add to server.ts imports +import categoryRoutes from './src/routes/category.routes'; + +// Add to route registrations +app.use('/api/categories', categoryRoutes); +``` + +#### 1.4. Testing Phase 1 + +**Create:** `src/tests/integration/category.routes.test.ts` + +```typescript +// src/tests/integration/category.routes.test.ts +import { describe, it, expect, beforeAll } from 'vitest'; +import supertest from 'supertest'; +import { app } from '../../server'; + +describe('Category API Routes', () => { + let request: supertest.SuperTest; + + beforeAll(() => { + request = supertest(app); + }); + + describe('GET /api/categories', () => { + it('should return list of all categories', async () => { + const response = await request.get('/api/categories'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThan(0); + + // Verify category structure + const firstCategory = response.body.data[0]; + expect(firstCategory).toHaveProperty('category_id'); + expect(firstCategory).toHaveProperty('name'); + expect(typeof firstCategory.category_id).toBe('number'); + expect(typeof firstCategory.name).toBe('string'); + }); + + it('should return categories in alphabetical order', async () => { + const response = await request.get('/api/categories'); + const categories = response.body.data; + + for (let i = 1; i < categories.length; i++) { + expect(categories[i].name >= categories[i - 1].name).toBe(true); + } + }); + }); + + describe('GET /api/categories/:id', () => { + it('should return specific category by ID', async () => { + // First get all categories to find a valid ID + const listResponse = await request.get('/api/categories'); + const firstCategory = listResponse.body.data[0]; + + const response = await request.get(`/api/categories/${firstCategory.category_id}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.category_id).toBe(firstCategory.category_id); + expect(response.body.data.name).toBe(firstCategory.name); + }); + + it('should return 404 for non-existent category ID', async () => { + const response = await request.get('/api/categories/999999'); + + expect(response.status).toBe(404); + expect(response.body.success).toBe(false); + }); + }); + + describe('GET /api/categories/lookup', () => { + it('should find category by exact name', async () => { + const response = await request.get('/api/categories/lookup?name=Dairy & Eggs'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.name).toBe('Dairy & Eggs'); + }); + + it('should find category by case-insensitive name', async () => { + const response = await request.get('/api/categories/lookup?name=dairy & eggs'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.name).toBe('Dairy & Eggs'); + }); + + it('should return 404 for non-existent category name', async () => { + const response = await request.get('/api/categories/lookup?name=NonExistent'); + + expect(response.status).toBe(404); + expect(response.body.success).toBe(false); + }); + + it('should return 400 if name parameter is missing', async () => { + const response = await request.get('/api/categories/lookup'); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + }); +}); +``` + +**Update E2E test:** `src/tests/e2e/deals-journey.e2e.test.ts` + +```typescript +// Add category fetch at start of test +const categoriesResponse = await authedFetch('/categories', { + method: 'GET', +}); +const categories = await categoriesResponse.json(); +const dairyCategory = categories.data.find((c) => c.name === 'Dairy & Eggs'); +const bakeryCategory = categories.data.find((c) => c.name === 'Bakery & Bread'); + +// Still use string for now (backward compatible) +const watchItem1Response = await authedFetch('/users/watched-items', { + method: 'POST', + token: authToken, + body: JSON.stringify({ + itemName: 'E2E Milk 2%', + category: 'Dairy & Eggs', // Still works + }), +}); +``` + +#### 1.5. Documentation + +Update `docs/api/README.md` or Swagger docs with new `/api/categories` endpoint. + +--- + +### Phase 2: Support Both Formats (Non-Breaking) + +**Duration:** 3-5 days +**Risk:** Medium +**Breaking:** No (backward compatible) + +#### 2.1. Update API Validation Schema + +**File:** `src/routes/user.routes.ts` + +```typescript +// Update schema to accept both formats +const addWatchedItemSchema = z.object({ + body: z + .object({ + itemName: requiredString("Field 'itemName' is required."), + // Accept either category (string, deprecated) or category_id (number, preferred) + category: z.string().trim().optional(), + category_id: z.number().int().positive().optional(), + }) + .refine((data) => data.category || data.category_id, { + message: "Either 'category' or 'category_id' must be provided", + path: ['category'], + }), +}); +``` + +#### 2.2. Update Service Layer + +**File:** `src/services/db/personalization.db.ts` + +```typescript +/** + * Add a master grocery item to a user's watchlist. + * + * @param userId - UUID of the user + * @param itemName - Name of the grocery item + * @param categoryIdOrName - Either category ID (number) or name (string, deprecated) + * @param logger - Pino logger instance + * @returns A promise that resolves to the MasterGroceryItem + * + * @deprecated Passing category name as string is deprecated. Use category ID instead. + */ +async addWatchedItem( + userId: string, + itemName: string, + categoryIdOrName: number | string, + logger: Logger, +): Promise { + try { + return await withTransaction(async (client) => { + let categoryId: number; + + // Handle both ID and name (with deprecation warning) + if (typeof categoryIdOrName === 'number') { + categoryId = categoryIdOrName; + } else { + // Legacy string-based lookup (deprecated) + logger.warn( + { categoryName: categoryIdOrName, userId, itemName }, + 'DEPRECATED: Using category name instead of ID. Please update to use category_id.' + ); + + const categoryRes = await client.query<{ category_id: number }>( + 'SELECT category_id FROM public.categories WHERE name = $1', + [categoryIdOrName], + ); + + categoryId = categoryRes.rows[0]?.category_id; + if (!categoryId) { + throw new Error(`Category '${categoryIdOrName}' not found.`); + } + } + + // Validate category ID exists + const categoryCheck = await client.query( + 'SELECT 1 FROM public.categories WHERE category_id = $1', + [categoryId] + ); + + if (categoryCheck.rows.length === 0) { + throw new Error(`Category ID ${categoryId} does not exist.`); + } + + // Find or create master item + let masterItem: MasterGroceryItem; + const masterItemRes = await client.query( + 'SELECT * FROM public.master_grocery_items WHERE name = $1', + [itemName], + ); + + if (masterItemRes.rows.length > 0) { + masterItem = masterItemRes.rows[0]; + } else { + const newMasterItemRes = await client.query( + 'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *', + [itemName, categoryId], + ); + masterItem = newMasterItemRes.rows[0]; + } + + // Add to user's watchlist + await client.query( + 'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING', + [userId, masterItem.master_grocery_item_id], + ); + + return masterItem; + }); + } catch (error) { + handleDbError( + error, + logger, + 'Transaction error in addWatchedItem', + { userId, itemName, categoryIdOrName }, + ); + throw error; + } +} +``` + +#### 2.3. Update Route Handler + +**File:** `src/routes/user.routes.ts` (route handler) + +```typescript +router.post( + '/watched-items', + validateRequest(addWatchedItemSchema), + asyncHandler(async (req: RequestWithJwt, res: Response) => { + const { itemName, category, category_id } = req.body; + const userId = req.jwt?.userId; + + // Prefer category_id, fallback to category (deprecated) + const categoryIdOrName = category_id ?? category; + + if (category && !category_id) { + // Log deprecation warning + req.log.warn( + { category, userId }, + 'Client using deprecated "category" field. Should use "category_id".', + ); + } + + const item = await PersonalizationDbService.addWatchedItem( + userId, + itemName, + categoryIdOrName, + req.log, + ); + + res.status(201).json({ + success: true, + data: item, + }); + }), +); +``` + +#### 2.4. Update Frontend (Gradual) + +**File:** `src/services/apiClient.ts` + +```typescript +/** + * Add item to watched list + * + * @param itemName - Name of the item + * @param categoryIdOrName - Category ID (preferred) or name (deprecated) + */ +export const addWatchedItem = async ( + itemName: string, + categoryIdOrName: number | string, +): Promise => { + const body: { itemName: string; category_id?: number; category?: string } = { + itemName, + }; + + if (typeof categoryIdOrName === 'number') { + body.category_id = categoryIdOrName; + } else { + body.category = categoryIdOrName; + } + + return authenticatedFetch('/users/watched-items', { + method: 'POST', + body: JSON.stringify(body), + }); +}; +``` + +#### 2.5. Update E2E Tests (Support Both) + +**File:** `src/tests/e2e/deals-journey.e2e.test.ts` + +```typescript +// Fetch categories first +const categoriesResponse = await fetch(`${API_BASE_URL}/categories`); +const categoriesData = await categoriesResponse.json(); +const dairyCategory = categoriesData.data.find((c) => c.name === 'Dairy & Eggs'); + +// Use category_id (new format) +const watchItem1Response = await authedFetch('/users/watched-items', { + method: 'POST', + token: authToken, + body: JSON.stringify({ + itemName: 'E2E Milk 2%', + category_id: dairyCategory.category_id, // ✅ Use ID + }), +}); +``` + +#### 2.6. Testing Phase 2 + +Create integration tests that verify both formats work: + +```typescript +describe('POST /api/users/watched-items (dual format)', () => { + it('should accept category_id (new format)', async () => { + const response = await authenticatedRequest.post('/api/users/watched-items').send({ + itemName: 'Test Item', + category_id: 3, + }); + + expect(response.status).toBe(201); + }); + + it('should accept category name (deprecated format)', async () => { + const response = await authenticatedRequest.post('/api/users/watched-items').send({ + itemName: 'Test Item', + category: 'Dairy & Eggs', + }); + + expect(response.status).toBe(201); + // Should log deprecation warning + }); + + it('should prefer category_id when both provided', async () => { + const response = await authenticatedRequest.post('/api/users/watched-items').send({ + itemName: 'Test Item', + category_id: 3, + category: 'Wrong Name', // Should be ignored + }); + + expect(response.status).toBe(201); + }); +}); +``` + +--- + +### Phase 3: Remove String Support (Breaking Change) + +**Duration:** 2-3 days +**Risk:** High +**Breaking:** Yes (requires version bump) + +#### 3.1. Version Bump + +Update `package.json`: + +```json +{ + "version": "1.0.0" // or "0.12.0" depending on semver strategy +} +``` + +#### 3.2. Remove Deprecated Code + +**File:** `src/routes/user.routes.ts` + +```typescript +// Remove deprecated category field +const addWatchedItemSchema = z.object({ + body: z.object({ + itemName: requiredString("Field 'itemName' is required."), + category_id: z.number().int().positive("Field 'category_id' must be a positive integer"), + }), +}); +``` + +**File:** `src/services/db/personalization.db.ts` + +```typescript +// Remove string overload +async addWatchedItem( + userId: string, + itemName: string, + categoryId: number, // Only accept number + logger: Logger, +): Promise { + try { + return await withTransaction(async (client) => { + // Validate category exists + const categoryCheck = await client.query( + 'SELECT 1 FROM public.categories WHERE category_id = $1', + [categoryId] + ); + + if (categoryCheck.rows.length === 0) { + throw new Error(`Category ID ${categoryId} does not exist.`); + } + + // ... rest of implementation (no string lookup) + }); + } catch (error) { + handleDbError(error, logger, 'Transaction error in addWatchedItem', { + userId, + itemName, + categoryId, + }); + throw error; + } +} +``` + +#### 3.3. Update All Client Code + +**File:** `src/services/apiClient.ts` + +```typescript +// Remove string support +export const addWatchedItem = async ( + itemName: string, + categoryId: number, // Only number +): Promise => { + return authenticatedFetch('/users/watched-items', { + method: 'POST', + body: JSON.stringify({ + itemName, + category_id: categoryId, + }), + }); +}; +``` + +**File:** `src/hooks/mutations/useAddWatchedItemMutation.ts` + +```typescript +interface AddWatchedItemParams { + itemName: string; + category_id: number; // Only number +} +``` + +#### 3.4. Update All Tests + +Update all E2E and integration tests to use `category_id`: + +```typescript +// All tests must fetch category IDs +const categoriesResponse = await fetch(`${API_BASE_URL}/categories`); +const categories = await categoriesResponse.json(); + +const watchItem1Response = await authedFetch('/users/watched-items', { + method: 'POST', + token: authToken, + body: JSON.stringify({ + itemName: 'E2E Milk 2%', + category_id: categories.data.find((c) => c.name === 'Dairy & Eggs').category_id, + }), +}); +``` + +#### 3.5. Update API Documentation + +- Update Swagger/OpenAPI specs +- Update README and API docs +- Add migration guide for API consumers + +#### 3.6. Deployment + +1. Deploy Phase 3 to test environment +2. Run full E2E test suite +3. Verify all tests pass +4. Deploy to production with major version bump +5. Communicate breaking change to stakeholders + +--- + +## Similar Issues to Address + +### 1. UPC/Barcode System + +**Current:** + +```typescript +// src/types/upc.ts +export interface UpcLookupResult { + category: string | null; // ❌ String +} +``` + +**Decision Needed:** + +- Is this user-provided free-form text? → Keep as string +- Is this from a predefined list? → Change to category_id + +**Recommendation:** If categories are predefined, change to `category_id: number | null` + +### 2. AI Extraction + +**Current:** + +```typescript +// src/types/ai.ts +export const FlyerItemSchema = z.object({ + category_name: z.string().nullish(), +}); +``` + +**Approach:** + +- AI extracts category name (string) +- Service layer maps to category_id using fuzzy matching +- Store category_id in database + +**Implementation:** + +```typescript +// New: AI Category Mapper Service +export class AiCategoryMapper { + static async mapCategoryName( + categoryName: string | null, + logger: Logger, + ): Promise { + if (!categoryName) return null; + + // Try exact match first + let category = await CategoryDbService.getCategoryByName(categoryName, logger); + + // If no match, try fuzzy matching or default to "Other/Miscellaneous" + if (!category) { + logger.warn({ categoryName }, 'AI extracted unknown category, using default'); + category = await CategoryDbService.getCategoryByName('Other/Miscellaneous', logger); + } + + return category?.category_id || null; + } +} +``` + +### 3. Flyer Data Transformer + +**Current:** + +```typescript +// src/services/flyerDataTransformer.ts +category_name: String(item.category_name ?? '').trim() || 'Other/Miscellaneous', +``` + +**Should map to category_id:** + +```typescript +const categoryId = await AiCategoryMapper.mapCategoryName(item.category_name, logger); +``` + +--- + +## Testing Strategy + +### Unit Tests + +- `CategoryDbService` - all CRUD operations +- `AiCategoryMapper` - fuzzy matching logic +- Validation schemas + +### Integration Tests + +- Category routes (all endpoints) +- Watched items with category_id +- Backward compatibility (Phase 2 only) + +### E2E Tests + +- Full user journey with category selection +- All 11 E2E tests updated to use category_id + +### Manual Testing Checklist + +- [ ] Frontend category dropdown populated from API +- [ ] Creating watched item with category_id +- [ ] Viewing watched items shows category name +- [ ] AI extraction maps categories correctly +- [ ] Error handling for invalid category_id + +--- + +## Rollback Plan + +### Phase 1 Rollback + +**Risk:** Very low +**Steps:** + +1. Remove category routes from `server.ts` +2. Delete `src/routes/category.routes.ts` +3. Delete `src/services/db/category.db.ts` +4. Redeploy previous version + +### Phase 2 Rollback + +**Risk:** Low (backward compatible) +**Steps:** + +1. Revert changes to validation schemas +2. Revert service layer to string-only +3. Clients using category_id will break, but old clients still work + +### Phase 3 Rollback + +**Risk:** High (breaking change) +**Steps:** + +1. Revert to Phase 2 code (dual support) +2. Redeploy with version downgrade +3. Communicate rollback to API consumers + +--- + +## Communication Plan + +### Before Phase 1 + +- Announce new `/api/categories` endpoint +- Encourage clients to start fetching categories + +### Before Phase 2 + +- Announce deprecation of `category` field +- Recommend migration to `category_id` +- Provide timeline for Phase 3 + +### Before Phase 3 + +- Final warning: 2 weeks before deployment +- Publish migration guide +- Offer support for client updates + +### After Phase 3 + +- Announce breaking change deployed +- Monitor error rates and Bugsink for issues +- Provide rollback plan if needed + +--- + +## Success Criteria + +- [ ] All E2E tests pass with category_id +- [ ] No performance regression +- [ ] API response times improved (no string lookups) +- [ ] Zero Bugsink errors related to category issues +- [ ] Frontend displays categories correctly +- [ ] All documentation updated + +--- + +## Timeline + +| Phase | Duration | Start | End | +| --------------------------- | -------------- | ------ | ------ | +| Phase 1: Discovery Endpoint | 2-3 days | Week 1 | Week 1 | +| Phase 1: Testing | 1 day | Week 1 | Week 1 | +| Phase 2: Dual Support | 3-5 days | Week 2 | Week 2 | +| Phase 2: Testing | 2 days | Week 2 | Week 2 | +| Phase 3: Remove Deprecated | 2-3 days | Week 3 | Week 3 | +| Phase 3: Testing & Deploy | 2 days | Week 3 | Week 3 | +| **Total** | **12-16 days** | | | + +--- + +## Related Documents + +- [ADR-023: Database Normalization and Referential Integrity](adr/0023-database-normalization-and-referential-integrity.md) +- [TESTING.md](TESTING.md) +- API Documentation (Swagger) + +--- + +## Notes + +- Consider adding category icons/colors in future +- May want to allow user-defined categories later +- Internationalization of category names needed eventually +- Category ordering/sorting preferences per user diff --git a/docs/research-e2e-test-separation.md b/docs/research-e2e-test-separation.md new file mode 100644 index 0000000..920845e --- /dev/null +++ b/docs/research-e2e-test-separation.md @@ -0,0 +1,232 @@ +# Research: Separating E2E Tests from Integration Tests + +**Date:** 2026-01-19 +**Status:** In Progress +**Context:** E2E tests exist with their own config but are not being run separately + +## Current State + +### Test Structure + +- **Unit tests**: `src/tests/unit/` (but most are co-located with source files) +- **Integration tests**: `src/tests/integration/` (28 test files) +- **E2E tests**: `src/tests/e2e/` (11 test files) **← NOT CURRENTLY RUNNING** + +### Configurations + +| Config File | Project Name | Environment | Port | Include Pattern | +| ------------------------------ | ------------- | ----------- | ---- | ------------------------------------------ | +| `vite.config.ts` | `unit` | jsdom | N/A | Component/hook tests | +| `vitest.config.integration.ts` | `integration` | node | 3099 | `src/tests/integration/**/*.test.{ts,tsx}` | +| `vitest.config.e2e.ts` | `e2e` | node | 3098 | `src/tests/e2e/**/*.e2e.test.ts` | + +### Workspace Configuration + +**`vitest.workspace.ts` currently includes:** + +```typescript +export default [ + 'vite.config.ts', // Unit tests + 'vitest.config.integration.ts', // Integration tests + // ❌ vitest.config.e2e.ts is NOT included! +]; +``` + +### NPM Scripts + +```json +{ + "test": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run", + "test:unit": "... --project unit ...", + "test:integration": "... --project integration ..." + // ❌ NO test:e2e script exists! +} +``` + +### CI/CD Status + +**`.gitea/workflows/deploy-to-test.yml` runs:** + +- ✅ `npm run test:unit -- --coverage` +- ✅ `npm run test:integration -- --coverage` +- ❌ E2E tests are NOT run in CI + +## Key Findings + +### 1. E2E Tests Are Orphaned + +- 11 E2E test files exist but are never executed +- E2E config file exists (`vitest.config.e2e.ts`) but is not referenced anywhere +- No npm script to run E2E tests +- Not included in vitest workspace +- Not run in CI/CD pipeline + +### 2. When Were E2E Tests Created? + +Git history shows E2E config was added in commit `e66027d` ("fix e2e and deploy to prod"), but: + +- It was never added to the workspace +- It was never added to CI +- No test:e2e script was created + +This suggests the E2E separation was **started but never completed**. + +### 3. How Are Tests Currently Run? + +**Locally:** + +- `npm test` → runs workspace (unit + integration only) +- `npm run test:unit` → runs only unit tests +- `npm run test:integration` → runs only integration tests +- E2E tests: **Not accessible via any command** + +**In CI:** + +- Only `test:unit` and `test:integration` are run +- E2E tests are never executed + +### 4. Port Allocation + +- Integration tests: Port 3099 +- E2E tests: Port 3098 (configured but never used) +- No conflicts if both run sequentially + +## E2E Test Files (11 total) + +1. `admin-authorization.e2e.test.ts` +2. `admin-dashboard.e2e.test.ts` +3. `auth.e2e.test.ts` +4. `budget-journey.e2e.test.ts` +5. `deals-journey.e2e.test.ts` ← Just fixed URL constraint issue +6. `error-reporting.e2e.test.ts` +7. `flyer-upload.e2e.test.ts` +8. `inventory-journey.e2e.test.ts` +9. `receipt-journey.e2e.test.ts` +10. `upc-journey.e2e.test.ts` +11. `user-journey.e2e.test.ts` + +## Problems to Solve + +### Immediate Issues + +1. **E2E tests are not running** - Code exists but is never executed +2. **No way to run E2E tests** - No npm script or CI job +3. **Coverage gaps** - E2E scenarios are untested in practice +4. **False sense of security** - Team may think E2E tests are running + +### Implementation Challenges + +#### 1. Adding E2E to Workspace + +**Option A: Add to workspace** + +```typescript +// vitest.workspace.ts +export default [ + 'vite.config.ts', + 'vitest.config.integration.ts', + 'vitest.config.e2e.ts', // ← Add this +]; +``` + +**Impact:** E2E tests would run with `npm test`, increasing test time significantly + +**Option B: Keep separate** + +- E2E remains outside workspace +- Requires explicit `npm run test:e2e` command +- CI would need separate step for E2E tests + +#### 2. Adding NPM Script + +```json +{ + "test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project e2e -c vitest.config.e2e.ts" +} +``` + +**Dependencies:** + +- Uses same global setup pattern as integration tests +- Requires server to be stopped first (like integration tests) +- Port 3098 must be available + +#### 3. CI/CD Integration + +**Add to `.gitea/workflows/deploy-to-test.yml`:** + +```yaml +- name: Run E2E Tests + run: | + npm run test:e2e -- --coverage \ + --reporter=verbose \ + --includeTaskLocation \ + --testTimeout=120000 \ + --silent=passed-only +``` + +**Questions:** + +- Should E2E run before or after integration tests? +- Should E2E failures block deployment? +- Should E2E have separate coverage reports? + +#### 4. Test Organization Questions + +- Are current "integration" tests actually E2E tests? +- Should some E2E tests be moved to integration? +- What's the distinction between integration and E2E in this project? + +#### 5. Coverage Implications + +- E2E tests have separate coverage directory: `.coverage/e2e` +- Integration tests: `.coverage/integration` +- How to merge coverage from all test types? +- Do we need combined coverage reports? + +## Recommended Approach + +### Phase 1: Quick Fix (Enable E2E Tests) + +1. ✅ Fix any failing E2E tests (like URL constraints) +2. Add `test:e2e` npm script +3. Document how to run E2E tests manually +4. Do NOT add to workspace yet (keep separate) + +### Phase 2: CI Integration + +1. Add E2E test step to `.gitea/workflows/deploy-to-test.yml` +2. Run after integration tests pass +3. Allow failures initially (monitor results) +4. Make blocking once stable + +### Phase 3: Optimize + +1. Review test categorization (integration vs E2E) +2. Consider adding to workspace if test time is acceptable +3. Merge coverage reports if needed +4. Document test strategy in testing docs + +## Next Steps + +1. **Create `test:e2e` script** in package.json +2. **Run E2E tests manually** to verify they work +3. **Fix any failing E2E tests** +4. **Document E2E testing** in TESTING.md +5. **Add to CI** once stable +6. **Consider workspace integration** after CI is stable + +## Questions for Team + +1. Why were E2E tests never fully integrated? +2. Should E2E tests run on every commit or separately? +3. What's the acceptable test time for local development? +4. Should we run E2E tests in parallel or sequentially with integration? + +## Related Files + +- `vitest.workspace.ts` - Workspace configuration +- `vitest.config.e2e.ts` - E2E test configuration +- `src/tests/setup/e2e-global-setup.ts` - E2E global setup +- `.gitea/workflows/deploy-to-test.yml` - CI pipeline +- `package.json` - NPM scripts diff --git a/package.json b/package.json index b39890d..6cb9919 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage", "test:unit": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts", "test:integration": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts", + "test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts", "format": "prettier --write .", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "type-check": "tsc --noEmit", diff --git a/src/services/db/receipt.db.test.ts b/src/services/db/receipt.db.test.ts index 50c5ade..0a8043e 100644 --- a/src/services/db/receipt.db.test.ts +++ b/src/services/db/receipt.db.test.ts @@ -78,7 +78,7 @@ describe('ReceiptRepository', () => { const receiptRow = { receipt_id: 2, user_id: 'user-1', - store_id: null, + store_location_id: null, receipt_image_url: '/uploads/receipts/receipt-2.jpg', transaction_date: null, total_amount_cents: null, diff --git a/src/tests/e2e/deals-journey.e2e.test.ts b/src/tests/e2e/deals-journey.e2e.test.ts index 55d97c6..073ec96 100644 --- a/src/tests/e2e/deals-journey.e2e.test.ts +++ b/src/tests/e2e/deals-journey.e2e.test.ts @@ -60,11 +60,13 @@ describe('E2E Deals and Price Tracking Journey', () => { await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1', [userId]); } - // Clean up flyer items + // Clean up flyer items (disable trigger to avoid issues with NULL master_item_id) if (createdFlyerIds.length > 0) { + await pool.query('ALTER TABLE public.flyer_items DISABLE TRIGGER ALL'); await pool.query('DELETE FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])', [ createdFlyerIds, ]); + await pool.query('ALTER TABLE public.flyer_items ENABLE TRIGGER ALL'); } // Clean up flyers @@ -166,7 +168,7 @@ describe('E2E Deals and Price Tracking Journey', () => { const flyer1Result = await pool.query( `INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, valid_from, valid_to, status) - VALUES ($1, 'e2e-flyer-1.jpg', '/uploads/flyers/e2e-flyer-1.jpg', '/uploads/flyers/e2e-flyer-1-icon.jpg', $2, $3, 'processed') + VALUES ($1, 'e2e-flyer-1.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-1.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-1-icon.jpg', $2, $3, 'processed') RETURNING flyer_id`, [store1Id, validFrom, validTo], ); @@ -175,7 +177,7 @@ describe('E2E Deals and Price Tracking Journey', () => { const flyer2Result = await pool.query( `INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, valid_from, valid_to, status) - VALUES ($1, 'e2e-flyer-2.jpg', '/uploads/flyers/e2e-flyer-2.jpg', '/uploads/flyers/e2e-flyer-2-icon.jpg', $2, $3, 'processed') + VALUES ($1, 'e2e-flyer-2.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-2.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-2-icon.jpg', $2, $3, 'processed') RETURNING flyer_id`, [store2Id, validFrom, validTo], ); @@ -184,26 +186,26 @@ describe('E2E Deals and Price Tracking Journey', () => { // Add items to flyers with prices (Store 1 - higher prices) await pool.query( - `INSERT INTO public.flyer_items (flyer_id, master_item_id, sale_price_cents, page_number) + `INSERT INTO public.flyer_items (flyer_id, master_item_id, price_in_cents, item, price_display, quantity) VALUES - ($1, $2, 599, 1), -- Milk at $5.99 - ($1, $3, 349, 1), -- Bread at $3.49 - ($1, $4, 1299, 2), -- Coffee at $12.99 - ($1, $5, 299, 2), -- Bananas at $2.99 - ($1, $6, 899, 3) -- Chicken at $8.99 + ($1, $2, 599, 'Milk', '$5.99', 'each'), -- Milk at $5.99 + ($1, $3, 349, 'Bread', '$3.49', 'each'), -- Bread at $3.49 + ($1, $4, 1299, 'Coffee', '$12.99', 'each'), -- Coffee at $12.99 + ($1, $5, 299, 'Bananas', '$2.99', 'lb'), -- Bananas at $2.99 + ($1, $6, 899, 'Chicken', '$8.99', 'lb') -- Chicken at $8.99 `, [flyer1Id, ...createdMasterItemIds], ); // Add items to flyers with prices (Store 2 - better prices) await pool.query( - `INSERT INTO public.flyer_items (flyer_id, master_item_id, sale_price_cents, page_number) + `INSERT INTO public.flyer_items (flyer_id, master_item_id, price_in_cents, item, price_display, quantity) VALUES - ($1, $2, 499, 1), -- Milk at $4.99 (BEST PRICE) - ($1, $3, 299, 1), -- Bread at $2.99 (BEST PRICE) - ($1, $4, 1099, 2), -- Coffee at $10.99 (BEST PRICE) - ($1, $5, 249, 2), -- Bananas at $2.49 (BEST PRICE) - ($1, $6, 799, 3) -- Chicken at $7.99 (BEST PRICE) + ($1, $2, 499, 'Milk', '$4.99', 'each'), -- Milk at $4.99 (BEST PRICE) + ($1, $3, 299, 'Bread', '$2.99', 'each'), -- Bread at $2.99 (BEST PRICE) + ($1, $4, 1099, 'Coffee', '$10.99', 'each'), -- Coffee at $10.99 (BEST PRICE) + ($1, $5, 249, 'Bananas', '$2.49', 'lb'), -- Bananas at $2.49 (BEST PRICE) + ($1, $6, 799, 'Chicken', '$7.99', 'lb') -- Chicken at $7.99 (BEST PRICE) `, [flyer2Id, ...createdMasterItemIds], ); @@ -214,7 +216,7 @@ describe('E2E Deals and Price Tracking Journey', () => { token: authToken, body: JSON.stringify({ itemName: 'E2E Milk 2%', - category: 'Dairy', + category: 'Dairy & Eggs', }), }); @@ -224,7 +226,7 @@ describe('E2E Deals and Price Tracking Journey', () => { // Add more items to watch list const itemsToWatch = [ - { itemName: 'E2E Bread White', category: 'Bakery' }, + { itemName: 'E2E Bread White', category: 'Bakery & Bread' }, { itemName: 'E2E Coffee Beans', category: 'Beverages' }, ]; @@ -252,7 +254,7 @@ describe('E2E Deals and Price Tracking Journey', () => { (item: { item_name: string }) => item.item_name === 'E2E Milk 2%', ); expect(watchedMilk).toBeDefined(); - expect(watchedMilk.category).toBe('Dairy'); + expect(watchedMilk.category).toBe('Dairy & Eggs'); // Step 6: Get best prices for watched items const bestPricesResponse = await authedFetch('/users/deals/best-watched-prices', {