// src/tests/integration/admin.integration.test.ts import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; import supertest from 'supertest'; import { getPool } from '../../services/db/connection.db'; import type { UserProfile } from '../../types'; import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers'; import { cleanupDb } from '../utils/cleanup'; import { createStoreWithLocation, cleanupStoreLocations, type CreatedStoreLocation, } from '../utils/storeHelpers'; /** * @vitest-environment node */ describe('Admin API Routes Integration Tests', () => { let request: ReturnType; let adminToken: string; let adminUser: UserProfile; let regularUser: UserProfile; let regularUserToken: string; const createdUserIds: string[] = []; const createdStoreLocations: CreatedStoreLocation[] = []; const createdCorrectionIds: number[] = []; const createdFlyerIds: number[] = []; beforeAll(async () => { vi.stubEnv('FRONTEND_URL', 'https://example.com'); const app = (await import('../../../server')).default; request = supertest(app); // Create a fresh admin user and a regular user for this test suite // Using unique emails to prevent test pollution from other integration test files. ({ user: adminUser, token: adminToken } = await createAndLoginUser({ email: `admin-integration-${Date.now()}@test.com`, role: 'admin', fullName: 'Admin Test User', request, // Pass supertest request to ensure user is created in the test DB })); createdUserIds.push(adminUser.user.user_id); ({ user: regularUser, token: regularUserToken } = await createAndLoginUser({ email: `regular-integration-${Date.now()}@test.com`, fullName: 'Regular User', request, // Pass supertest request })); createdUserIds.push(regularUser.user.user_id); }); afterAll(async () => { vi.unstubAllEnvs(); await cleanupDb({ userIds: createdUserIds, suggestedCorrectionIds: createdCorrectionIds, flyerIds: createdFlyerIds, }); await cleanupStoreLocations(getPool(), createdStoreLocations); }); describe('GET /api/admin/stats', () => { it('should allow an admin to fetch application stats', async () => { const response = await request .get('/api/admin/stats') .set('Authorization', `Bearer ${adminToken}`); 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); } expect(stats).toBeDefined(); expect(stats).toHaveProperty('flyerCount'); expect(stats).toHaveProperty('userCount'); expect(stats).toHaveProperty('flyerItemCount'); }); it('should forbid a regular user from fetching application stats', async () => { const response = await request .get('/api/admin/stats') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); describe('GET /api/admin/stats/daily', () => { it('should allow an admin to fetch daily stats', async () => { const response = await request .get('/api/admin/stats/daily') .set('Authorization', `Bearer ${adminToken}`); 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 expect(dailyStats.length).toBe(30); expect(dailyStats[0]).toHaveProperty('date'); expect(dailyStats[0]).toHaveProperty('new_users'); expect(dailyStats[0]).toHaveProperty('new_flyers'); }); it('should forbid a regular user from fetching daily stats', async () => { const response = await request .get('/api/admin/stats/daily') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); describe('GET /api/admin/corrections', () => { it('should allow an admin to fetch suggested corrections', async () => { // This test just verifies access and correct response shape. // More detailed tests would require seeding corrections. const response = await request .get('/api/admin/corrections') .set('Authorization', `Bearer ${adminToken}`); const corrections = response.body.data; expect(corrections).toBeDefined(); expect(Array.isArray(corrections)).toBe(true); }); it('should forbid a regular user from fetching suggested corrections', async () => { const response = await request .get('/api/admin/corrections') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); describe('GET /api/admin/brands', () => { it('should allow an admin to fetch all brands', async () => { const response = await request .get('/api/admin/brands') .set('Authorization', `Bearer ${adminToken}`); const brands = response.body.data; expect(brands).toBeDefined(); expect(Array.isArray(brands)).toBe(true); // Even if no brands exist, it should return an array. // (We rely on seed or empty state here, which is fine for a read test, // but creating a brand would be strictly better if we wanted to assert length > 0) }); it('should forbid a regular user from fetching all brands', async () => { const response = await request .get('/api/admin/brands') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); const errorData = response.body.error; expect(errorData.message).toBe('Forbidden: Administrator access required.'); }); }); describe('Admin Data Modification Routes', () => { let testStoreId: number; let testFlyerItemId: number; let testCorrectionId: number; // Create a store and flyer once for all tests in this block. beforeAll(async () => { // Create a dummy store with location to ensure foreign keys exist const store = await createStoreWithLocation(getPool(), { name: `Admin Test Store - ${Date.now()}`, address: '100 Admin St', city: 'Toronto', province: 'ON', postalCode: 'M5V 1A1', }); testStoreId = store.storeId; createdStoreLocations.push(store); }); // Before each modification test, create a fresh flyer item and a correction for it. beforeEach(async () => { const flyerRes = await getPool().query( `INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum) VALUES ($1, 'admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`, // The checksum must be a unique 64-character string to satisfy the DB constraint. // We generate a dynamic string and pad it to 64 characters. [testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')], ); const flyerId = flyerRes.rows[0].flyer_id; createdFlyerIds.push(flyerId); const flyerItemRes = await getPool().query( `INSERT INTO public.flyer_items (flyer_id, item, price_display, price_in_cents, quantity) VALUES ($1, 'Test Item for Correction', '$1.99', 199, 'each') RETURNING flyer_item_id`, [flyerId], ); testFlyerItemId = flyerItemRes.rows[0].flyer_item_id; const correctionRes = await getPool().query( `INSERT INTO public.suggested_corrections (flyer_item_id, user_id, correction_type, suggested_value, status) VALUES ($1, $2, 'WRONG_PRICE', '250', 'pending') RETURNING suggested_correction_id`, [testFlyerItemId, adminUser.user.user_id], ); testCorrectionId = correctionRes.rows[0].suggested_correction_id; createdCorrectionIds.push(testCorrectionId); }); it('should allow an admin to approve a correction', async () => { // Act: Approve the correction. const response = await request .post(`/api/admin/corrections/${testCorrectionId}/approve`) .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(200); // Assert: Verify the flyer item's price was updated and the correction status changed. const { rows: itemRows } = await getPool().query( 'SELECT price_in_cents FROM public.flyer_items WHERE flyer_item_id = $1', [testFlyerItemId], ); expect(itemRows[0].price_in_cents).toBe(250); const { rows: correctionRows } = await getPool().query( 'SELECT status FROM public.suggested_corrections WHERE suggested_correction_id = $1', [testCorrectionId], ); expect(correctionRows[0].status).toBe('approved'); }); it('should allow an admin to reject a correction', async () => { // Act: Reject the correction. const response = await request .post(`/api/admin/corrections/${testCorrectionId}/reject`) .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(200); // Assert: Verify the correction status changed. const { rows: correctionRows } = await getPool().query( 'SELECT status FROM public.suggested_corrections WHERE suggested_correction_id = $1', [testCorrectionId], ); expect(correctionRows[0].status).toBe('rejected'); }); it('should allow an admin to update a correction', async () => { // Act: Update the suggested value of the correction. const response = await request .put(`/api/admin/corrections/${testCorrectionId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ suggested_value: '300' }); const updatedCorrection = response.body.data; // Assert: Verify the API response and the database state. expect(updatedCorrection.suggested_value).toBe('300'); const { rows } = await getPool().query( 'SELECT suggested_value FROM public.suggested_corrections WHERE suggested_correction_id = $1', [testCorrectionId], ); expect(rows[0].suggested_value).toBe('300'); }); it('should allow an admin to update a recipe status', async () => { // Create a recipe specifically for this test const recipeRes = await getPool().query( `INSERT INTO public.recipes (name, instructions, user_id) VALUES ('Admin Test Recipe', 'Cook it', $1) RETURNING recipe_id`, [regularUser.user.user_id], ); const recipeId = recipeRes.rows[0].recipe_id; // Act: Update the status to 'public'. const response = await request .put(`/api/admin/recipes/${recipeId}/status`) .set('Authorization', `Bearer ${adminToken}`) .send({ status: 'public' }); expect(response.status).toBe(200); // Assert: Verify the status was updated in the database. const { rows: updatedRecipeRows } = await getPool().query( 'SELECT status FROM public.recipes WHERE recipe_id = $1', [recipeId], ); expect(updatedRecipeRows[0].status).toBe('public'); }); }); describe('DELETE /api/admin/users/:id', () => { it("should allow an admin to delete another user's account", async () => { // Create a dedicated user for this deletion test to avoid affecting other tests const { user: userToDelete } = await createAndLoginUser({ email: `delete-target-${Date.now()}@test.com`, fullName: 'User To Delete', request, }); // Act: Call the delete endpoint as an admin. const response = await request .delete(`/api/admin/users/${userToDelete.user.user_id}`) .set('Authorization', `Bearer ${adminToken}`); // Assert: Check for a successful deletion status. expect(response.status).toBe(204); }); it('should prevent an admin from deleting their own account', async () => { // Act: Call the delete endpoint as the same admin user. const adminUserId = adminUser.user.user_id; const response = await request .delete(`/api/admin/users/${adminUserId}`) .set('Authorization', `Bearer ${adminToken}`); // Assert: // 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, ); } expect([400, 403]).toContain(response.status); 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 () => { // Arrange: Use a valid UUID that does not exist const notFoundUserId = '00000000-0000-0000-0000-000000000000'; const response = await request .delete(`/api/admin/users/${notFoundUserId}`) .set('Authorization', `Bearer ${adminToken}`); // Assert: Check for a 404 status code expect(response.status).toBe(404); }); }); describe('Queue Management Routes', () => { describe('GET /api/admin/queues/status', () => { it('should return queue status for all queues', async () => { const response = await request .get('/api/admin/queues/status') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeInstanceOf(Array); // Should have data for each queue if (response.body.data.length > 0) { const firstQueue = response.body.data[0]; expect(firstQueue).toHaveProperty('name'); expect(firstQueue).toHaveProperty('counts'); } }); it('should forbid regular users from viewing queue status', async () => { const response = await request .get('/api/admin/queues/status') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); expect(response.body.error.message).toBe('Forbidden: Administrator access required.'); }); }); describe('POST /api/admin/trigger/analytics-report', () => { it('should enqueue an analytics report job', async () => { const response = await request .post('/api/admin/trigger/analytics-report') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(202); // 202 Accepted for async job enqueue expect(response.body.success).toBe(true); expect(response.body.data.message).toContain('enqueued'); }); it('should forbid regular users from triggering analytics report', async () => { const response = await request .post('/api/admin/trigger/analytics-report') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); }); describe('POST /api/admin/trigger/weekly-analytics', () => { it('should enqueue a weekly analytics job', async () => { const response = await request .post('/api/admin/trigger/weekly-analytics') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(202); // 202 Accepted for async job enqueue expect(response.body.success).toBe(true); expect(response.body.data.message).toContain('enqueued'); }); it('should forbid regular users from triggering weekly analytics', async () => { const response = await request .post('/api/admin/trigger/weekly-analytics') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); }); describe('POST /api/admin/trigger/daily-deal-check', () => { it('should enqueue a daily deal check job', async () => { const response = await request .post('/api/admin/trigger/daily-deal-check') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(202); // 202 Accepted for async job trigger expect(response.body.success).toBe(true); expect(response.body.data.message).toContain('triggered'); }); it('should forbid regular users from triggering daily deal check', async () => { const response = await request .post('/api/admin/trigger/daily-deal-check') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); }); describe('POST /api/admin/system/clear-cache', () => { it('should clear the application cache', async () => { const response = await request .post('/api/admin/system/clear-cache') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.message).toContain('cleared'); }); it('should forbid regular users from clearing cache', async () => { const response = await request .post('/api/admin/system/clear-cache') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); }); describe('POST /api/admin/jobs/:queue/:id/retry', () => { it('should return validation error for invalid queue name', async () => { const response = await request .post('/api/admin/jobs/invalid-queue-name/1/retry') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(400); expect(response.body.success).toBe(false); expect(response.body.error.code).toBe('VALIDATION_ERROR'); }); it('should return 404 for non-existent job', async () => { const response = await request .post('/api/admin/jobs/flyer-processing/999999999/retry') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(404); expect(response.body.success).toBe(false); }); it('should forbid regular users from retrying jobs', async () => { const response = await request .post('/api/admin/jobs/flyer-processing/1/retry') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); }); }); describe('GET /api/admin/users', () => { it('should return all users for admin', async () => { const response = await request .get('/api/admin/users') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); // The endpoint returns { users: [...], total: N } expect(response.body.data).toHaveProperty('users'); expect(response.body.data).toHaveProperty('total'); expect(response.body.data.users).toBeInstanceOf(Array); expect(typeof response.body.data.total).toBe('number'); }); it('should forbid regular users from listing all users', async () => { const response = await request .get('/api/admin/users') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); }); describe('GET /api/admin/review/flyers', () => { it('should return pending review flyers for admin', async () => { const response = await request .get('/api/admin/review/flyers') .set('Authorization', `Bearer ${adminToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeInstanceOf(Array); }); it('should forbid regular users from viewing pending flyers', async () => { const response = await request .get('/api/admin/review/flyers') .set('Authorization', `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); }); });