diff --git a/.gitea/workflows/deploy-to-test.yml b/.gitea/workflows/deploy-to-test.yml index 7a5fd872..ca8772c5 100644 --- a/.gitea/workflows/deploy-to-test.yml +++ b/.gitea/workflows/deploy-to-test.yml @@ -142,6 +142,11 @@ jobs: echo "--- Running Integration Tests ---" npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true + echo "--- Running E2E Tests ---" + # Run E2E tests using the integration config (for DB setup) but output coverage separately. + # We increase timeout significantly (120s) for E2E flows that involve AI processing. + npx vitest run src/tests/e2e --config vitest.config.integration.ts --coverage --coverage.reportsDirectory=.coverage/e2e --reporter=verbose --testTimeout=120000 --no-file-parallelism || true + # Re-enable secret masking for subsequent steps. echo "::secret-masking::" @@ -156,6 +161,7 @@ jobs: echo "Checking for source coverage files..." ls -l .coverage/unit/coverage-final.json ls -l .coverage/integration/coverage-final.json + ls -l .coverage/e2e/coverage-final.json || echo "E2E coverage file not found" # --- V8 Coverage Processing for Backend Server --- # The integration tests start the server, which generates raw V8 coverage data. @@ -187,6 +193,7 @@ jobs: # We give them unique names to be safe, though it's not strictly necessary. cp .coverage/unit/coverage-final.json "$NYC_SOURCE_DIR/unit-coverage.json" cp .coverage/integration/coverage-final.json "$NYC_SOURCE_DIR/integration-coverage.json" + cp .coverage/e2e/coverage-final.json "$NYC_SOURCE_DIR/e2e-coverage.json" || echo "E2E coverage file not found, skipping." # This file might not exist if integration tests fail early, so we add `|| true` cp .coverage/integration-server/coverage-final.json "$NYC_SOURCE_DIR/integration-server-coverage.json" || echo "Server coverage file not found, skipping." echo "Copied coverage files to source directory. Contents:" diff --git a/src/routes/price.routes.test.ts b/src/routes/price.routes.test.ts index 146a84ab..e4849754 100644 --- a/src/routes/price.routes.test.ts +++ b/src/routes/price.routes.test.ts @@ -4,13 +4,130 @@ import supertest from 'supertest'; import { createTestApp } from '../tests/utils/createTestApp'; import { mockLogger } from '../tests/utils/mockLogger'; +// Mock the price repository +vi.mock('../services/db/price.db', () => ({ + priceRepo: { + getPriceHistory: vi.fn(), + }, +})); + +// Mock the logger to keep test output clean +vi.mock('../services/logger.server', () => ({ + logger: mockLogger, +})); + // Import the router AFTER other setup. import priceRouter from './price.routes'; +import { priceRepo } from '../services/db/price.db'; describe('Price Routes (/api/price-history)', () => { const app = createTestApp({ router: priceRouter, basePath: '/api/price-history' }); + // Add a basic error handler to capture errors passed to next(err) and return JSON. + app.use((err: any, req: any, res: any, next: any) => { + res.status(err.status || 500).json({ message: err.message, errors: err.errors }); + }); beforeEach(() => { vi.clearAllMocks(); }); - // The rest of the tests are unchanged. + + describe('POST /', () => { + it('should return 200 OK with price history data for a valid request', async () => { + const mockHistory = [ + { master_item_id: 1, price_in_cents: 199, date: '2024-01-01T00:00:00.000Z' }, + { master_item_id: 2, price_in_cents: 299, date: '2024-01-08T00:00:00.000Z' }, + ]; + vi.mocked(priceRepo.getPriceHistory).mockResolvedValue(mockHistory); + + const response = await supertest(app) + .post('/api/price-history') + .send({ masterItemIds: [1, 2] }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockHistory); + expect(priceRepo.getPriceHistory).toHaveBeenCalledWith([1, 2], expect.any(Object), 1000, 0); + }); + + it('should pass limit and offset from the body to the repository', async () => { + vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]); + await supertest(app) + .post('/api/price-history') + .send({ masterItemIds: [1, 2, 3], limit: 50, offset: 10 }); + + expect(priceRepo.getPriceHistory).toHaveBeenCalledWith( + [1, 2, 3], + expect.any(Object), + 50, + 10, + ); + }); + + it('should log the request info', async () => { + vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]); + await supertest(app) + .post('/api/price-history') + .send({ masterItemIds: [1, 2, 3], limit: 25, offset: 5 }); + + expect(mockLogger.info).toHaveBeenCalledWith( + { itemCount: 3, limit: 25, offset: 5 }, + '[API /price-history] Received request for historical price data.', + ); + }); + + it('should return 500 if the database call fails', async () => { + const dbError = new Error('Database connection failed'); + vi.mocked(priceRepo.getPriceHistory).mockRejectedValue(dbError); + + const response = await supertest(app) + .post('/api/price-history') + .send({ masterItemIds: [1, 2, 3] }); + + expect(response.status).toBe(500); + expect(response.body.message).toBe('Database connection failed'); + }); + + it('should return 400 if masterItemIds is an empty array', async () => { + const response = await supertest(app).post('/api/price-history').send({ masterItemIds: [] }); + + expect(response.status).toBe(400); + expect(response.body.errors[0].message).toBe( + 'masterItemIds must be a non-empty array of positive integers.', + ); + }); + + it('should return 400 if masterItemIds is not an array', async () => { + const response = await supertest(app) + .post('/api/price-history') + .send({ masterItemIds: 'not-an-array' }); + + expect(response.status).toBe(400); + expect(response.body.errors[0].message).toBe('Expected array, received string'); + }); + + it('should return 400 if masterItemIds contains non-positive integers', async () => { + const response = await supertest(app) + .post('/api/price-history') + .send({ masterItemIds: [1, -2, 3] }); + + expect(response.status).toBe(400); + expect(response.body.errors[0].message).toBe('Number must be greater than 0'); + }); + + it('should return 400 if masterItemIds is missing', async () => { + const response = await supertest(app).post('/api/price-history').send({}); + + expect(response.status).toBe(400); + expect(response.body.errors[0].message).toBe('Required'); + }); + + it('should return 400 for invalid limit and offset', async () => { + const response = await supertest(app) + .post('/api/price-history') + .send({ masterItemIds: [1], limit: -1, offset: 'abc' }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveLength(2); + expect(response.body.errors[0].message).toBe('Number must be greater than 0'); + expect(response.body.errors[1].message).toContain('Expected number, received string'); + }); + }); }); diff --git a/src/routes/price.routes.ts b/src/routes/price.routes.ts index 3b736687..5d30ee35 100644 --- a/src/routes/price.routes.ts +++ b/src/routes/price.routes.ts @@ -1,7 +1,8 @@ // src/routes/price.routes.ts -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { validateRequest } from '../middleware/validation.middleware'; +import { priceRepo } from '../services/db/price.db'; const router = Router(); @@ -10,6 +11,8 @@ const priceHistorySchema = z.object({ masterItemIds: z.array(z.number().int().positive()).nonempty({ message: 'masterItemIds must be a non-empty array of positive integers.', }), + limit: z.coerce.number().int().positive().optional().default(1000), + offset: z.coerce.number().int().nonnegative().optional().default(0), }), }); @@ -18,18 +21,23 @@ type PriceHistoryRequest = z.infer; /** * POST /api/price-history - Fetches historical price data for a given list of master item IDs. - * This is a placeholder implementation. + * This endpoint retrieves price points over time for specified master grocery items. */ -router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response) => { +router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response, next: NextFunction) => { // Cast 'req' to the inferred type for full type safety. const { - body: { masterItemIds }, + body: { masterItemIds, limit, offset }, } = req as unknown as PriceHistoryRequest; req.log.info( - { itemCount: masterItemIds.length }, + { itemCount: masterItemIds.length, limit, offset }, '[API /price-history] Received request for historical price data.', ); - res.status(200).json([]); + try { + const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset); + res.status(200).json(priceHistory); + } catch (error) { + next(error); + } }); export default router; diff --git a/src/services/db/price.db.ts b/src/services/db/price.db.ts new file mode 100644 index 00000000..b93b6572 --- /dev/null +++ b/src/services/db/price.db.ts @@ -0,0 +1,53 @@ +// src/services/db/price.db.ts +import type { Logger } from 'pino'; +import type { PriceHistoryData } from '../../types'; +import { getPool } from './connection.db'; + +/** + * Repository for fetching price-related data. + */ +export const priceRepo = { + /** + * Fetches the historical price data for a given list of master item IDs. + * It retrieves the price in cents and the start date of the flyer for each item. + * + * @param masterItemIds An array of master grocery item IDs. + * @param logger The pino logger instance. + * @param limit The maximum number of records to return. + * @param offset The number of records to skip. + * @returns A promise that resolves to an array of price history data points. + */ + async getPriceHistory( + masterItemIds: number[], + logger: Logger, + limit: number = 1000, + offset: number = 0, + ): Promise { + if (masterItemIds.length === 0) { + return []; + } + + const query = ` + SELECT + fi.master_item_id, + fi.price_in_cents, + f.valid_from AS date + FROM public.flyer_items fi + JOIN public.flyers f ON fi.flyer_id = f.flyer_id + WHERE + fi.master_item_id = ANY($1::int[]) + AND f.valid_from IS NOT NULL + AND fi.price_in_cents IS NOT NULL + ORDER BY + fi.master_item_id, f.valid_from ASC + LIMIT $2 OFFSET $3; + `; + + const result = await getPool().query(query, [masterItemIds, limit, offset]); + logger.debug( + { count: result.rows.length, itemIds: masterItemIds.length, limit, offset }, + 'Fetched price history from database.', + ); + return result.rows; + }, +}; \ No newline at end of file diff --git a/src/tests/e2e/admin-dashboard.e2e.test.ts b/src/tests/e2e/admin-dashboard.e2e.test.ts new file mode 100644 index 00000000..81e7beae --- /dev/null +++ b/src/tests/e2e/admin-dashboard.e2e.test.ts @@ -0,0 +1,96 @@ +// src/tests/e2e/admin-dashboard.e2e.test.ts +import { describe, it, expect, afterAll } from 'vitest'; +import supertest from 'supertest'; +import app from '../../../server'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +const request = supertest(app); + +describe('E2E Admin Dashboard Flow', () => { + // Use a unique email for every run to avoid collisions + const uniqueId = Date.now(); + const adminEmail = `e2e-admin-${uniqueId}@example.com`; + const adminPassword = 'StrongPassword123!'; + + let authToken: string; + let adminUserId: string | null = null; + + afterAll(async () => { + // Safety cleanup: Ensure the user is deleted from the DB if the test fails mid-way. + if (adminUserId) { + try { + await getPool().query('DELETE FROM public.users WHERE user_id = $1', [adminUserId]); + } catch (err) { + console.error('Error cleaning up E2E admin user:', err); + } + } + }); + + it('should allow an admin to log in and access dashboard features', async () => { + // 1. Register a new user (initially a regular user) + const registerResponse = await request.post('/api/auth/register').send({ + email: adminEmail, + password: adminPassword, + full_name: 'E2E Admin User', + }); + + expect(registerResponse.status).toBe(201); + const registeredUser = registerResponse.body.userprofile.user; + adminUserId = registeredUser.user_id; + expect(adminUserId).toBeDefined(); + + // 2. Promote the user to 'admin' via direct DB access + // (This simulates an existing admin or a manual promotion, as there is no public "register as admin" endpoint) + await getPool().query(`UPDATE public.profiles SET role = 'admin' WHERE user_id = $1`, [ + adminUserId, + ]); + + // 3. Login to get the access token (now with admin privileges) + const loginResponse = await request.post('/api/auth/login').send({ + email: adminEmail, + password: adminPassword, + }); + + expect(loginResponse.status).toBe(200); + authToken = loginResponse.body.token; + expect(authToken).toBeDefined(); + // Verify the role returned in the login response is now 'admin' + expect(loginResponse.body.userprofile.role).toBe('admin'); + + // 4. Fetch System Stats (Protected Admin Route) + const statsResponse = await request + .get('/api/admin/stats') + .set('Authorization', `Bearer ${authToken}`); + + expect(statsResponse.status).toBe(200); + expect(statsResponse.body).toHaveProperty('userCount'); + expect(statsResponse.body).toHaveProperty('flyerCount'); + + // 5. Fetch User List (Protected Admin Route) + const usersResponse = await request + .get('/api/admin/users') + .set('Authorization', `Bearer ${authToken}`); + + expect(usersResponse.status).toBe(200); + expect(Array.isArray(usersResponse.body)).toBe(true); + // The list should contain the admin user we just created + const self = usersResponse.body.find((u: any) => u.user_id === adminUserId); + expect(self).toBeDefined(); + + // 6. Check Queue Status (Protected Admin Route) + const queueResponse = await request + .get('/api/admin/queues/status') + .set('Authorization', `Bearer ${authToken}`); + + expect(queueResponse.status).toBe(200); + expect(Array.isArray(queueResponse.body)).toBe(true); + // Verify that the 'flyer-processing' queue is present in the status report + const flyerQueue = queueResponse.body.find((q: any) => q.name === 'flyer-processing'); + expect(flyerQueue).toBeDefined(); + expect(flyerQueue.counts).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/src/tests/e2e/flyer-upload.e2e.test.ts b/src/tests/e2e/flyer-upload.e2e.test.ts new file mode 100644 index 00000000..eba8298b --- /dev/null +++ b/src/tests/e2e/flyer-upload.e2e.test.ts @@ -0,0 +1,110 @@ +// src/tests/e2e/flyer-upload.e2e.test.ts +import { describe, it, expect, afterAll } from 'vitest'; +import supertest from 'supertest'; +import app from '../../../server'; +import { getPool } from '../../services/db/connection.db'; +import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs'; + +/** + * @vitest-environment node + */ + +const request = supertest(app); + +describe('E2E Flyer Upload and Processing Workflow', () => { + const uniqueId = Date.now(); + const userEmail = `e2e-uploader-${uniqueId}@example.com`; + const userPassword = 'StrongPassword123!'; + + let authToken: string; + let userId: string | null = null; + let flyerId: number | null = null; + + afterAll(async () => { + // Cleanup: Delete the flyer and user created during the test + const pool = getPool(); + if (flyerId) { + await pool.query('DELETE FROM public.flyers WHERE flyer_id = $1', [flyerId]); + } + if (userId) { + await pool.query('DELETE FROM public.users WHERE user_id = $1', [userId]); + } + }); + + it('should allow a user to upload a flyer and wait for processing to complete', async () => { + // 1. Register a new user + const registerResponse = await request.post('/api/auth/register').send({ + email: userEmail, + password: userPassword, + full_name: 'E2E Flyer Uploader', + }); + expect(registerResponse.status).toBe(201); + + // 2. Login to get the access token + const loginResponse = await request.post('/api/auth/login').send({ + email: userEmail, + password: userPassword, + }); + expect(loginResponse.status).toBe(200); + authToken = loginResponse.body.token; + userId = loginResponse.body.userprofile.user.user_id; + expect(authToken).toBeDefined(); + + // 3. Prepare the flyer file + // We try to use the existing test asset if available, otherwise create a dummy buffer. + // Note: In a real E2E scenario against a live AI service, a valid image is required. + // If the AI service is mocked or stubbed in this environment, a dummy buffer might suffice. + let fileBuffer: Buffer; + let fileName = `e2e-test-flyer-${uniqueId}.jpg`; + + const assetPath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); + if (fs.existsSync(assetPath)) { + const rawBuffer = fs.readFileSync(assetPath); + // Append unique ID to ensure unique checksum for every test run + fileBuffer = Buffer.concat([rawBuffer, Buffer.from(uniqueId.toString())]); + } else { + // Fallback to a minimal valid JPEG header + random data if asset is missing + // (This might fail if the backend does strict image validation/processing) + fileBuffer = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), // JPEG Start of Image + Buffer.from(uniqueId.toString()) + ]); + } + + // Calculate checksum (required by the API) + const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + + // 4. Upload the flyer + const uploadResponse = await request + .post('/api/ai/upload-and-process') + .set('Authorization', `Bearer ${authToken}`) + .field('checksum', checksum) + .attach('flyerFile', fileBuffer, fileName); + + expect(uploadResponse.status).toBe(202); + const jobId = uploadResponse.body.jobId; + expect(jobId).toBeDefined(); + + // 5. Poll for job completion + let jobStatus; + const maxRetries = 30; // Poll for up to 90 seconds + for (let i = 0; i < maxRetries; i++) { + await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3s + + const statusResponse = await request + .get(`/api/ai/jobs/${jobId}/status`) + .set('Authorization', `Bearer ${authToken}`); + + jobStatus = statusResponse.body; + if (jobStatus.state === 'completed' || jobStatus.state === 'failed') { + break; + } + } + + expect(jobStatus.state).toBe('completed'); + flyerId = jobStatus.returnValue?.flyerId; + expect(flyerId).toBeTypeOf('number'); + }, 120000); // Extended timeout for AI processing +}); \ No newline at end of file diff --git a/src/tests/e2e/user-journey.e2e.test.ts b/src/tests/e2e/user-journey.e2e.test.ts new file mode 100644 index 00000000..68aa8939 --- /dev/null +++ b/src/tests/e2e/user-journey.e2e.test.ts @@ -0,0 +1,111 @@ +// src/tests/e2e/user-journey.e2e.test.ts +import { describe, it, expect, afterAll } from 'vitest'; +import supertest from 'supertest'; +import app from '../../../server'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +const request = supertest(app); + +describe('E2E User Journey', () => { + // Use a unique email for every run to avoid collisions + const uniqueId = Date.now(); + const userEmail = `e2e-test-${uniqueId}@example.com`; + const userPassword = 'StrongPassword123!'; + + let authToken: string; + let userId: string | null = null; + let shoppingListId: number; + + afterAll(async () => { + // Safety cleanup: Ensure the user is deleted from the DB if the test fails mid-way. + // If the test succeeds, the user deletes their own account, so this acts as a fallback. + if (userId) { + try { + await getPool().query('DELETE FROM public.users WHERE user_id = $1', [userId]); + } catch (err) { + console.error('Error cleaning up E2E test user:', err); + } + } + }); + + it('should complete a full user lifecycle: Register -> Login -> Manage List -> Delete Account', async () => { + // 1. Register a new user + const registerResponse = await request.post('/api/auth/register').send({ + email: userEmail, + password: userPassword, + full_name: 'E2E Traveler', + }); + + expect(registerResponse.status).toBe(201); + expect(registerResponse.body.message).toBe('User registered successfully!'); + + // 2. Login to get the access token + const loginResponse = await request.post('/api/auth/login').send({ + email: userEmail, + password: userPassword, + }); + + expect(loginResponse.status).toBe(200); + authToken = loginResponse.body.token; + userId = loginResponse.body.userprofile.user.user_id; + + expect(authToken).toBeDefined(); + expect(userId).toBeDefined(); + + // 3. Create a Shopping List + const createListResponse = await request + .post('/api/users/shopping-lists') + .set('Authorization', `Bearer ${authToken}`) + .send({ name: 'E2E Party List' }); + + expect(createListResponse.status).toBe(201); + shoppingListId = createListResponse.body.shopping_list_id; + expect(shoppingListId).toBeDefined(); + + // 4. Add an item to the list + const addItemResponse = await request + .post(`/api/users/shopping-lists/${shoppingListId}/items`) + .set('Authorization', `Bearer ${authToken}`) + .send({ customItemName: 'Chips' }); + + expect(addItemResponse.status).toBe(201); + expect(addItemResponse.body.custom_item_name).toBe('Chips'); + + // 5. Verify the list and item exist via GET + const getListsResponse = await request + .get('/api/users/shopping-lists') + .set('Authorization', `Bearer ${authToken}`); + + expect(getListsResponse.status).toBe(200); + const myLists = getListsResponse.body; + const targetList = myLists.find((l: any) => l.shopping_list_id === shoppingListId); + + expect(targetList).toBeDefined(); + expect(targetList.items).toHaveLength(1); + expect(targetList.items[0].custom_item_name).toBe('Chips'); + + // 6. Delete the User Account (Self-Service) + const deleteAccountResponse = await request + .delete('/api/users/account') + .set('Authorization', `Bearer ${authToken}`) + .send({ password: userPassword }); + + expect(deleteAccountResponse.status).toBe(200); + expect(deleteAccountResponse.body.message).toBe('Account deleted successfully.'); + + // 7. Verify Login is no longer possible + const failLoginResponse = await request.post('/api/auth/login').send({ + email: userEmail, + password: userPassword, + }); + + expect(failLoginResponse.status).toBe(401); + + // Mark userId as null so afterAll doesn't attempt to delete it again + userId = null; + }); +}); diff --git a/src/tests/integration/price.integration.test.ts b/src/tests/integration/price.integration.test.ts new file mode 100644 index 00000000..cb8f8627 --- /dev/null +++ b/src/tests/integration/price.integration.test.ts @@ -0,0 +1,141 @@ +// src/tests/integration/price.integration.test.ts +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import supertest from 'supertest'; +import app from '../../../server'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +const request = supertest(app); + +describe('Price History API Integration Test (/api/price-history)', () => { + let masterItemId: number; + let storeId: number; + let flyerId1: number; + let flyerId2: number; + let flyerId3: number; + + beforeAll(async () => { + const pool = getPool(); + + // 1. Create a master grocery item + const masterItemRes = await pool.query( + `INSERT INTO public.master_grocery_items (name, category_id) VALUES ('Integration Test Apples', (SELECT category_id FROM categories WHERE name = 'Fruits & Vegetables' LIMIT 1)) RETURNING master_grocery_item_id`, + ); + masterItemId = masterItemRes.rows[0].master_grocery_item_id; + + // 2. Create a store + const storeRes = await pool.query( + `INSERT INTO public.stores (name) VALUES ('Integration Price Test Store') RETURNING store_id`, + ); + storeId = storeRes.rows[0].store_id; + + // 3. Create two flyers with different dates + const flyerRes1 = await pool.query( + `INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from) + VALUES ($1, 'price-test-1.jpg', 'http://test.com/price-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`, + [storeId, `checksum-price-1-${Date.now()}`], + ); + flyerId1 = flyerRes1.rows[0].flyer_id; + + const flyerRes2 = await pool.query( + `INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from) + VALUES ($1, 'price-test-2.jpg', 'http://test.com/price-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`, + [storeId, `checksum-price-2-${Date.now()}`], + ); + flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed. + + const flyerRes3 = await pool.query( + `INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from) + VALUES ($1, 'price-test-3.jpg', 'http://test.com/price-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`, + [storeId, `checksum-price-3-${Date.now()}`], + ); + flyerId3 = flyerRes3.rows[0].flyer_id; + + // 4. Create flyer items linking the master item to the flyers with prices + await pool.query( + `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display) VALUES ($1, $2, 'Apples', 199, '$1.99')`, + [flyerId1, masterItemId], + ); + await pool.query( + `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display) VALUES ($1, $2, 'Apples', 249, '$2.49')`, + [flyerId2, masterItemId], + ); + await pool.query( + `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display) VALUES ($1, $2, 'Apples', 299, '$2.99')`, + [flyerId3, masterItemId], + ); + }); + + afterAll(async () => { + const pool = getPool(); + // The CASCADE on the tables should handle flyer_items. + // We just need to delete the flyers, store, and master item. + const flyerIds = [flyerId1, flyerId2, flyerId3].filter(Boolean); + if (flyerIds.length > 0) { + await pool.query('DELETE FROM public.flyers WHERE flyer_id = ANY($1::int[])', [flyerIds]); + } + if (storeId) await pool.query('DELETE FROM public.stores WHERE store_id = $1', [storeId]); + if (masterItemId) + await pool.query('DELETE FROM public.master_grocery_items WHERE master_grocery_item_id = $1', [ + masterItemId, + ]); + }); + + it('should return the correct price history for a given master item ID', async () => { + const response = await request.post('/api/price-history').send({ masterItemIds: [masterItemId] }); + + expect(response.status).toBe(200); + expect(response.body).toBeInstanceOf(Array); + expect(response.body).toHaveLength(3); + + expect(response.body[0]).toMatchObject({ master_item_id: masterItemId, price_in_cents: 199 }); + expect(response.body[1]).toMatchObject({ master_item_id: masterItemId, price_in_cents: 249 }); + expect(response.body[2]).toMatchObject({ master_item_id: masterItemId, price_in_cents: 299 }); + }); + + it('should respect the limit parameter', async () => { + const response = await request + .post('/api/price-history') + .send({ masterItemIds: [masterItemId], limit: 2 }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0].price_in_cents).toBe(199); + expect(response.body[1].price_in_cents).toBe(249); + }); + + it('should respect the offset parameter', async () => { + const response = await request + .post('/api/price-history') + .send({ masterItemIds: [masterItemId], limit: 2, offset: 1 }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0].price_in_cents).toBe(249); + expect(response.body[1].price_in_cents).toBe(299); + }); + + it('should return price history sorted by date in ascending order', async () => { + const response = await request.post('/api/price-history').send({ masterItemIds: [masterItemId] }); + + expect(response.status).toBe(200); + const history = response.body; + expect(history).toHaveLength(3); + + const date1 = new Date(history[0].date).getTime(); + const date2 = new Date(history[1].date).getTime(); + const date3 = new Date(history[2].date).getTime(); + + expect(date1).toBeLessThan(date2); + expect(date2).toBeLessThan(date3); + }); + + it('should return an empty array for a master item ID with no price history', async () => { + const response = await request.post('/api/price-history').send({ masterItemIds: [999999] }); + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 9d7d92c2..66dfa660 100644 --- a/src/types.ts +++ b/src/types.ts @@ -955,3 +955,9 @@ export interface AdminUserView { full_name: string | null; avatar_url: string | null; } + +export interface PriceHistoryData { + master_item_id: number; + price_in_cents: number; + date: string; // ISO date string +}