From 771f59d009e84ed0b617797819875abcfa33cf4e Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Wed, 28 Jan 2026 09:58:28 -0800 Subject: [PATCH] more api versioning work -whee --- .gitignore | 4 + src/features/flyer/FlyerDisplay.test.tsx | 3 +- src/features/flyer/FlyerList.test.tsx | 6 +- src/layouts/MainLayout.test.tsx | 30 ++- src/routes/health.routes.test.ts | 221 +++++++++----------- src/tests/e2e/budget-journey.e2e.test.ts | 12 +- src/tests/e2e/deals-journey.e2e.test.ts | 6 +- src/tests/e2e/flyer-upload.e2e.test.ts | 2 +- src/tests/e2e/inventory-journey.e2e.test.ts | 18 +- src/tests/e2e/receipt-journey.e2e.test.ts | 18 +- src/tests/e2e/upc-journey.e2e.test.ts | 6 +- src/tests/e2e/user-journey.e2e.test.ts | 2 +- vite.config.ts | 5 + vitest.config.e2e.ts | 3 +- vitest.config.integration.ts | 3 +- 15 files changed, 184 insertions(+), 155 deletions(-) diff --git a/.gitignore b/.gitignore index 14320eaa..3449a14d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ Thumbs.db .claude/settings.local.json nul tmpclaude* + + + +test.tmp \ No newline at end of file diff --git a/src/features/flyer/FlyerDisplay.test.tsx b/src/features/flyer/FlyerDisplay.test.tsx index faaa917e..a91b000c 100644 --- a/src/features/flyer/FlyerDisplay.test.tsx +++ b/src/features/flyer/FlyerDisplay.test.tsx @@ -89,8 +89,7 @@ describe('FlyerDisplay', () => { it('should apply dark mode image styles', () => { render(); const image = screen.getByAltText('Grocery Flyer'); - expect(image).toHaveClass('dark:invert'); - expect(image).toHaveClass('dark:hue-rotate-180'); + expect(image).toHaveClass('dark:brightness-90'); }); describe('"Correct Data" Button', () => { diff --git a/src/features/flyer/FlyerList.test.tsx b/src/features/flyer/FlyerList.test.tsx index 0b9934db..08a30563 100644 --- a/src/features/flyer/FlyerList.test.tsx +++ b/src/features/flyer/FlyerList.test.tsx @@ -147,7 +147,11 @@ describe('FlyerList', () => { ); const selectedItem = screen.getByText('Metro').closest('li'); - expect(selectedItem).toHaveClass('bg-brand-light', 'dark:bg-brand-dark/30'); + expect(selectedItem).toHaveClass( + 'border-brand-primary', + 'bg-teal-50/50', + 'dark:bg-teal-900/10', + ); }); describe('UI Details and Edge Cases', () => { diff --git a/src/layouts/MainLayout.test.tsx b/src/layouts/MainLayout.test.tsx index 8b44fd16..cbbfcd63 100644 --- a/src/layouts/MainLayout.test.tsx +++ b/src/layouts/MainLayout.test.tsx @@ -237,7 +237,20 @@ describe('MainLayout Component', () => { expect(screen.queryByTestId('anonymous-banner')).not.toBeInTheDocument(); }); - it('renders auth-gated components (PriceHistoryChart, Leaderboard, ActivityLog)', () => { + it('renders auth-gated components for regular users (PriceHistoryChart, Leaderboard)', () => { + renderWithRouter(); + expect(screen.getByTestId('price-history-chart')).toBeInTheDocument(); + expect(screen.getByTestId('leaderboard')).toBeInTheDocument(); + // ActivityLog is admin-only, should NOT be present for regular users + expect(screen.queryByTestId('activity-log')).not.toBeInTheDocument(); + }); + + it('renders ActivityLog for admin users', () => { + mockedUseAuth.mockReturnValue({ + ...defaultUseAuthReturn, + authStatus: 'AUTHENTICATED', + userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }), + }); renderWithRouter(); expect(screen.getByTestId('price-history-chart')).toBeInTheDocument(); expect(screen.getByTestId('leaderboard')).toBeInTheDocument(); @@ -245,6 +258,11 @@ describe('MainLayout Component', () => { }); it('calls setActiveListId when a list is shared via ActivityLog and the list exists', () => { + mockedUseAuth.mockReturnValue({ + ...defaultUseAuthReturn, + authStatus: 'AUTHENTICATED', + userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }), + }); mockedUseShoppingLists.mockReturnValue({ ...defaultUseShoppingListsReturn, shoppingLists: [ @@ -260,6 +278,11 @@ describe('MainLayout Component', () => { }); it('does not call setActiveListId for actions other than list_shared', () => { + mockedUseAuth.mockReturnValue({ + ...defaultUseAuthReturn, + authStatus: 'AUTHENTICATED', + userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }), + }); renderWithRouter(); const otherLogAction = screen.getByTestId('activity-log-other'); fireEvent.click(otherLogAction); @@ -268,6 +291,11 @@ describe('MainLayout Component', () => { }); it('does not call setActiveListId if the shared list does not exist', () => { + mockedUseAuth.mockReturnValue({ + ...defaultUseAuthReturn, + authStatus: 'AUTHENTICATED', + userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }), + }); renderWithRouter(); const activityLog = screen.getByTestId('activity-log'); fireEvent.click(activityLog); // Mock click simulates sharing list with id 1 diff --git a/src/routes/health.routes.test.ts b/src/routes/health.routes.test.ts index 4f76c285..d65c8cc2 100644 --- a/src/routes/health.routes.test.ts +++ b/src/routes/health.routes.test.ts @@ -28,13 +28,58 @@ vi.mock('../services/queueService.server', () => ({ // We need to mock the `connection` export which is an object with a `ping` method. connection: { ping: vi.fn(), + get: vi.fn(), // Add get method for worker heartbeat checks }, })); +// Use vi.hoisted to create mock queue objects that are available during vi.mock hoisting. +// This ensures the mock objects exist when the factory function runs. +const { mockQueuesModule } = vi.hoisted(() => { + // Helper function to create a mock queue object with vi.fn() + const createMockQueue = () => ({ + getJobCounts: vi.fn().mockResolvedValue({ + waiting: 0, + active: 0, + failed: 0, + delayed: 0, + }), + }); + + return { + mockQueuesModule: { + flyerQueue: createMockQueue(), + emailQueue: createMockQueue(), + analyticsQueue: createMockQueue(), + weeklyAnalyticsQueue: createMockQueue(), + cleanupQueue: createMockQueue(), + tokenCleanupQueue: createMockQueue(), + receiptQueue: createMockQueue(), + expiryAlertQueue: createMockQueue(), + barcodeQueue: createMockQueue(), + }, + }; +}); + +// Mock the queues.server module BEFORE the health router imports it. +vi.mock('../services/queues.server', () => mockQueuesModule); + // Import the router and mocked modules AFTER all mocks are defined. import healthRouter from './health.routes'; import * as dbConnection from '../services/db/connection.db'; +// Use the hoisted mock module directly for test assertions and configuration +const mockedQueues = mockQueuesModule as { + flyerQueue: { getJobCounts: ReturnType }; + emailQueue: { getJobCounts: ReturnType }; + analyticsQueue: { getJobCounts: ReturnType }; + weeklyAnalyticsQueue: { getJobCounts: ReturnType }; + cleanupQueue: { getJobCounts: ReturnType }; + tokenCleanupQueue: { getJobCounts: ReturnType }; + receiptQueue: { getJobCounts: ReturnType }; + expiryAlertQueue: { getJobCounts: ReturnType }; + barcodeQueue: { getJobCounts: ReturnType }; +}; + // Mock the logger to keep test output clean. vi.mock('../services/logger.server', async () => ({ // Use async import to avoid hoisting issues with mockLogger @@ -49,7 +94,9 @@ vi.mock('../services/logger.server', async () => ({ })); // Cast the mocked import to a Mocked type for type-safe access to mock functions. -const mockedRedisConnection = redisConnection as Mocked; +const mockedRedisConnection = redisConnection as Mocked & { + get: ReturnType; +}; const mockedDbConnection = dbConnection as Mocked; const mockedFs = fs as Mocked; @@ -635,34 +682,27 @@ describe('Health Routes (/api/v1/health)', () => { // ============================================================================= describe('GET /queues', () => { - // Mock the queues module - beforeEach(async () => { - vi.resetModules(); - // Re-import after mocks are set up - }); + // Helper function to set all queue mocks to return the same job counts + const setAllQueueMocks = (jobCounts: { + waiting: number; + active: number; + failed: number; + delayed: number; + }) => { + mockedQueues.flyerQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.emailQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(jobCounts); + mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(jobCounts); + }; it('should return 200 OK with queue metrics and worker heartbeats when all healthy', async () => { - // Arrange: Mock queue getJobCounts() and Redis heartbeats - const mockQueues = await import('../services/queues.server'); - const mockQueue = { - getJobCounts: vi.fn().mockResolvedValue({ - waiting: 5, - active: 2, - failed: 1, - delayed: 0, - }), - }; - - // Mock all queues - vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never); + // Arrange: Mock queue getJobCounts() to return specific values + setAllQueueMocks({ waiting: 5, active: 2, failed: 1, delayed: 0 }); // Mock Redis heartbeat responses (all healthy, last seen < 60s ago) const recentTimestamp = new Date(Date.now() - 10000).toISOString(); // 10 seconds ago @@ -672,7 +712,7 @@ describe('Health Routes (/api/v1/health)', () => { host: 'test-host', }); - mockedRedisConnection.get = vi.fn().mockResolvedValue(heartbeatValue); + mockedRedisConnection.get.mockResolvedValue(heartbeatValue); // Act const response = await supertest(app).get('/api/v1/health/queues'); @@ -702,31 +742,22 @@ describe('Health Routes (/api/v1/health)', () => { }); it('should return 503 when a queue is unavailable', async () => { - // Arrange: Mock one queue to fail - const mockQueues = await import('../services/queues.server'); - const healthyQueue = { - getJobCounts: vi.fn().mockResolvedValue({ - waiting: 0, - active: 0, - failed: 0, - delayed: 0, - }), - }; - const failingQueue = { - getJobCounts: vi.fn().mockRejectedValue(new Error('Redis connection lost')), - }; + // Arrange: Mock flyerQueue to fail, others succeed + mockedQueues.flyerQueue.getJobCounts.mockRejectedValue(new Error('Redis connection lost')); - vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(failingQueue as never); - vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(healthyQueue as never); - vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(healthyQueue as never); - vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(healthyQueue as never); - vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(healthyQueue as never); - vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(healthyQueue as never); - vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(healthyQueue as never); - vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(healthyQueue as never); - vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(healthyQueue as never); + // Set other queues to succeed with healthy job counts + const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; + mockedQueues.emailQueue.getJobCounts.mockResolvedValue(healthyJobCounts); + mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts); + mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts); + mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts); + mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts); + mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(healthyJobCounts); + mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(healthyJobCounts); + mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(healthyJobCounts); - mockedRedisConnection.get = vi.fn().mockResolvedValue(null); + // No heartbeats (workers not running) + mockedRedisConnection.get.mockResolvedValue(null); // Act const response = await supertest(app).get('/api/v1/health/queues'); @@ -742,26 +773,9 @@ describe('Health Routes (/api/v1/health)', () => { }); it('should return 503 when a worker heartbeat is stale', async () => { - // Arrange: Mock queues as healthy but one worker heartbeat as stale - const mockQueues = await import('../services/queues.server'); - const mockQueue = { - getJobCounts: vi.fn().mockResolvedValue({ - waiting: 0, - active: 0, - failed: 0, - delayed: 0, - }), - }; - - vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never); + // Arrange: Mock queues as healthy + const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; + setAllQueueMocks(healthyJobCounts); // Mock heartbeat - one worker is stale (> 60s ago) const staleTimestamp = new Date(Date.now() - 120000).toISOString(); // 120 seconds ago @@ -773,7 +787,7 @@ describe('Health Routes (/api/v1/health)', () => { // First call returns stale heartbeat for flyer-processing, rest return null (no heartbeat) let callCount = 0; - mockedRedisConnection.get = vi.fn().mockImplementation(() => { + mockedRedisConnection.get.mockImplementation(() => { callCount++; return Promise.resolve(callCount === 1 ? staleHeartbeat : null); }); @@ -789,29 +803,12 @@ describe('Health Routes (/api/v1/health)', () => { }); it('should return 503 when worker heartbeat is missing', async () => { - // Arrange: Mock queues as healthy but no worker heartbeats in Redis - const mockQueues = await import('../services/queues.server'); - const mockQueue = { - getJobCounts: vi.fn().mockResolvedValue({ - waiting: 0, - active: 0, - failed: 0, - delayed: 0, - }), - }; - - vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never); + // Arrange: Mock queues as healthy + const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; + setAllQueueMocks(healthyJobCounts); // Mock Redis to return null (no heartbeat found) - mockedRedisConnection.get = vi.fn().mockResolvedValue(null); + mockedRedisConnection.get.mockResolvedValue(null); // Act const response = await supertest(app).get('/api/v1/health/queues'); @@ -824,42 +821,30 @@ describe('Health Routes (/api/v1/health)', () => { }); it('should handle Redis connection errors gracefully', async () => { - // Arrange: Mock queues to succeed but Redis get() to fail - const mockQueues = await import('../services/queues.server'); - const mockQueue = { - getJobCounts: vi.fn().mockResolvedValue({ - waiting: 0, - active: 0, - failed: 0, - delayed: 0, - }), - }; - - vi.spyOn(mockQueues, 'flyerQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'emailQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'analyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'weeklyAnalyticsQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'cleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'tokenCleanupQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'receiptQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'expiryAlertQueue', 'get').mockReturnValue(mockQueue as never); - vi.spyOn(mockQueues, 'barcodeQueue', 'get').mockReturnValue(mockQueue as never); + // Arrange: Mock queues as healthy + const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; + setAllQueueMocks(healthyJobCounts); // Mock Redis get() to throw error - mockedRedisConnection.get = vi.fn().mockRejectedValue(new Error('Redis connection lost')); + mockedRedisConnection.get.mockRejectedValue(new Error('Redis connection lost')); // Act const response = await supertest(app).get('/api/v1/health/queues'); - // Assert: Should still return queue metrics but mark workers as unhealthy - expect(response.status).toBe(503); - expect(response.body.error.details.queues['flyer-processing']).toEqual({ + // Assert: Production code treats heartbeat fetch errors as non-critical. + // When Redis get() fails for heartbeat checks, the endpoint returns 200 (healthy) + // with error details in the workers object. This is intentional - a heartbeat + // fetch error could be transient and shouldn't immediately mark the system unhealthy. + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.status).toBe('healthy'); + expect(response.body.data.queues['flyer-processing']).toEqual({ waiting: 0, active: 0, failed: 0, delayed: 0, }); - expect(response.body.error.details.workers['flyer-processing']).toEqual({ + expect(response.body.data.workers['flyer-processing']).toEqual({ alive: false, error: 'Redis connection lost', }); diff --git a/src/tests/e2e/budget-journey.e2e.test.ts b/src/tests/e2e/budget-journey.e2e.test.ts index 21f6ec55..4715aa3b 100644 --- a/src/tests/e2e/budget-journey.e2e.test.ts +++ b/src/tests/e2e/budget-journey.e2e.test.ts @@ -143,7 +143,7 @@ describe('E2E Budget Management Journey', () => { // Step 6: Update a budget const updateBudgetResponse = await getRequest() - .put(`/api/budgets/${budgetId}`) + .put(`/api/v1/budgets/${budgetId}`) .set('Authorization', `Bearer ${authToken}`) .send({ amount_cents: 55000, // Increase to $550.00 @@ -189,7 +189,7 @@ describe('E2E Budget Management Journey', () => { const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); const spendingResponse = await getRequest() .get( - `/api/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`, + `/api/v1/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`, ) .set('Authorization', `Bearer ${authToken}`); @@ -227,7 +227,7 @@ describe('E2E Budget Management Journey', () => { // Step 11: Test update validation - empty update const emptyUpdateResponse = await getRequest() - .put(`/api/budgets/${budgetId}`) + .put(`/api/v1/budgets/${budgetId}`) .set('Authorization', `Bearer ${authToken}`) .send({}); // No fields to update @@ -264,7 +264,7 @@ describe('E2E Budget Management Journey', () => { // Other user should not be able to update our budget const otherUpdateResponse = await getRequest() - .put(`/api/budgets/${budgetId}`) + .put(`/api/v1/budgets/${budgetId}`) .set('Authorization', `Bearer ${otherToken}`) .send({ amount_cents: 99999, @@ -274,7 +274,7 @@ describe('E2E Budget Management Journey', () => { // Other user should not be able to delete our budget const otherDeleteAttemptResponse = await getRequest() - .delete(`/api/budgets/${budgetId}`) + .delete(`/api/v1/budgets/${budgetId}`) .set('Authorization', `Bearer ${otherToken}`); expect(otherDeleteAttemptResponse.status).toBe(404); @@ -284,7 +284,7 @@ describe('E2E Budget Management Journey', () => { // Step 13: Delete the weekly budget const deleteBudgetResponse = await getRequest() - .delete(`/api/budgets/${weeklyBudgetResponse.body.data.budget_id}`) + .delete(`/api/v1/budgets/${weeklyBudgetResponse.body.data.budget_id}`) .set('Authorization', `Bearer ${authToken}`); expect(deleteBudgetResponse.status).toBe(204); diff --git a/src/tests/e2e/deals-journey.e2e.test.ts b/src/tests/e2e/deals-journey.e2e.test.ts index 844e7912..2bfca17e 100644 --- a/src/tests/e2e/deals-journey.e2e.test.ts +++ b/src/tests/e2e/deals-journey.e2e.test.ts @@ -96,7 +96,9 @@ describe('E2E Deals and Price Tracking Journey', () => { expect(dairyEggsCategoryId).toBeGreaterThan(0); // Verify we can retrieve the category by ID - const categoryByIdResponse = await getRequest().get(`/api/categories/${dairyEggsCategoryId}`); + const categoryByIdResponse = await getRequest().get( + `/api/v1/categories/${dairyEggsCategoryId}`, + ); expect(categoryByIdResponse.status).toBe(200); expect(categoryByIdResponse.body.success).toBe(true); expect(categoryByIdResponse.body.data.category_id).toBe(dairyEggsCategoryId); @@ -314,7 +316,7 @@ describe('E2E Deals and Price Tracking Journey', () => { // Step 8: Remove an item from watch list const milkMasterItemId = createdMasterItemIds[0]; const removeResponse = await getRequest() - .delete(`/api/users/watched-items/${milkMasterItemId}`) + .delete(`/api/v1/users/watched-items/${milkMasterItemId}`) .set('Authorization', `Bearer ${authToken}`); expect(removeResponse.status).toBe(204); diff --git a/src/tests/e2e/flyer-upload.e2e.test.ts b/src/tests/e2e/flyer-upload.e2e.test.ts index efd5f810..d667f0b4 100644 --- a/src/tests/e2e/flyer-upload.e2e.test.ts +++ b/src/tests/e2e/flyer-upload.e2e.test.ts @@ -92,7 +92,7 @@ describe('E2E Flyer Upload and Processing Workflow', () => { const jobStatusResponse = await poll( async () => { const statusResponse = await getRequest() - .get(`/api/jobs/${jobId}`) + .get(`/api/v1/jobs/${jobId}`) .set('Authorization', `Bearer ${authToken}`); return statusResponse.body; }, diff --git a/src/tests/e2e/inventory-journey.e2e.test.ts b/src/tests/e2e/inventory-journey.e2e.test.ts index 603476de..f6fe9ded 100644 --- a/src/tests/e2e/inventory-journey.e2e.test.ts +++ b/src/tests/e2e/inventory-journey.e2e.test.ts @@ -243,7 +243,7 @@ describe('E2E Inventory/Expiry Management Journey', () => { // Step 8: Get specific item details const milkId = createdInventoryIds[0]; const detailResponse = await getRequest() - .get(`/api/inventory/${milkId}`) + .get(`/api/v1/inventory/${milkId}`) .set('Authorization', `Bearer ${authToken}`); expect(detailResponse.status).toBe(200); @@ -252,7 +252,7 @@ describe('E2E Inventory/Expiry Management Journey', () => { // Step 9: Update item quantity and location const updateResponse = await getRequest() - .put(`/api/inventory/${milkId}`) + .put(`/api/v1/inventory/${milkId}`) .set('Authorization', `Bearer ${authToken}`) .send({ quantity: 1, @@ -266,7 +266,7 @@ describe('E2E Inventory/Expiry Management Journey', () => { // First, reduce quantity via update const applesId = createdInventoryIds[3]; const partialConsumeResponse = await getRequest() - .put(`/api/inventory/${applesId}`) + .put(`/api/v1/inventory/${applesId}`) .set('Authorization', `Bearer ${authToken}`) .send({ quantity: 4 }); // 6 - 2 = 4 @@ -310,14 +310,14 @@ describe('E2E Inventory/Expiry Management Journey', () => { // Step 14: Fully consume an item (marks as consumed, returns 204) const breadId = createdInventoryIds[2]; const fullConsumeResponse = await getRequest() - .post(`/api/inventory/${breadId}/consume`) + .post(`/api/v1/inventory/${breadId}/consume`) .set('Authorization', `Bearer ${authToken}`); expect(fullConsumeResponse.status).toBe(204); // Verify the item is now marked as consumed const consumedItemResponse = await getRequest() - .get(`/api/inventory/${breadId}`) + .get(`/api/v1/inventory/${breadId}`) .set('Authorization', `Bearer ${authToken}`); expect(consumedItemResponse.status).toBe(200); expect(consumedItemResponse.body.data.is_consumed).toBe(true); @@ -325,7 +325,7 @@ describe('E2E Inventory/Expiry Management Journey', () => { // Step 15: Delete an item const riceId = createdInventoryIds[4]; const deleteResponse = await getRequest() - .delete(`/api/inventory/${riceId}`) + .delete(`/api/v1/inventory/${riceId}`) .set('Authorization', `Bearer ${authToken}`); expect(deleteResponse.status).toBe(204); @@ -338,7 +338,7 @@ describe('E2E Inventory/Expiry Management Journey', () => { // Step 16: Verify deletion const verifyDeleteResponse = await getRequest() - .get(`/api/inventory/${riceId}`) + .get(`/api/v1/inventory/${riceId}`) .set('Authorization', `Bearer ${authToken}`); expect(verifyDeleteResponse.status).toBe(404); @@ -366,7 +366,7 @@ describe('E2E Inventory/Expiry Management Journey', () => { // Other user should not see our inventory const otherDetailResponse = await getRequest() - .get(`/api/inventory/${milkId}`) + .get(`/api/v1/inventory/${milkId}`) .set('Authorization', `Bearer ${otherToken}`); expect(otherDetailResponse.status).toBe(404); @@ -385,7 +385,7 @@ describe('E2E Inventory/Expiry Management Journey', () => { // Step 18: Move frozen item to fridge (simulating thawing) const pizzaId = createdInventoryIds[1]; const moveResponse = await getRequest() - .put(`/api/inventory/${pizzaId}`) + .put(`/api/v1/inventory/${pizzaId}`) .set('Authorization', `Bearer ${authToken}`) .send({ location: 'fridge', diff --git a/src/tests/e2e/receipt-journey.e2e.test.ts b/src/tests/e2e/receipt-journey.e2e.test.ts index f9dcef74..df05cbc4 100644 --- a/src/tests/e2e/receipt-journey.e2e.test.ts +++ b/src/tests/e2e/receipt-journey.e2e.test.ts @@ -149,7 +149,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 5: View receipt details const detailResponse = await getRequest() - .get(`/api/receipts/${receiptId}`) + .get(`/api/v1/receipts/${receiptId}`) .set('Authorization', `Bearer ${authToken}`); expect(detailResponse.status).toBe(200); @@ -158,7 +158,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 6: View receipt items const itemsResponse = await getRequest() - .get(`/api/receipts/${receiptId}/items`) + .get(`/api/v1/receipts/${receiptId}/items`) .set('Authorization', `Bearer ${authToken}`); expect(itemsResponse.status).toBe(200); @@ -166,7 +166,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 7: Update an item's status const updateItemResponse = await getRequest() - .put(`/api/receipts/${receiptId}/items/${itemIds[1]}`) + .put(`/api/v1/receipts/${receiptId}/items/${itemIds[1]}`) .set('Authorization', `Bearer ${authToken}`) .send({ status: 'matched', @@ -178,7 +178,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 8: View unadded items const unaddedResponse = await getRequest() - .get(`/api/receipts/${receiptId}/items/unadded`) + .get(`/api/v1/receipts/${receiptId}/items/unadded`) .set('Authorization', `Bearer ${authToken}`); expect(unaddedResponse.status).toBe(200); @@ -186,7 +186,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 9: Confirm items to add to inventory const confirmResponse = await getRequest() - .post(`/api/receipts/${receiptId}/confirm`) + .post(`/api/v1/receipts/${receiptId}/confirm`) .set('Authorization', `Bearer ${authToken}`) .send({ items: [ @@ -260,7 +260,7 @@ describe('E2E Receipt Processing Journey', () => { // Other user should not see our receipt const otherDetailResponse = await getRequest() - .get(`/api/receipts/${receiptId}`) + .get(`/api/v1/receipts/${receiptId}`) .set('Authorization', `Bearer ${otherToken}`); expect(otherDetailResponse.status).toBe(404); @@ -290,7 +290,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 16: Test reprocessing a failed receipt const reprocessResponse = await getRequest() - .post(`/api/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`) + .post(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`) .set('Authorization', `Bearer ${authToken}`); expect(reprocessResponse.status).toBe(200); @@ -298,7 +298,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 17: Delete the failed receipt const deleteResponse = await getRequest() - .delete(`/api/receipts/${receipt2Result.rows[0].receipt_id}`) + .delete(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}`) .set('Authorization', `Bearer ${authToken}`); expect(deleteResponse.status).toBe(204); @@ -311,7 +311,7 @@ describe('E2E Receipt Processing Journey', () => { // Step 18: Verify deletion const verifyDeleteResponse = await getRequest() - .get(`/api/receipts/${receipt2Result.rows[0].receipt_id}`) + .get(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}`) .set('Authorization', `Bearer ${authToken}`); expect(verifyDeleteResponse.status).toBe(404); diff --git a/src/tests/e2e/upc-journey.e2e.test.ts b/src/tests/e2e/upc-journey.e2e.test.ts index 228c69d2..246896df 100644 --- a/src/tests/e2e/upc-journey.e2e.test.ts +++ b/src/tests/e2e/upc-journey.e2e.test.ts @@ -115,7 +115,7 @@ describe('E2E UPC Scanning Journey', () => { // Step 5: Lookup the product by UPC const lookupResponse = await getRequest() - .get(`/api/upc/lookup?upc_code=${testUpc}`) + .get(`/api/v1/upc/lookup?upc_code=${testUpc}`) .set('Authorization', `Bearer ${authToken}`); expect(lookupResponse.status).toBe(200); @@ -152,7 +152,7 @@ describe('E2E UPC Scanning Journey', () => { // Step 8: View specific scan details const scanDetailResponse = await getRequest() - .get(`/api/upc/history/${scanId}`) + .get(`/api/v1/upc/history/${scanId}`) .set('Authorization', `Bearer ${authToken}`); expect(scanDetailResponse.status).toBe(200); @@ -201,7 +201,7 @@ describe('E2E UPC Scanning Journey', () => { // Other user should not see our scan const otherScanDetailResponse = await getRequest() - .get(`/api/upc/history/${scanId}`) + .get(`/api/v1/upc/history/${scanId}`) .set('Authorization', `Bearer ${otherToken}`); expect(otherScanDetailResponse.status).toBe(404); diff --git a/src/tests/e2e/user-journey.e2e.test.ts b/src/tests/e2e/user-journey.e2e.test.ts index 590750da..347c9b79 100644 --- a/src/tests/e2e/user-journey.e2e.test.ts +++ b/src/tests/e2e/user-journey.e2e.test.ts @@ -72,7 +72,7 @@ describe('E2E User Journey', () => { // 4. Add an item to the list const addItemResponse = await getRequest() - .post(`/api/users/shopping-lists/${shoppingListId}/items`) + .post(`/api/v1/users/shopping-lists/${shoppingListId}/items`) .set('Authorization', `Bearer ${authToken}`) .send({ customItemName: 'Chips' }); diff --git a/vite.config.ts b/vite.config.ts index f107068c..bce85715 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -123,6 +123,11 @@ export default defineConfig({ test: { // Name this project 'unit' to distinguish it in the workspace. name: 'unit', + // Set environment variables for unit tests + env: { + // ADR-008: Ensure API versioning is correctly set for unit tests + VITE_API_BASE_URL: '/api/v1', + }, // By default, Vitest does not suppress console logs. // The onConsoleLog hook is only needed if you want to conditionally filter specific logs. // Keeping the default behavior is often safer to avoid missing important warnings. diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index 05d413a7..663cb94d 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -32,7 +32,8 @@ const e2eConfig = mergeConfig( FRONTEND_URL: 'https://example.com', // Use port 3098 for E2E tests (integration uses 3099) TEST_PORT: '3098', - VITE_API_BASE_URL: 'http://localhost:3098/api', + // ADR-008: API versioning - all routes use /api/v1 prefix + VITE_API_BASE_URL: 'http://localhost:3098/api/v1', }, // E2E tests have their own dedicated global setup file globalSetup: './src/tests/setup/e2e-global-setup.ts', diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index f276dd06..2e097c48 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -68,7 +68,8 @@ const finalConfig = mergeConfig( // Use a dedicated test port (3099) to avoid conflicts with production servers // that might be running on port 3000 or 3001 TEST_PORT: '3099', - VITE_API_BASE_URL: 'http://localhost:3099/api', + // ADR-008: API versioning - all routes use /api/v1 prefix + VITE_API_BASE_URL: 'http://localhost:3099/api/v1', }, // This setup script starts the backend server before tests run. globalSetup: './src/tests/setup/integration-global-setup.ts',