diff --git a/.gitignore b/.gitignore index d532e728..57be5377 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,11 @@ coverage .nyc_output .coverage +# Test artifacts - flyer-images/ is a runtime directory +# Test fixtures are stored in src/tests/assets/ instead +flyer-images/ +test-output.txt + # Editor directories and files .vscode/* !.vscode/extensions.json @@ -31,3 +36,4 @@ coverage *.sw? Thumbs.db .claude +nul diff --git a/CLAUDE.md b/CLAUDE.md index d19997ec..44b8045f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,26 @@ npm run test:unit # Run unit tests only npm run test:integration # Run integration tests (requires DB/Redis) ``` +### Running Tests via Podman (from Windows host) + +The command to run unit tests in the Linux container via podman: + +```bash +podman exec -it flyer-crawler-dev npm run test:unit +``` + +The command to run integration tests in the Linux container via podman: + +```bash +podman exec -it flyer-crawler-dev npm run test:integration +``` + +For running specific test files: + +```bash +podman exec -it flyer-crawler-dev npm test -- --run src/hooks/useAuth.test.tsx +``` + ### Why Linux Only? - Path separators: Code uses POSIX-style paths (`/`) which may break on Windows diff --git a/src/tests/integration/admin.integration.test.ts b/src/tests/integration/admin.integration.test.ts index 6385263a..5df8036a 100644 --- a/src/tests/integration/admin.integration.test.ts +++ b/src/tests/integration/admin.integration.test.ts @@ -59,7 +59,7 @@ describe('Admin API Routes Integration Tests', () => { const response = await request .get('/api/admin/stats') .set('Authorization', `Bearer ${adminToken}`); - const stats = response.body; + const stats = response.body.data; // DEBUG: Log response if it fails expectation if (response.status !== 200) { console.error('[DEBUG] GET /api/admin/stats failed:', response.status, response.body); @@ -75,7 +75,7 @@ describe('Admin API Routes Integration Tests', () => { .get('/api/admin/stats') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); - const errorData = response.body; + const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); @@ -85,7 +85,7 @@ describe('Admin API Routes Integration Tests', () => { const response = await request .get('/api/admin/stats/daily') .set('Authorization', `Bearer ${adminToken}`); - const dailyStats = response.body; + const dailyStats = response.body.data; expect(dailyStats).toBeDefined(); expect(Array.isArray(dailyStats)).toBe(true); // We just created users in beforeAll, so we should have data @@ -100,7 +100,7 @@ describe('Admin API Routes Integration Tests', () => { .get('/api/admin/stats/daily') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); - const errorData = response.body; + const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); @@ -112,7 +112,7 @@ describe('Admin API Routes Integration Tests', () => { const response = await request .get('/api/admin/corrections') .set('Authorization', `Bearer ${adminToken}`); - const corrections = response.body; + const corrections = response.body.data; expect(corrections).toBeDefined(); expect(Array.isArray(corrections)).toBe(true); }); @@ -122,7 +122,7 @@ describe('Admin API Routes Integration Tests', () => { .get('/api/admin/corrections') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); - const errorData = response.body; + const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); @@ -132,7 +132,7 @@ describe('Admin API Routes Integration Tests', () => { const response = await request .get('/api/admin/brands') .set('Authorization', `Bearer ${adminToken}`); - const brands = response.body; + const brands = response.body.data; expect(brands).toBeDefined(); expect(Array.isArray(brands)).toBe(true); // Even if no brands exist, it should return an array. @@ -145,7 +145,7 @@ describe('Admin API Routes Integration Tests', () => { .get('/api/admin/brands') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); - const errorData = response.body; + const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); @@ -238,7 +238,7 @@ describe('Admin API Routes Integration Tests', () => { .put(`/api/admin/corrections/${testCorrectionId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ suggested_value: '300' }); - const updatedCorrection = response.body; + const updatedCorrection = response.body.data; // Assert: Verify the API response and the database state. expect(updatedCorrection.suggested_value).toBe('300'); @@ -274,7 +274,7 @@ describe('Admin API Routes Integration Tests', () => { }); describe('DELETE /api/admin/users/:id', () => { - it('should allow an admin to delete another user\'s account', async () => { + it("should allow an admin to delete another user's account", async () => { // Act: Call the delete endpoint as an admin. const targetUserId = regularUser.user.user_id; const response = await request @@ -296,10 +296,14 @@ describe('Admin API Routes Integration Tests', () => { // The service throws ValidationError, which maps to 400. // We also allow 403 in case authorization middleware catches it in the future. if (response.status !== 400 && response.status !== 403) { - console.error('[DEBUG] Self-deletion failed with unexpected status:', response.status, response.body); + console.error( + '[DEBUG] Self-deletion failed with unexpected status:', + response.status, + response.body, + ); } expect([400, 403]).toContain(response.status); - expect(response.body.message).toMatch(/Admins cannot delete their own account/); + expect(response.body.error.message).toMatch(/Admins cannot delete their own account/); }); it('should return 404 if the user to be deleted is not found', async () => { diff --git a/src/tests/integration/ai.integration.test.ts b/src/tests/integration/ai.integration.test.ts index f95ec9c7..ffa7ede3 100644 --- a/src/tests/integration/ai.integration.test.ts +++ b/src/tests/integration/ai.integration.test.ts @@ -67,7 +67,7 @@ describe('AI API Routes Integration Tests', () => { .post('/api/ai/check-flyer') .set('Authorization', `Bearer ${authToken}`) .attach('image', Buffer.from('content'), 'test.jpg'); - const result = response.body; + const result = response.body.data; expect(response.status).toBe(200); // The backend is stubbed to always return true for this check expect(result.is_flyer).toBe(true); @@ -78,7 +78,7 @@ describe('AI API Routes Integration Tests', () => { .post('/api/ai/extract-address') .set('Authorization', `Bearer ${authToken}`) .attach('image', Buffer.from('content'), 'test.jpg'); - const result = response.body; + const result = response.body.data; expect(response.status).toBe(200); expect(result.address).toBe('not identified'); }); @@ -88,7 +88,7 @@ describe('AI API Routes Integration Tests', () => { .post('/api/ai/extract-logo') .set('Authorization', `Bearer ${authToken}`) .attach('images', Buffer.from('content'), 'test.jpg'); - const result = response.body; + const result = response.body.data; expect(response.status).toBe(200); expect(result).toEqual({ store_logo_base_64: null }); }); @@ -98,7 +98,7 @@ describe('AI API Routes Integration Tests', () => { .post('/api/ai/quick-insights') .set('Authorization', `Bearer ${authToken}`) .send({ items: [{ item: 'test' }] }); - const result = response.body; + const result = response.body.data; // DEBUG: Log response if it fails expectation if (response.status !== 200 || !result.text) { console.log('[DEBUG] POST /api/ai/quick-insights response:', response.status, response.body); @@ -112,7 +112,7 @@ describe('AI API Routes Integration Tests', () => { .post('/api/ai/deep-dive') .set('Authorization', `Bearer ${authToken}`) .send({ items: [{ item: 'test' }] }); - const result = response.body; + const result = response.body.data; // DEBUG: Log response if it fails expectation if (response.status !== 200 || !result.text) { console.log('[DEBUG] POST /api/ai/deep-dive response:', response.status, response.body); @@ -126,7 +126,7 @@ describe('AI API Routes Integration Tests', () => { .post('/api/ai/search-web') .set('Authorization', `Bearer ${authToken}`) .send({ query: 'test query' }); - const result = response.body; + const result = response.body.data; // DEBUG: Log response if it fails expectation if (response.status !== 200 || !result.text) { console.log('[DEBUG] POST /api/ai/search-web response:', response.status, response.body); @@ -174,7 +174,7 @@ describe('AI API Routes Integration Tests', () => { console.log('[DEBUG] POST /api/ai/plan-trip response:', response.status, response.body); } expect(response.status).toBe(500); - const errorResult = response.body; + const errorResult = response.body.error; expect(errorResult.message).toContain('planTripWithMaps'); }); diff --git a/src/tests/integration/auth.integration.test.ts b/src/tests/integration/auth.integration.test.ts index 970aecf1..4056e446 100644 --- a/src/tests/integration/auth.integration.test.ts +++ b/src/tests/integration/auth.integration.test.ts @@ -44,10 +44,14 @@ describe('Authentication API Integration', () => { const response = await request .post('/api/auth/login') .send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: false }); - const data = response.body; + const data = response.body.data; if (response.status !== 200) { - console.error('[DEBUG] Login failed:', response.status, JSON.stringify(data, null, 2)); + console.error( + '[DEBUG] Login failed:', + response.status, + JSON.stringify(response.body, null, 2), + ); } // Assert that the API returns the expected structure @@ -69,7 +73,7 @@ describe('Authentication API Integration', () => { .post('/api/auth/login') .send({ email: adminEmail, password: wrongPassword, rememberMe: false }); expect(response.status).toBe(401); - const errorData = response.body; + const errorData = response.body.error; expect(errorData.message).toBe('Incorrect email or password.'); }); @@ -82,7 +86,7 @@ describe('Authentication API Integration', () => { .post('/api/auth/login') .send({ email: nonExistentEmail, password: anyPassword, rememberMe: false }); expect(response.status).toBe(401); - const errorData = response.body; + const errorData = response.body.error; // Security best practice: the error message should be identical for wrong password and wrong email // to prevent user enumeration attacks. expect(errorData.message).toBe('Incorrect email or password.'); @@ -103,8 +107,8 @@ describe('Authentication API Integration', () => { // Assert 1: Check that the registration was successful and the returned profile is correct. expect(registerResponse.status).toBe(201); - const registeredProfile = registerResponse.body.userprofile; - const registeredToken = registerResponse.body.token; + const registeredProfile = registerResponse.body.data.userprofile; + const registeredToken = registerResponse.body.data.token; expect(registeredProfile.user.email).toBe(email); expect(registeredProfile.avatar_url).toBeNull(); // The API should return null for the avatar_url. @@ -117,7 +121,7 @@ describe('Authentication API Integration', () => { .set('Authorization', `Bearer ${registeredToken}`); expect(profileResponse.status).toBe(200); - expect(profileResponse.body.avatar_url).toBeNull(); + expect(profileResponse.body.data.avatar_url).toBeNull(); }); it('should successfully refresh an access token using a refresh token cookie', async () => { @@ -137,7 +141,7 @@ describe('Authentication API Integration', () => { // Assert: Check for a successful response and a new access token. expect(response.status).toBe(200); - const data = response.body; + const data = response.body.data; expect(data.token).toBeTypeOf('string'); }); @@ -152,7 +156,7 @@ describe('Authentication API Integration', () => { // Assert: Check for a 403 Forbidden response. expect(response.status).toBe(403); - const data = response.body; + const data = response.body.error; expect(data.message).toBe('Invalid or expired refresh token.'); }); diff --git a/src/tests/integration/budget.integration.test.ts b/src/tests/integration/budget.integration.test.ts index 36930962..2a5abce9 100644 --- a/src/tests/integration/budget.integration.test.ts +++ b/src/tests/integration/budget.integration.test.ts @@ -45,7 +45,13 @@ describe('Budget API Routes Integration Tests', () => { `INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date) VALUES ($1, $2, $3, $4, $5) RETURNING *`, - [testUser.user.user_id, budgetToCreate.name, budgetToCreate.amount_cents, budgetToCreate.period, budgetToCreate.start_date], + [ + testUser.user.user_id, + budgetToCreate.name, + budgetToCreate.amount_cents, + budgetToCreate.period, + budgetToCreate.start_date, + ], ); testBudget = budgetRes.rows[0]; createdBudgetIds.push(testBudget.budget_id); @@ -67,9 +73,9 @@ describe('Budget API Routes Integration Tests', () => { .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); - const budgets: Budget[] = response.body; + const budgets: Budget[] = response.body.data; expect(budgets).toBeInstanceOf(Array); - expect(budgets.some(b => b.budget_id === testBudget.budget_id)).toBe(true); + expect(budgets.some((b) => b.budget_id === testBudget.budget_id)).toBe(true); }); it('should return 401 if user is not authenticated', async () => { @@ -82,4 +88,4 @@ describe('Budget API Routes Integration Tests', () => { it.todo('should allow an authenticated user to update their own budget'); it.todo('should allow an authenticated user to delete their own budget'); it.todo('should return spending analysis for the authenticated user'); -}); \ No newline at end of file +}); diff --git a/src/tests/integration/flyer.integration.test.ts b/src/tests/integration/flyer.integration.test.ts index 438caee9..14cd0547 100644 --- a/src/tests/integration/flyer.integration.test.ts +++ b/src/tests/integration/flyer.integration.test.ts @@ -44,7 +44,7 @@ describe('Public Flyer API Routes Integration Tests', () => { ); const response = await request.get('/api/flyers'); - flyers = response.body; + flyers = response.body.data; }); afterAll(async () => { @@ -60,7 +60,7 @@ describe('Public Flyer API Routes Integration Tests', () => { it('should return a list of flyers', async () => { // Act: Call the API endpoint using the client function. const response = await request.get('/api/flyers'); - const flyers: Flyer[] = response.body; + const flyers: Flyer[] = response.body.data; expect(response.status).toBe(200); expect(flyers).toBeInstanceOf(Array); @@ -86,7 +86,7 @@ describe('Public Flyer API Routes Integration Tests', () => { // Act: Fetch items for the first flyer. const response = await request.get(`/api/flyers/${testFlyer.flyer_id}/items`); - const items: FlyerItem[] = response.body; + const items: FlyerItem[] = response.body.data; expect(response.status).toBe(200); expect(items).toBeInstanceOf(Array); @@ -110,7 +110,7 @@ describe('Public Flyer API Routes Integration Tests', () => { // Act: Fetch items for all available flyers. const response = await request.post('/api/flyers/items/batch-fetch').send({ flyerIds }); - const items: FlyerItem[] = response.body; + const items: FlyerItem[] = response.body.data; expect(response.status).toBe(200); expect(items).toBeInstanceOf(Array); // The total number of items should be greater than or equal to the number of flyers (assuming at least one item per flyer). @@ -128,7 +128,7 @@ describe('Public Flyer API Routes Integration Tests', () => { // Act const response = await request.post('/api/flyers/items/batch-count').send({ flyerIds }); - const result = response.body; + const result = response.body.data; // Assert expect(result.count).toBeTypeOf('number'); diff --git a/src/tests/integration/gamification.integration.test.ts b/src/tests/integration/gamification.integration.test.ts index 70aad6ba..3dcb2d17 100644 --- a/src/tests/integration/gamification.integration.test.ts +++ b/src/tests/integration/gamification.integration.test.ts @@ -260,7 +260,7 @@ describe('Gamification Flow Integration Test', () => { // --- Act 4: Fetch the leaderboard --- const leaderboardResponse = await request.get('/api/achievements/leaderboard'); - const leaderboard: LeaderboardUser[] = leaderboardResponse.body; + const leaderboard: LeaderboardUser[] = leaderboardResponse.body.data; // --- Assert 3: Verify the user is on the leaderboard with points --- const userOnLeaderboard = leaderboard.find((u) => u.user_id === testUser.user.user_id); @@ -315,7 +315,7 @@ describe('Gamification Flow Integration Test', () => { // --- Assert --- // 6. Check for a successful response. expect(response.status).toBe(200); - const newFlyer: Flyer = response.body; + const newFlyer: Flyer = response.body.data; expect(newFlyer).toBeDefined(); expect(newFlyer.flyer_id).toBeTypeOf('number'); createdFlyerIds.push(newFlyer.flyer_id); // Add for cleanup. diff --git a/src/tests/integration/notification.integration.test.ts b/src/tests/integration/notification.integration.test.ts index 51c69097..cd1aa871 100644 --- a/src/tests/integration/notification.integration.test.ts +++ b/src/tests/integration/notification.integration.test.ts @@ -62,7 +62,7 @@ describe('Notification API Routes Integration Tests', () => { .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); - const notifications: Notification[] = response.body; + const notifications: Notification[] = response.body.data; expect(notifications).toHaveLength(2); // Only the two unread ones expect(notifications.every((n) => !n.is_read)).toBe(true); }); @@ -73,7 +73,7 @@ describe('Notification API Routes Integration Tests', () => { .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); - const notifications: Notification[] = response.body; + const notifications: Notification[] = response.body.data; expect(notifications).toHaveLength(3); // All three notifications }); @@ -84,7 +84,7 @@ describe('Notification API Routes Integration Tests', () => { .set('Authorization', `Bearer ${authToken}`); expect(response1.status).toBe(200); - const notifications1: Notification[] = response1.body; + const notifications1: Notification[] = response1.body.data; expect(notifications1).toHaveLength(1); expect(notifications1[0].content).toBe('Your second unread notification'); // Assuming DESC order @@ -94,7 +94,7 @@ describe('Notification API Routes Integration Tests', () => { .set('Authorization', `Bearer ${authToken}`); expect(response2.status).toBe(200); - const notifications2: Notification[] = response2.body; + const notifications2: Notification[] = response2.body.data; expect(notifications2).toHaveLength(1); expect(notifications2[0].content).toBe('Your first unread notification'); }); @@ -145,4 +145,4 @@ describe('Notification API Routes Integration Tests', () => { expect(Number(finalUnreadCountRes.rows[0].count)).toBe(0); }); }); -}); \ No newline at end of file +}); diff --git a/src/tests/integration/price.integration.test.ts b/src/tests/integration/price.integration.test.ts index 404f8c82..f6c3050d 100644 --- a/src/tests/integration/price.integration.test.ts +++ b/src/tests/integration/price.integration.test.ts @@ -114,17 +114,27 @@ describe('Price History API Integration Test (/api/price-history)', () => { }); it('should return the correct price history for a given master item ID', async () => { - const response = await request.post('/api/price-history') + const response = await request + .post('/api/price-history') .set('Authorization', `Bearer ${authToken}`) .send({ masterItemIds: [masterItemId] }); expect(response.status).toBe(200); - expect(response.body).toBeInstanceOf(Array); - expect(response.body).toHaveLength(3); + expect(response.body.data).toBeInstanceOf(Array); + expect(response.body.data).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 }); + expect(response.body.data[0]).toMatchObject({ + master_item_id: masterItemId, + price_in_cents: 199, + }); + expect(response.body.data[1]).toMatchObject({ + master_item_id: masterItemId, + price_in_cents: 249, + }); + expect(response.body.data[2]).toMatchObject({ + master_item_id: masterItemId, + price_in_cents: 299, + }); }); it('should respect the limit parameter', async () => { @@ -134,9 +144,9 @@ describe('Price History API Integration Test (/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); + expect(response.body.data).toHaveLength(2); + expect(response.body.data[0].price_in_cents).toBe(199); + expect(response.body.data[1].price_in_cents).toBe(249); }); it('should respect the offset parameter', async () => { @@ -146,18 +156,19 @@ describe('Price History API Integration Test (/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); + expect(response.body.data).toHaveLength(2); + expect(response.body.data[0].price_in_cents).toBe(249); + expect(response.body.data[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') + const response = await request + .post('/api/price-history') .set('Authorization', `Bearer ${authToken}`) .send({ masterItemIds: [masterItemId] }); expect(response.status).toBe(200); - const history = response.body; + const history = response.body.data; expect(history).toHaveLength(3); const date1 = new Date(history[0].date).getTime(); @@ -169,10 +180,11 @@ describe('Price History API Integration Test (/api/price-history)', () => { }); it('should return an empty array for a master item ID with no price history', async () => { - const response = await request.post('/api/price-history') + const response = await request + .post('/api/price-history') .set('Authorization', `Bearer ${authToken}`) .send({ masterItemIds: [999999] }); expect(response.status).toBe(200); - expect(response.body).toEqual([]); + expect(response.body.data).toEqual([]); }); -}); \ No newline at end of file +}); diff --git a/src/tests/integration/public.routes.integration.test.ts b/src/tests/integration/public.routes.integration.test.ts index d57aa9a9..eaa8a81b 100644 --- a/src/tests/integration/public.routes.integration.test.ts +++ b/src/tests/integration/public.routes.integration.test.ts @@ -118,16 +118,16 @@ describe('Public API Routes Integration Tests', () => { it('GET /api/health/time should return the server time', async () => { const response = await request.get('/api/health/time'); expect(response.status).toBe(200); - expect(response.body).toHaveProperty('currentTime'); - expect(response.body).toHaveProperty('year'); - expect(response.body).toHaveProperty('week'); + expect(response.body.data).toHaveProperty('currentTime'); + expect(response.body.data).toHaveProperty('year'); + expect(response.body.data).toHaveProperty('week'); }); }); describe('Public Data Endpoints', () => { it('GET /api/flyers should return a list of flyers', async () => { const response = await request.get('/api/flyers'); - const flyers: Flyer[] = response.body; + const flyers: Flyer[] = response.body.data; expect(flyers.length).toBeGreaterThan(0); const foundFlyer = flyers.find((f) => f.flyer_id === testFlyer.flyer_id); expect(foundFlyer).toBeDefined(); @@ -136,7 +136,7 @@ describe('Public API Routes Integration Tests', () => { it('GET /api/flyers/:id/items should return items for a specific flyer', async () => { const response = await request.get(`/api/flyers/${testFlyer.flyer_id}/items`); - const items: FlyerItem[] = response.body; + const items: FlyerItem[] = response.body.data; expect(response.status).toBe(200); expect(items).toBeInstanceOf(Array); expect(items.length).toBe(1); @@ -146,7 +146,7 @@ describe('Public API Routes Integration Tests', () => { it('POST /api/flyers/items/batch-fetch should return items for multiple flyers', async () => { const flyerIds = [testFlyer.flyer_id]; const response = await request.post('/api/flyers/items/batch-fetch').send({ flyerIds }); - const items: FlyerItem[] = response.body; + const items: FlyerItem[] = response.body.data; expect(response.status).toBe(200); expect(items).toBeInstanceOf(Array); expect(items.length).toBeGreaterThan(0); @@ -156,13 +156,13 @@ describe('Public API Routes Integration Tests', () => { const flyerIds = [testFlyer.flyer_id]; const response = await request.post('/api/flyers/items/batch-count').send({ flyerIds }); expect(response.status).toBe(200); - expect(response.body.count).toBeTypeOf('number'); - expect(response.body.count).toBeGreaterThan(0); + expect(response.body.data.count).toBeTypeOf('number'); + expect(response.body.data.count).toBeGreaterThan(0); }); it('GET /api/personalization/master-items should return a list of master grocery items', async () => { const response = await request.get('/api/personalization/master-items'); - const masterItems = response.body; + const masterItems = response.body.data; expect(response.status).toBe(200); expect(masterItems).toBeInstanceOf(Array); expect(masterItems.length).toBeGreaterThan(0); // This relies on seed data for master items. @@ -171,7 +171,7 @@ describe('Public API Routes Integration Tests', () => { it('GET /api/recipes/by-sale-percentage should return recipes', async () => { const response = await request.get('/api/recipes/by-sale-percentage?minPercentage=10'); - const recipes: Recipe[] = response.body; + const recipes: Recipe[] = response.body.data; expect(response.status).toBe(200); expect(recipes).toBeInstanceOf(Array); }); @@ -181,7 +181,7 @@ describe('Public API Routes Integration Tests', () => { const response = await request.get( '/api/recipes/by-ingredient-and-tag?ingredient=Test&tag=Public', ); - const recipes: Recipe[] = response.body; + const recipes: Recipe[] = response.body.data; expect(response.status).toBe(200); expect(recipes).toBeInstanceOf(Array); }); @@ -194,7 +194,7 @@ describe('Public API Routes Integration Tests', () => { ); createdRecipeCommentIds.push(commentRes.rows[0].recipe_comment_id); const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`); - const comments: RecipeComment[] = response.body; + const comments: RecipeComment[] = response.body.data; expect(response.status).toBe(200); expect(comments).toBeInstanceOf(Array); expect(comments.length).toBe(1); @@ -203,7 +203,7 @@ describe('Public API Routes Integration Tests', () => { it('GET /api/stats/most-frequent-sales should return frequent items', async () => { const response = await request.get('/api/stats/most-frequent-sales?days=365&limit=5'); - const items = response.body; + const items = response.body.data; expect(response.status).toBe(200); expect(items).toBeInstanceOf(Array); }); @@ -211,7 +211,7 @@ describe('Public API Routes Integration Tests', () => { it('GET /api/personalization/dietary-restrictions should return a list of restrictions', async () => { // This test relies on static seed data for a lookup table, which is acceptable. const response = await request.get('/api/personalization/dietary-restrictions'); - const restrictions: DietaryRestriction[] = response.body; + const restrictions: DietaryRestriction[] = response.body.data; expect(response.status).toBe(200); expect(restrictions).toBeInstanceOf(Array); expect(restrictions.length).toBeGreaterThan(0); @@ -220,7 +220,7 @@ describe('Public API Routes Integration Tests', () => { it('GET /api/personalization/appliances should return a list of appliances', async () => { const response = await request.get('/api/personalization/appliances'); - const appliances: Appliance[] = response.body; + const appliances: Appliance[] = response.body.data; expect(response.status).toBe(200); expect(appliances).toBeInstanceOf(Array); expect(appliances.length).toBeGreaterThan(0); diff --git a/src/tests/integration/recipe.integration.test.ts b/src/tests/integration/recipe.integration.test.ts index 7fc25d9a..5b2c3d3a 100644 --- a/src/tests/integration/recipe.integration.test.ts +++ b/src/tests/integration/recipe.integration.test.ts @@ -69,9 +69,9 @@ describe('Recipe API Routes Integration Tests', () => { const response = await request.get(`/api/recipes/${testRecipe.recipe_id}`); expect(response.status).toBe(200); - expect(response.body).toBeDefined(); - expect(response.body.recipe_id).toBe(testRecipe.recipe_id); - expect(response.body.name).toBe('Integration Test Recipe'); + expect(response.body.data).toBeDefined(); + expect(response.body.data.recipe_id).toBe(testRecipe.recipe_id); + expect(response.body.data.name).toBe('Integration Test Recipe'); }); it('should return 404 for a non-existent recipe ID', async () => { @@ -94,7 +94,7 @@ describe('Recipe API Routes Integration Tests', () => { // Assert the response from the POST request expect(response.status).toBe(201); - const createdRecipe: Recipe = response.body; + const createdRecipe: Recipe = response.body.data; expect(createdRecipe).toBeDefined(); expect(createdRecipe.recipe_id).toBeTypeOf('number'); expect(createdRecipe.name).toBe(newRecipeData.name); @@ -106,7 +106,7 @@ describe('Recipe API Routes Integration Tests', () => { // Verify the recipe can be fetched from the public endpoint const verifyResponse = await request.get(`/api/recipes/${createdRecipe.recipe_id}`); expect(verifyResponse.status).toBe(200); - expect(verifyResponse.body.name).toBe(newRecipeData.name); + expect(verifyResponse.body.data.name).toBe(newRecipeData.name); }); it('should allow an authenticated user to update their own recipe', async () => { const recipeUpdates = { @@ -121,14 +121,14 @@ describe('Recipe API Routes Integration Tests', () => { // Assert the response from the PUT request expect(response.status).toBe(200); - const updatedRecipe: Recipe = response.body; + const updatedRecipe: Recipe = response.body.data; expect(updatedRecipe.name).toBe(recipeUpdates.name); expect(updatedRecipe.instructions).toBe(recipeUpdates.instructions); // Verify the changes were persisted by fetching the recipe again const verifyResponse = await request.get(`/api/recipes/${testRecipe.recipe_id}`); expect(verifyResponse.status).toBe(200); - expect(verifyResponse.body.name).toBe(recipeUpdates.name); + expect(verifyResponse.body.data.name).toBe(recipeUpdates.name); }); it.todo("should prevent a user from updating another user's recipe"); it.todo('should allow an authenticated user to delete their own recipe'); @@ -148,7 +148,7 @@ describe('Recipe API Routes Integration Tests', () => { .send({ ingredients }); expect(response.status).toBe(200); - expect(response.body).toEqual({ suggestion: mockSuggestion }); + expect(response.body.data).toEqual({ suggestion: mockSuggestion }); expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith( ingredients, expect.anything(), diff --git a/src/tests/integration/server.integration.test.ts b/src/tests/integration/server.integration.test.ts index 9ca032d4..500be07b 100644 --- a/src/tests/integration/server.integration.test.ts +++ b/src/tests/integration/server.integration.test.ts @@ -58,7 +58,7 @@ describe('Server Initialization Smoke Test', () => { // by the application user, which is critical for file uploads. expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.message).toContain('is accessible and writable'); + expect(response.body.data.message).toContain('is accessible and writable'); }); it('should respond with 200 OK for GET /api/health/redis', async () => { @@ -70,6 +70,6 @@ describe('Server Initialization Smoke Test', () => { // essential for the background job queueing system (BullMQ). expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.message).toBe('Redis connection is healthy.'); + expect(response.body.data.message).toBe('Redis connection is healthy.'); }); }); diff --git a/src/tests/integration/user.integration.test.ts b/src/tests/integration/user.integration.test.ts index dbe410b7..65ba61a2 100644 --- a/src/tests/integration/user.integration.test.ts +++ b/src/tests/integration/user.integration.test.ts @@ -67,7 +67,7 @@ describe('User API Routes Integration Tests', () => { const response = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); - const profile = response.body; + const profile = response.body.data; // Assert: Verify the profile data matches the created user. expect(response.status).toBe(200); @@ -88,7 +88,7 @@ describe('User API Routes Integration Tests', () => { .put('/api/users/profile') .set('Authorization', `Bearer ${authToken}`) .send(profileUpdates); - const updatedProfile = response.body; + const updatedProfile = response.body.data; // Assert: Check that the returned profile reflects the changes. expect(response.status).toBe(200); @@ -98,7 +98,7 @@ describe('User API Routes Integration Tests', () => { const refetchResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); - const refetchedProfile = refetchResponse.body; + const refetchedProfile = refetchResponse.body.data; expect(refetchedProfile.full_name).toBe('Updated Test User'); }); @@ -114,7 +114,7 @@ describe('User API Routes Integration Tests', () => { .put('/api/users/profile') .set('Authorization', `Bearer ${authToken}`) .send(profileUpdates); - const updatedProfile = response.body; + const updatedProfile = response.body.data; // Assert: Check that the returned profile reflects the changes. expect(response.status).toBe(200); @@ -125,7 +125,7 @@ describe('User API Routes Integration Tests', () => { const refetchResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); - expect(refetchResponse.body.avatar_url).toBeNull(); + expect(refetchResponse.body.data.avatar_url).toBeNull(); }); it('should update user preferences via PUT /api/users/profile/preferences', async () => { @@ -139,7 +139,7 @@ describe('User API Routes Integration Tests', () => { .put('/api/users/profile/preferences') .set('Authorization', `Bearer ${authToken}`) .send(preferenceUpdates); - const updatedProfile = response.body; + const updatedProfile = response.body.data; // Assert: Check that the preferences object in the returned profile is updated. expect(response.status).toBe(200); @@ -160,10 +160,10 @@ describe('User API Routes Integration Tests', () => { }); expect(response.status).toBe(400); - const errorData = response.body as { message: string; errors: { message: string }[] }; - // For validation errors, the detailed messages are in the `errors` array. + const errorData = response.body.error as { message: string; details: { message: string }[] }; + // For validation errors, the detailed messages are in the `details` array. // We join them to check for the specific feedback from the password strength checker. - const detailedErrorMessage = errorData.errors?.map((e) => e.message).join(' '); + const detailedErrorMessage = errorData.details?.map((e) => e.message).join(' '); expect(detailedErrorMessage).toMatch(/Password is too weak/); }); @@ -185,14 +185,14 @@ describe('User API Routes Integration Tests', () => { // Assert: Check for a successful deletion message. expect(response.status).toBe(200); - expect(deleteResponse.message).toBe('Account deleted successfully.'); + expect(deleteResponse.data.message).toBe('Account deleted successfully.'); // Assert (Verification): Attempting to log in again with the same credentials should now fail. const loginResponse = await request .post('/api/auth/login') .send({ email: deletionEmail, password: TEST_PASSWORD }); expect(loginResponse.status).toBe(401); - const errorData = loginResponse.body; + const errorData = loginResponse.body.error; expect(errorData.message).toBe('Incorrect email or password.'); }); @@ -210,7 +210,7 @@ describe('User API Routes Integration Tests', () => { const errorData = resetRequestRawResponse.body; throw new Error(errorData.message || 'Password reset request failed'); } - const resetRequestResponse = resetRequestRawResponse.body; + const resetRequestResponse = resetRequestRawResponse.body.data; const resetToken = resetRequestResponse.token; // Assert 1: Check that we received a token. @@ -226,7 +226,7 @@ describe('User API Routes Integration Tests', () => { const errorData = resetRawResponse.body; throw new Error(errorData.message || 'Password reset failed'); } - const resetResponse = resetRawResponse.body; + const resetResponse = resetRawResponse.body.data; // Assert 2: Check for a successful password reset message. expect(resetResponse.message).toBe('Password has been reset successfully.'); @@ -235,7 +235,7 @@ describe('User API Routes Integration Tests', () => { const loginResponse = await request .post('/api/auth/login') .send({ email: resetEmail, password: newPassword }); - const loginData = loginResponse.body; + const loginData = loginResponse.body.data; expect(loginData.userprofile).toBeDefined(); expect(loginData.userprofile.user.user_id).toBe(resetUser.user.user_id); }); @@ -247,7 +247,7 @@ describe('User API Routes Integration Tests', () => { .post('/api/users/watched-items') .set('Authorization', `Bearer ${authToken}`) .send({ itemName: 'Integration Test Item', category: 'Other/Miscellaneous' }); - const newItem = addResponse.body; + const newItem = addResponse.body.data; if (newItem?.master_grocery_item_id) createdMasterItemIds.push(newItem.master_grocery_item_id); @@ -259,7 +259,7 @@ describe('User API Routes Integration Tests', () => { const watchedItemsResponse = await request .get('/api/users/watched-items') .set('Authorization', `Bearer ${authToken}`); - const watchedItems = watchedItemsResponse.body; + const watchedItems = watchedItemsResponse.body.data; // Assert 2: Verify the new item is in the user's watched list. expect( @@ -279,7 +279,7 @@ describe('User API Routes Integration Tests', () => { const finalWatchedItemsResponse = await request .get('/api/users/watched-items') .set('Authorization', `Bearer ${authToken}`); - const finalWatchedItems = finalWatchedItemsResponse.body; + const finalWatchedItems = finalWatchedItemsResponse.body.data; expect( finalWatchedItems.some( (item: MasterGroceryItem) => @@ -294,7 +294,7 @@ describe('User API Routes Integration Tests', () => { .post('/api/users/shopping-lists') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'My Integration Test List' }); - const newList = createListResponse.body; + const newList = createListResponse.body.data; // Assert 1: Check that the list was created. expect(createListResponse.status).toBe(201); @@ -305,7 +305,7 @@ describe('User API Routes Integration Tests', () => { .post(`/api/users/shopping-lists/${newList.shopping_list_id}/items`) .set('Authorization', `Bearer ${authToken}`) .send({ customItemName: 'Custom Test Item' }); - const addedItem = addItemResponse.body; + const addedItem = addItemResponse.body.data; // Assert 2: Check that the item was added. expect(addItemResponse.status).toBe(201); @@ -315,7 +315,7 @@ describe('User API Routes Integration Tests', () => { const fetchResponse = await request .get('/api/users/shopping-lists') .set('Authorization', `Bearer ${authToken}`); - const lists = fetchResponse.body; + const lists = fetchResponse.body.data; expect(fetchResponse.status).toBe(200); const updatedList = lists.find( (l: ShoppingList) => l.shopping_list_id === newList.shopping_list_id, @@ -340,7 +340,7 @@ describe('User API Routes Integration Tests', () => { // Assert: Check the response expect(response.status).toBe(200); - const updatedProfile = response.body; + const updatedProfile = response.body.data; expect(updatedProfile.avatar_url).toBeDefined(); expect(updatedProfile.avatar_url).not.toBeNull(); expect(updatedProfile.avatar_url).toContain('/uploads/avatars/test-avatar'); @@ -349,7 +349,7 @@ describe('User API Routes Integration Tests', () => { const verifyResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); - const refetchedProfile = verifyResponse.body; + const refetchedProfile = verifyResponse.body.data; expect(refetchedProfile.avatar_url).toBe(updatedProfile.avatar_url); }); @@ -365,9 +365,9 @@ describe('User API Routes Integration Tests', () => { .attach('avatar', invalidFileBuffer, invalidFileName); // Assert: Check for a 400 Bad Request response. - // This error comes from the multer fileFilter configuration in the route. + // This error comes from ValidationError via the global errorHandler (sendError format). expect(response.status).toBe(400); - expect(response.body.message).toBe('Only image files are allowed!'); + expect(response.body.error.message).toBe('Only image files are allowed!'); }); it('should reject avatar upload for a file that is too large', async () => { diff --git a/src/tests/integration/user.routes.integration.test.ts b/src/tests/integration/user.routes.integration.test.ts index 625f81ed..0e61641b 100644 --- a/src/tests/integration/user.routes.integration.test.ts +++ b/src/tests/integration/user.routes.integration.test.ts @@ -43,9 +43,9 @@ describe('User Routes Integration Tests (/api/users)', () => { .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); - expect(response.body).toBeDefined(); - expect(response.body.user.email).toBe(testUser.user.email); - expect(response.body.role).toBe('user'); + expect(response.body.data).toBeDefined(); + expect(response.body.data.user.email).toBe(testUser.user.email); + expect(response.body.data.role).toBe('user'); }); it('should return 401 Unauthorized if no token is provided', async () => { @@ -63,14 +63,14 @@ describe('User Routes Integration Tests (/api/users)', () => { .send({ full_name: newName }); expect(response.status).toBe(200); - expect(response.body.full_name).toBe(newName); + expect(response.body.data.full_name).toBe(newName); // Verify the change by fetching the profile again const verifyResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); - expect(verifyResponse.body.full_name).toBe(newName); + expect(verifyResponse.body.data.full_name).toBe(newName); }); }); @@ -83,15 +83,15 @@ describe('User Routes Integration Tests (/api/users)', () => { .send(preferences); expect(response.status).toBe(200); - expect(response.body.preferences).toEqual(preferences); + expect(response.body.data.preferences).toEqual(preferences); // Verify the change by fetching the profile again const verifyResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); - expect(verifyResponse.body.preferences?.darkMode).toBe(true); - expect(verifyResponse.body.preferences?.unitSystem).toBe('metric'); + expect(verifyResponse.body.data.preferences?.darkMode).toBe(true); + expect(verifyResponse.body.data.preferences?.unitSystem).toBe('metric'); }); }); @@ -105,8 +105,8 @@ describe('User Routes Integration Tests (/api/users)', () => { .send({ name: listName }); expect(createResponse.status).toBe(201); - expect(createResponse.body.name).toBe(listName); - const listId = createResponse.body.shopping_list_id; + expect(createResponse.body.data.name).toBe(listName); + const listId = createResponse.body.data.shopping_list_id; expect(listId).toBeDefined(); // 2. Retrieve @@ -115,7 +115,7 @@ describe('User Routes Integration Tests (/api/users)', () => { .set('Authorization', `Bearer ${authToken}`); expect(getResponse.status).toBe(200); - const foundList = getResponse.body.find( + const foundList = getResponse.body.data.find( (l: { shopping_list_id: number }) => l.shopping_list_id === listId, ); expect(foundList).toBeDefined(); @@ -130,7 +130,7 @@ describe('User Routes Integration Tests (/api/users)', () => { const verifyResponse = await request .get('/api/users/shopping-lists') .set('Authorization', `Bearer ${authToken}`); - const notFoundList = verifyResponse.body.find( + const notFoundList = verifyResponse.body.data.find( (l: { shopping_list_id: number }) => l.shopping_list_id === listId, ); expect(notFoundList).toBeUndefined(); @@ -144,7 +144,7 @@ describe('User Routes Integration Tests (/api/users)', () => { .set('Authorization', `Bearer ${authToken}`) // Use owner's token .send({ name: listName }); expect(createListResponse.status).toBe(201); - const listId = createListResponse.body.shopping_list_id; + const listId = createListResponse.body.data.shopping_list_id; // Arrange: Create a second, "malicious" user. const maliciousEmail = `malicious-user-${Date.now()}@example.com`; @@ -163,7 +163,7 @@ describe('User Routes Integration Tests (/api/users)', () => { // Assert 1: The request should fail. A 404 is expected because the list is not found for this user. expect(addItemResponse.status).toBe(404); - expect(addItemResponse.body.message).toContain('Shopping list not found'); + expect(addItemResponse.body.error.message).toContain('Shopping list not found'); // Act 2: Malicious user attempts to delete the owner's list. const deleteResponse = await request @@ -172,7 +172,7 @@ describe('User Routes Integration Tests (/api/users)', () => { // Assert 2: This should also fail with a 404. expect(deleteResponse.status).toBe(404); - expect(deleteResponse.body.message).toContain('Shopping list not found'); + expect(deleteResponse.body.error.message).toContain('Shopping list not found'); // Act 3: Malicious user attempts to update an item on the owner's list. // First, the owner adds an item. @@ -181,7 +181,7 @@ describe('User Routes Integration Tests (/api/users)', () => { .set('Authorization', `Bearer ${authToken}`) // Owner's token .send({ customItemName: 'Legitimate Item' }); expect(ownerAddItemResponse.status).toBe(201); - const itemId = ownerAddItemResponse.body.shopping_list_item_id; + const itemId = ownerAddItemResponse.body.data.shopping_list_item_id; // Now, the malicious user tries to update it. const updateItemResponse = await request @@ -191,7 +191,7 @@ describe('User Routes Integration Tests (/api/users)', () => { // Assert 3: This should also fail with a 404. expect(updateItemResponse.status).toBe(404); - expect(updateItemResponse.body.message).toContain('Shopping list item not found'); + expect(updateItemResponse.body.error.message).toContain('Shopping list item not found'); // Cleanup the list created in this test await request @@ -210,7 +210,7 @@ describe('User Routes Integration Tests (/api/users)', () => { .post('/api/users/shopping-lists') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Item Test List' }); - listId = response.body.shopping_list_id; + listId = response.body.data.shopping_list_id; }); // Clean up the list after the item tests are done @@ -229,9 +229,9 @@ describe('User Routes Integration Tests (/api/users)', () => { .send({ customItemName: 'Test Item' }); expect(response.status).toBe(201); - expect(response.body.custom_item_name).toBe('Test Item'); - expect(response.body.shopping_list_item_id).toBeDefined(); - itemId = response.body.shopping_list_item_id; // Save for next tests + expect(response.body.data.custom_item_name).toBe('Test Item'); + expect(response.body.data.shopping_list_item_id).toBeDefined(); + itemId = response.body.data.shopping_list_item_id; // Save for next tests }); it('should update an item in a shopping list', async () => { @@ -242,8 +242,8 @@ describe('User Routes Integration Tests (/api/users)', () => { .send(updates); expect(response.status).toBe(200); - expect(response.body.is_purchased).toBe(true); - expect(response.body.quantity).toBe(5); + expect(response.body.data.is_purchased).toBe(true); + expect(response.body.data.quantity).toBe(5); }); it('should delete an item from a shopping list', async () => { diff --git a/src/tests/setup/integration-global-setup.ts b/src/tests/setup/integration-global-setup.ts index ee784173..c922fa67 100644 --- a/src/tests/setup/integration-global-setup.ts +++ b/src/tests/setup/integration-global-setup.ts @@ -2,14 +2,24 @@ import { execSync } from 'child_process'; import fs from 'node:fs/promises'; import path from 'path'; +import os from 'os'; import type { Server } from 'http'; import { logger } from '../../services/logger.server'; import { getPool } from '../../services/db/connection.db'; +// --- DEBUG: Log when this file is first loaded/parsed --- +const SETUP_LOAD_TIME = new Date().toISOString(); +console.error(`\n[GLOBAL-SETUP-DEBUG] Module loaded at ${SETUP_LOAD_TIME}`); +console.error(`[GLOBAL-SETUP-DEBUG] Current working directory: ${process.cwd()}`); +console.error(`[GLOBAL-SETUP-DEBUG] NODE_ENV: ${process.env.NODE_ENV}`); +console.error(`[GLOBAL-SETUP-DEBUG] __filename: ${import.meta.url}`); + // --- Centralized State for Integration Test Lifecycle --- let server: Server; // This will hold the single database pool instance for the entire test run. let globalPool: ReturnType | null = null; +// Temporary directory for test file storage (to avoid modifying committed fixtures) +let tempStorageDir: string | null = null; /** * Cleans all BullMQ queues to ensure no stale jobs from previous test runs. @@ -68,26 +78,28 @@ async function cleanAllQueues() { } export async function setup() { + console.error(`\n[GLOBAL-SETUP-DEBUG] ========================================`); + console.error(`[GLOBAL-SETUP-DEBUG] setup() function STARTED at ${new Date().toISOString()}`); + console.error(`[GLOBAL-SETUP-DEBUG] ========================================`); + // Ensure we are in the correct environment for these tests. process.env.NODE_ENV = 'test'; // Fix: Set the FRONTEND_URL globally for the test server instance process.env.FRONTEND_URL = 'https://example.com'; + // CRITICAL: Create a temporary directory for test file storage. + // This prevents tests from modifying or deleting committed fixture files. + // The temp directory is cleaned up in teardown(). + tempStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flyer-crawler-test-')); + const tempFlyerImagesDir = path.join(tempStorageDir, 'flyer-images'); + await fs.mkdir(path.join(tempFlyerImagesDir, 'icons'), { recursive: true }); + console.error(`[SETUP] Created temporary storage directory: ${tempFlyerImagesDir}`); + // CRITICAL: Set STORAGE_PATH before importing the server. // The multer middleware runs an IIFE on import that creates directories based on this path. - // If not set, it defaults to /var/www/.../flyer-images which won't exist in the test environment. - if (!process.env.STORAGE_PATH) { - // Use path relative to the project root (where tests run from) - process.env.STORAGE_PATH = path.resolve(process.cwd(), 'flyer-images'); - } - - // Ensure the storage directories exist before the server starts - try { - await fs.mkdir(path.join(process.env.STORAGE_PATH, 'icons'), { recursive: true }); - console.error(`[SETUP] Created storage directory: ${process.env.STORAGE_PATH}`); - } catch (error) { - console.error(`[SETUP] Warning: Could not create storage directory: ${error}`); - } + // Using a temp directory ensures test file operations don't affect committed files. + process.env.STORAGE_PATH = tempFlyerImagesDir; + console.error(`[SETUP] Set STORAGE_PATH to temporary directory: ${process.env.STORAGE_PATH}`); console.error(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`); console.error(`[SETUP] STORAGE_PATH: ${process.env.STORAGE_PATH}`); @@ -117,16 +129,37 @@ export async function setup() { globalPool = getPool(); // Fix: Dynamic import AFTER env vars are set + console.error(`[GLOBAL-SETUP-DEBUG] About to import server module...`); const appModule = await import('../../../server'); + console.error(`[GLOBAL-SETUP-DEBUG] Server module imported successfully`); const app = appModule.default; + console.error(`[GLOBAL-SETUP-DEBUG] App object type: ${typeof app}`); // Programmatically start the server within the same process. const port = process.env.PORT || 3001; - await new Promise((resolve) => { - server = app.listen(port, () => { - console.log(`✅ In-process test server started on port ${port}`); - resolve(); - }); + console.error(`[GLOBAL-SETUP-DEBUG] Attempting to start server on port ${port}...`); + + await new Promise((resolve, reject) => { + try { + server = app.listen(port, () => { + console.log(`✅ In-process test server started on port ${port}`); + console.error( + `[GLOBAL-SETUP-DEBUG] Server listen callback invoked at ${new Date().toISOString()}`, + ); + resolve(); + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + console.error(`[GLOBAL-SETUP-DEBUG] Server error event:`, err.message); + if (err.code === 'EADDRINUSE') { + console.error(`[GLOBAL-SETUP-DEBUG] Port ${port} is already in use!`); + } + reject(err); + }); + } catch (err) { + console.error(`[GLOBAL-SETUP-DEBUG] Error during app.listen:`, err); + reject(err); + } }); /** @@ -135,23 +168,41 @@ export async function setup() { */ const pingTestBackend = async (): Promise => { const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api'; + const pingUrl = `${apiUrl.replace('/api', '')}/api/health/ping`; + console.error(`[GLOBAL-SETUP-DEBUG] Pinging: ${pingUrl}`); try { - const response = await fetch(`${apiUrl.replace('/api', '')}/api/health/ping`); - if (!response.ok) return false; + const response = await fetch(pingUrl); + console.error(`[GLOBAL-SETUP-DEBUG] Ping response status: ${response.status}`); + if (!response.ok) { + console.error(`[GLOBAL-SETUP-DEBUG] Ping response not OK: ${response.statusText}`); + return false; + } // The ping endpoint returns JSON: { status: 'success', data: { message: 'pong' } } const json = await response.json(); + console.error(`[GLOBAL-SETUP-DEBUG] Ping response JSON:`, JSON.stringify(json)); return json?.data?.message === 'pong'; } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + console.error(`[GLOBAL-SETUP-DEBUG] Ping exception: ${errMsg}`); logger.debug({ error: e }, 'Ping failed while waiting for server, this is expected.'); return false; } }; + console.error( + `[GLOBAL-SETUP-DEBUG] Server started, beginning ping loop at ${new Date().toISOString()}`, + ); + console.error(`[GLOBAL-SETUP-DEBUG] Server address info:`, server.address()); + const maxRetries = 15; const retryDelay = 1000; for (let i = 0; i < maxRetries; i++) { + console.error(`[GLOBAL-SETUP-DEBUG] Ping attempt ${i + 1}/${maxRetries}`); if (await pingTestBackend()) { console.log('✅ Backend server is running and responsive.'); + console.error( + `[GLOBAL-SETUP-DEBUG] setup() function COMPLETED SUCCESSFULLY at ${new Date().toISOString()}`, + ); return; } console.log( @@ -160,6 +211,10 @@ export async function setup() { await new Promise((resolve) => setTimeout(resolve, retryDelay)); } + console.error(`[GLOBAL-SETUP-DEBUG] All ${maxRetries} ping attempts failed!`); + console.error(`[GLOBAL-SETUP-DEBUG] Server listening status: ${server.listening}`); + console.error(`[GLOBAL-SETUP-DEBUG] Server address: ${JSON.stringify(server.address())}`); + throw new Error('Backend server failed to start.'); } @@ -175,4 +230,13 @@ export async function teardown() { await globalPool.end(); console.log('✅ Global database pool teardown complete.'); } + // 3. Clean up the temporary storage directory. + if (tempStorageDir) { + try { + await fs.rm(tempStorageDir, { recursive: true, force: true }); + console.log(`✅ Cleaned up temporary storage directory: ${tempStorageDir}`); + } catch (error) { + console.error(`⚠️ Warning: Could not clean up temp directory ${tempStorageDir}:`, error); + } + } } diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 3134f6e3..37963358 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -2,6 +2,8 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import type { UserConfig } from 'vite'; import viteConfig from './vite.config'; +import * as fs from 'fs'; +import * as path from 'path'; // Ensure NODE_ENV is set to 'test' for all Vitest runs. process.env.NODE_ENV = 'test'; @@ -9,7 +11,21 @@ process.env.NODE_ENV = 'test'; // 1. Separate the 'test' config (which has Unit Test settings) // from the rest of the general Vite config (plugins, aliases, etc.) // DEBUG: Use console.error to ensure logs appear in CI/CD output -console.error('[DEBUG] Loading vitest.config.integration.ts...'); +console.error(`[DEBUG] Loading vitest.config.integration.ts at ${new Date().toISOString()}...`); +console.error(`[DEBUG] CWD: ${process.cwd()}`); + +// Check if the integration test directory exists and list its contents +const integrationTestDir = path.resolve(process.cwd(), 'src/tests/integration'); +try { + const files = fs.readdirSync(integrationTestDir); + console.error( + `[DEBUG] Integration test directory (${integrationTestDir}) contains ${files.length} files:`, + ); + files.forEach((f) => console.error(`[DEBUG] - ${f}`)); +} catch (e) { + console.error(`[DEBUG] ERROR: Could not read integration test directory: ${integrationTestDir}`); + console.error(`[DEBUG] Error: ${e instanceof Error ? e.message : String(e)}`); +} // Define a type that includes the 'test' property from Vitest's config. // This allows us to destructure it in a type-safe way without using 'as any'.