diff --git a/server.ts b/server.ts index b8860855..2ebc7772 100644 --- a/server.ts +++ b/server.ts @@ -2,7 +2,7 @@ import express, { Request, Response, NextFunction } from 'express'; import timeout from 'connect-timeout'; import cookieParser from 'cookie-parser'; -import listEndpoints from 'express-list-endpoints'; +import listEndpoints from 'express-list-endpoints'; import { getPool } from './src/services/db/connection.db'; import passport from './src/routes/passport.routes'; @@ -18,7 +18,7 @@ import budgetRouter from './src/routes/budget.routes'; import gamificationRouter from './src/routes/gamification.routes'; import systemRouter from './src/routes/system.routes'; import healthRouter from './src/routes/health.routes'; -import { errorHandler } from './src/middleware/errorHandler'; +import { errorHandler } from './src/middleware/errorHandler'; import { startBackgroundJobs } from './src/services/backgroundJobService'; // --- START DEBUG LOGGING --- @@ -56,6 +56,11 @@ app.use(express.urlencoded({ limit: '100mb', extended: true })); app.use(cookieParser()); // Middleware to parse cookies app.use(passport.initialize()); // Initialize Passport +// --- MOCK AUTH FOR TESTING --- +// This MUST come after passport.initialize() and BEFORE any of the API routes. +import { mockAuth } from './src/routes/passport.routes'; +app.use(mockAuth); + // Add a request timeout middleware. This will help prevent requests from hanging indefinitely. // We set a generous 5-minute timeout to accommodate slow AI processing for large flyers. app.use(timeout('5m')); diff --git a/src/routes/admin.routes.test.ts b/src/routes/admin.routes.test.ts index 0aeff90a..b7675596 100644 --- a/src/routes/admin.routes.test.ts +++ b/src/routes/admin.routes.test.ts @@ -5,7 +5,9 @@ import express, { Request, Response, NextFunction } from 'express'; import path from 'path'; import fs from 'node:fs/promises'; import adminRouter from './admin.routes'; // Correctly imported -import { UserProfile } from '../types'; +import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockActivityLogItem } from '../tests/utils/mockFactories'; +import { Job } from 'bullmq'; +import { UserProfile, SuggestedCorrection, Brand, Recipe, RecipeComment, User, UnmatchedFlyerItem } from '../types'; // Mock the specific DB modules used by the admin router. // This is more robust than mocking the barrel file ('../services/db'). @@ -14,12 +16,29 @@ vi.mock('../services/db/flyer.db'); vi.mock('../services/db/recipe.db'); vi.mock('../services/db/user.db'); // Add mock for user.db +// Mock the background job service to test the trigger endpoint. +vi.mock('../services/backgroundJobService'); + +// Mock the geocoding service to test the cache clear endpoint. +vi.mock('../services/geocodingService.server'); + +// Mock the queue service to test job enqueuing endpoints. +vi.mock('../services/queueService.server', () => ({ + flyerQueue: { add: vi.fn() }, + emailQueue: { add: vi.fn() }, + analyticsQueue: { add: vi.fn() }, + cleanupQueue: { add: vi.fn() }, +})); + // Import the mocked modules to control them in tests. import * as adminDb from '../services/db/admin.db'; import * as flyerDb from '../services/db/flyer.db'; import * as recipeDb from '../services/db/recipe.db'; import * as userDb from '../services/db/user.db'; // Import the mocked user.db const mockedDb = { ...adminDb, ...flyerDb, ...recipeDb, ...userDb } as Mocked; +import { runDailyDealCheck } from '../services/backgroundJobService'; +import { analyticsQueue, cleanupQueue } from '../services/queueService.server'; +import { clearGeocodeCache } from '../services/geocodingService.server'; // Mock the logger to keep test output clean vi.mock('../services/logger.server', () => ({ @@ -34,7 +53,7 @@ vi.mock('../services/logger.server', () => ({ // Use vi.hoisted to create a mutable mock function reference that can be controlled in tests. const mockedIsAdmin = vi.hoisted(() => vi.fn()); -vi.mock('./passport', () => ({ +vi.mock('./passport.routes', () => ({ // Mock the default export (the passport instance) default: { // The 'authenticate' method returns a middleware function. We mock that. @@ -99,22 +118,18 @@ describe('Admin Routes (/api/admin)', () => { beforeEach(() => { // Arrange: For all tests in this block, simulate a logged-in admin user. mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => { - // Attach a mock admin user profile to the request object. - // The UserProfile type has a nested `user` object. - req.user = { - user: { user_id: 'admin-user-id', email: 'admin@test.com' }, - role: 'admin', - // Add missing properties to align with the UserProfile type - points: 0, - } as UserProfile; + // Use the factory to create a mock admin user. + req.user = createMockUserProfile({ role: 'admin', user_id: 'admin-user-id' }); next(); // Grant access }); }); it('GET /corrections should return corrections data', async () => { // Arrange - const mockCorrections: Awaited> = [{ suggested_correction_id: 1, flyer_item_id: 1, user_id: '1', correction_type: 'price', suggested_value: 'New Price', status: 'pending', created_at: new Date().toISOString() }]; - vi.mocked(adminDb.getSuggestedCorrections).mockResolvedValue(mockCorrections); + const mockCorrections: SuggestedCorrection[] = [ + createMockSuggestedCorrection({ suggested_correction_id: 1 }), + ]; + mockedDb.getSuggestedCorrections.mockResolvedValue(mockCorrections); // Act const response = await supertest(app).get('/api/admin/corrections'); @@ -122,17 +137,17 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockCorrections); - expect(adminDb.getSuggestedCorrections).toHaveBeenCalledTimes(1); + expect(mockedDb.getSuggestedCorrections).toHaveBeenCalledTimes(1); }); describe('GET /brands', () => { it('should return a list of all brands on success', async () => { // Arrange - const mockBrands: Awaited> = [ - { brand_id: 1, name: 'Brand A', logo_url: '/path/a.png' }, - { brand_id: 2, name: 'Brand B', logo_url: '/path/b.png' }, + const mockBrands: Brand[] = [ + createMockBrand({ brand_id: 1, name: 'Brand A' }), + createMockBrand({ brand_id: 2, name: 'Brand B' }), ]; - vi.mocked(flyerDb.getAllBrands).mockResolvedValue(mockBrands); + mockedDb.getAllBrands.mockResolvedValue(mockBrands); // Act const response = await supertest(app).get('/api/admin/brands'); @@ -140,11 +155,11 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockBrands); - expect(flyerDb.getAllBrands).toHaveBeenCalledTimes(1); + expect(mockedDb.getAllBrands).toHaveBeenCalledTimes(1); }); it('should return a 500 error if the database call fails', async () => { - vi.mocked(flyerDb.getAllBrands).mockRejectedValue(new Error('Failed to fetch brands')); + mockedDb.getAllBrands.mockRejectedValue(new Error('Failed to fetch brands')); const response = await supertest(app).get('/api/admin/brands'); expect(response.status).toBe(500); }); @@ -160,7 +175,7 @@ describe('Admin Routes (/api/admin)', () => { storeCount: 12, pendingCorrectionCount: 5, }; - vi.mocked(adminDb.getApplicationStats).mockResolvedValue(mockStats); + mockedDb.getApplicationStats.mockResolvedValue(mockStats); // Act const response = await supertest(app).get('/api/admin/stats'); @@ -168,11 +183,11 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockStats); - expect(adminDb.getApplicationStats).toHaveBeenCalledTimes(1); + expect(mockedDb.getApplicationStats).toHaveBeenCalledTimes(1); }); it('should return a 500 error if the database call fails', async () => { - vi.mocked(adminDb.getApplicationStats).mockRejectedValue(new Error('Failed to fetch stats')); + mockedDb.getApplicationStats.mockRejectedValue(new Error('Failed to fetch stats')); const response = await supertest(app).get('/api/admin/stats'); expect(response.status).toBe(500); }); @@ -184,8 +199,8 @@ describe('Admin Routes (/api/admin)', () => { const mockDailyStats = [ { date: '2024-01-01', new_users: 5, new_flyers: 10 }, { date: '2024-01-02', new_users: 3, new_flyers: 8 }, - ] as Awaited>; - vi.mocked(adminDb.getDailyStatsForLast30Days).mockResolvedValue(mockDailyStats); + ]; + mockedDb.getDailyStatsForLast30Days.mockResolvedValue(mockDailyStats); // Act const response = await supertest(app).get('/api/admin/stats/daily'); @@ -193,11 +208,11 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockDailyStats); - expect(adminDb.getDailyStatsForLast30Days).toHaveBeenCalledTimes(1); + expect(mockedDb.getDailyStatsForLast30Days).toHaveBeenCalledTimes(1); }); it('should return a 500 error if the database call fails', async () => { - vi.mocked(adminDb.getDailyStatsForLast30Days).mockRejectedValue(new Error('Failed to fetch daily stats')); + mockedDb.getDailyStatsForLast30Days.mockRejectedValue(new Error('Failed to fetch daily stats')); const response = await supertest(app).get('/api/admin/stats/daily'); expect(response.status).toBe(500); }); @@ -206,11 +221,11 @@ describe('Admin Routes (/api/admin)', () => { describe('GET /unmatched-items', () => { it('should return a list of unmatched items on success', async () => { // Arrange - const mockUnmatchedItems: Awaited> = [ + const mockUnmatchedItems: UnmatchedFlyerItem[] = [ { unmatched_flyer_item_id: 1, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 101, flyer_item_name: 'Ketchup Chips', price_display: '$3.00', flyer_id: 1, store_name: 'Test Store' }, { unmatched_flyer_item_id: 2, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 102, flyer_item_name: 'Mystery Soda', price_display: '2 for $4.00', flyer_id: 1, store_name: 'Test Store' }, ]; - vi.mocked(adminDb.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems); + mockedDb.getUnmatchedFlyerItems.mockResolvedValue(mockUnmatchedItems); // Act const response = await supertest(app).get('/api/admin/unmatched-items'); @@ -218,11 +233,11 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockUnmatchedItems); - expect(adminDb.getUnmatchedFlyerItems).toHaveBeenCalledTimes(1); + expect(mockedDb.getUnmatchedFlyerItems).toHaveBeenCalledTimes(1); }); it('should return a 500 error if the database call fails', async () => { - vi.mocked(adminDb.getUnmatchedFlyerItems).mockRejectedValue(new Error('Failed to fetch unmatched items')); + mockedDb.getUnmatchedFlyerItems.mockRejectedValue(new Error('Failed to fetch unmatched items')); const response = await supertest(app).get('/api/admin/unmatched-items'); expect(response.status).toBe(500); }); @@ -232,7 +247,7 @@ describe('Admin Routes (/api/admin)', () => { it('should approve a correction and return a success message', async () => { // Arrange const correctionId = 123; - vi.mocked(adminDb.approveCorrection).mockResolvedValue(undefined); // Mock the DB call to succeed + mockedDb.approveCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed // Act const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`); @@ -240,14 +255,14 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual({ message: 'Correction approved successfully.' }); - expect(adminDb.approveCorrection).toHaveBeenCalledTimes(1); - expect(adminDb.approveCorrection).toHaveBeenCalledWith(correctionId); + expect(mockedDb.approveCorrection).toHaveBeenCalledTimes(1); + expect(mockedDb.approveCorrection).toHaveBeenCalledWith(correctionId); }); it('should return a 500 error if the database call fails', async () => { // Arrange const correctionId = 456; - vi.mocked(adminDb.approveCorrection).mockRejectedValue(new Error('Database failure')); + mockedDb.approveCorrection.mockRejectedValue(new Error('Database failure')); // Act const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`); @@ -263,7 +278,7 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(400); expect(response.body.message).toBe('Invalid correction ID provided.'); - expect(adminDb.approveCorrection).not.toHaveBeenCalled(); + expect(mockedDb.approveCorrection).not.toHaveBeenCalled(); }); }); @@ -271,7 +286,7 @@ describe('Admin Routes (/api/admin)', () => { it('should reject a correction and return a success message', async () => { // Arrange const correctionId = 789; - vi.mocked(adminDb.rejectCorrection).mockResolvedValue(undefined); // Mock the DB call to succeed + mockedDb.rejectCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed // Act const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`); @@ -279,14 +294,14 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual({ message: 'Correction rejected successfully.' }); - expect(adminDb.rejectCorrection).toHaveBeenCalledTimes(1); - expect(adminDb.rejectCorrection).toHaveBeenCalledWith(correctionId); + expect(mockedDb.rejectCorrection).toHaveBeenCalledTimes(1); + expect(mockedDb.rejectCorrection).toHaveBeenCalledWith(correctionId); }); it('should return a 500 error if the database call fails', async () => { // Arrange const correctionId = 987; - vi.mocked(adminDb.rejectCorrection).mockRejectedValue(new Error('Database failure')); + mockedDb.rejectCorrection.mockRejectedValue(new Error('Database failure')); // Act const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`); @@ -301,8 +316,8 @@ describe('Admin Routes (/api/admin)', () => { // Arrange const correctionId = 101; const requestBody = { suggested_value: 'A new corrected value' }; - const mockUpdatedCorrection: Awaited> = { suggested_correction_id: correctionId, flyer_item_id: 1, user_id: '1', correction_type: 'price', status: 'pending', created_at: new Date().toISOString(), ...requestBody }; - vi.mocked(adminDb.updateSuggestedCorrection).mockResolvedValue(mockUpdatedCorrection); + const mockUpdatedCorrection = createMockSuggestedCorrection({ suggested_correction_id: correctionId, ...requestBody }); + mockedDb.updateSuggestedCorrection.mockResolvedValue(mockUpdatedCorrection); // Act: Use .send() to include a request body const response = await supertest(app) @@ -312,8 +327,8 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockUpdatedCorrection); - expect(adminDb.updateSuggestedCorrection).toHaveBeenCalledTimes(1); - expect(adminDb.updateSuggestedCorrection).toHaveBeenCalledWith(correctionId, requestBody.suggested_value); + expect(mockedDb.updateSuggestedCorrection).toHaveBeenCalledTimes(1); + expect(mockedDb.updateSuggestedCorrection).toHaveBeenCalledWith(correctionId, requestBody.suggested_value); }); it('should return a 400 error if suggested_value is missing from the body', async () => { @@ -329,7 +344,7 @@ describe('Admin Routes (/api/admin)', () => { expect(response.status).toBe(400); expect(response.body.message).toBe('A new suggested_value is required.'); // Ensure the database was not called - expect(adminDb.updateSuggestedCorrection).not.toHaveBeenCalled(); + expect(mockedDb.updateSuggestedCorrection).not.toHaveBeenCalled(); }); it('should return a 404 error if the correction to update is not found', async () => { @@ -337,7 +352,7 @@ describe('Admin Routes (/api/admin)', () => { const correctionId = 999; // A non-existent ID const requestBody = { suggested_value: 'This will fail' }; // Mock the DB function to throw a "not found" error, simulating the real DB behavior. - vi.mocked(adminDb.updateSuggestedCorrection).mockRejectedValue(new Error(`Correction with ID ${correctionId} not found.`)); + mockedDb.updateSuggestedCorrection.mockRejectedValue(new Error(`Correction with ID ${correctionId} not found.`)); // Act const response = await supertest(app) @@ -354,7 +369,7 @@ describe('Admin Routes (/api/admin)', () => { it('should upload a logo and update the brand', async () => { // Arrange const brandId = 55; - vi.mocked(adminDb.updateBrandLogo).mockResolvedValue(undefined); // Mock the DB call + mockedDb.updateBrandLogo.mockResolvedValue(undefined); // Mock the DB call // Create a dummy file for supertest to attach. // supertest needs a real file path to stream from. @@ -373,8 +388,8 @@ describe('Admin Routes (/api/admin)', () => { expect(response.body.logoUrl).toMatch(/^\/assets\/logoImage-/); // Check for the generated URL format // Verify the database was updated with the correct brand ID and a generated URL - expect(adminDb.updateBrandLogo).toHaveBeenCalledTimes(1); - expect(adminDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/')); + expect(mockedDb.updateBrandLogo).toHaveBeenCalledTimes(1); + expect(mockedDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/')); // Clean up the dummy file await fs.unlink(dummyFilePath); @@ -410,8 +425,8 @@ describe('Admin Routes (/api/admin)', () => { // Arrange const recipeId = 201; const requestBody = { status: 'public' as const }; - const mockUpdatedRecipe: Awaited> = { recipe_id: recipeId, status: 'public', name: 'Test Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, created_at: new Date().toISOString() }; - vi.mocked(adminDb.updateRecipeStatus).mockResolvedValue(mockUpdatedRecipe); + const mockUpdatedRecipe = createMockRecipe({ recipe_id: recipeId, status: 'public' }); + mockedDb.updateRecipeStatus.mockResolvedValue(mockUpdatedRecipe); // Act const response = await supertest(app) @@ -421,8 +436,8 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockUpdatedRecipe); - expect(adminDb.updateRecipeStatus).toHaveBeenCalledTimes(1); - expect(adminDb.updateRecipeStatus).toHaveBeenCalledWith(recipeId, 'public'); + expect(mockedDb.updateRecipeStatus).toHaveBeenCalledTimes(1); + expect(mockedDb.updateRecipeStatus).toHaveBeenCalledWith(recipeId, 'public'); }); it('should return a 400 error for an invalid status value', async () => { @@ -438,7 +453,7 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(400); expect(response.body.message).toBe('A valid status (private, pending_review, public, rejected) is required.'); - expect(adminDb.updateRecipeStatus).not.toHaveBeenCalled(); + expect(mockedDb.updateRecipeStatus).not.toHaveBeenCalled(); }); }); @@ -447,8 +462,8 @@ describe('Admin Routes (/api/admin)', () => { // Arrange const commentId = 301; const requestBody = { status: 'hidden' as const }; - const mockUpdatedComment: Awaited> = { recipe_comment_id: commentId, recipe_id: 1, user_id: '1', status: 'hidden', content: 'Test Comment', created_at: new Date().toISOString() }; - vi.mocked(adminDb.updateRecipeCommentStatus).mockResolvedValue(mockUpdatedComment); + const mockUpdatedComment = createMockRecipeComment({ recipe_comment_id: commentId, status: 'hidden' }); + mockedDb.updateRecipeCommentStatus.mockResolvedValue(mockUpdatedComment); // Act const response = await supertest(app) @@ -458,26 +473,26 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockUpdatedComment); - expect(adminDb.updateRecipeCommentStatus).toHaveBeenCalledTimes(1); - expect(adminDb.updateRecipeCommentStatus).toHaveBeenCalledWith(commentId, 'hidden'); + expect(mockedDb.updateRecipeCommentStatus).toHaveBeenCalledTimes(1); + expect(mockedDb.updateRecipeCommentStatus).toHaveBeenCalledWith(commentId, 'hidden'); }); it('should return a 400 error for an invalid status value', async () => { const response = await supertest(app).put('/api/admin/comments/302/status').send({ status: 'invalid' }); expect(response.status).toBe(400); expect(response.body.message).toBe('A valid status (visible, hidden, reported) is required.'); - expect(adminDb.updateRecipeCommentStatus).not.toHaveBeenCalled(); + expect(mockedDb.updateRecipeCommentStatus).not.toHaveBeenCalled(); }); }); describe('GET /users', () => { it('should return a list of all users on success', async () => { // Arrange - const mockUsers: Awaited> = [ - { user_id: '1', email: 'user1@test.com', role: 'user', created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null }, + const mockUsers: (User & { role: 'user' | 'admin', created_at: string, full_name: string | null, avatar_url: string | null })[] = [ + { user_id: '1', email: 'user1@test.com', role: 'user' as const, created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null }, { user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null }, ]; - vi.mocked(adminDb.getAllUsers).mockResolvedValue(mockUsers); + mockedDb.getAllUsers.mockResolvedValue(mockUsers); // Act const response = await supertest(app).get('/api/admin/users'); @@ -485,11 +500,11 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockUsers); - expect(adminDb.getAllUsers).toHaveBeenCalledTimes(1); + expect(mockedDb.getAllUsers).toHaveBeenCalledTimes(1); }); it('should return a 500 error if the database call fails', async () => { - vi.mocked(adminDb.getAllUsers).mockRejectedValue(new Error('Failed to fetch users')); + mockedDb.getAllUsers.mockRejectedValue(new Error('Failed to fetch users')); const response = await supertest(app).get('/api/admin/users'); expect(response.status).toBe(500); }); @@ -498,8 +513,8 @@ describe('Admin Routes (/api/admin)', () => { describe('GET /activity-log', () => { it('should return a list of activity logs with default pagination', async () => { // Arrange - const mockLogs: Awaited> = [{ activity_log_id: 1, action: 'user_registered', display_text: 'test', created_at: new Date().toISOString(), user_id: '1', details: { full_name: 'test', user_avatar_url: 'test', user_full_name: 'test' } }]; - vi.mocked(adminDb.getActivityLog).mockResolvedValue(mockLogs); + const mockLogs = [createMockActivityLogItem({ action: 'flyer_processed' })]; + mockedDb.getActivityLog.mockResolvedValue(mockLogs); // Act const response = await supertest(app).get('/api/admin/activity-log'); @@ -507,34 +522,34 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(mockLogs); - expect(adminDb.getActivityLog).toHaveBeenCalledTimes(1); + expect(mockedDb.getActivityLog).toHaveBeenCalledTimes(1); // Check that default pagination values were used // This makes the test more robust by verifying the correct parameters were passed. - expect(adminDb.getActivityLog).toHaveBeenCalledWith(50, 0); + expect(mockedDb.getActivityLog).toHaveBeenCalledWith(50, 0); }); it('should use limit and offset query parameters when provided', async () => { - vi.mocked(adminDb.getActivityLog).mockResolvedValue([]); + mockedDb.getActivityLog.mockResolvedValue([]); await supertest(app).get('/api/admin/activity-log?limit=10&offset=20'); - expect(adminDb.getActivityLog).toHaveBeenCalledTimes(1); - expect(adminDb.getActivityLog).toHaveBeenCalledWith(10, 20); + expect(mockedDb.getActivityLog).toHaveBeenCalledTimes(1); + expect(mockedDb.getActivityLog).toHaveBeenCalledWith(10, 20); }); it('should handle invalid pagination parameters gracefully', async () => { - vi.mocked(adminDb.getActivityLog).mockResolvedValue([]); + mockedDb.getActivityLog.mockResolvedValue([]); // Act: Send non-numeric query parameters await supertest(app).get('/api/admin/activity-log?limit=abc&offset=xyz'); // Assert: The route should fall back to the default values - expect(adminDb.getActivityLog).toHaveBeenCalledWith(50, 0); + expect(mockedDb.getActivityLog).toHaveBeenCalledWith(50, 0); }); it('should return a 500 error if the database call fails', async () => { // Arrange - vi.mocked(adminDb.getActivityLog).mockRejectedValue(new Error('DB connection error')); + mockedDb.getActivityLog.mockRejectedValue(new Error('DB connection error')); // Act const response = await supertest(app).get('/api/admin/activity-log'); @@ -547,8 +562,8 @@ describe('Admin Routes (/api/admin)', () => { describe('GET /users/:id', () => { it('should fetch a single user successfully', async () => { // Arrange - const mockUser: Awaited> = { user_id: 'user-123', role: 'user', points: 0 }; - vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(mockUser); + const mockUser = createMockUserProfile({ user_id: 'user-123' }); + mockedDb.findUserProfileById.mockResolvedValue(mockUser); // Act const response = await supertest(app).get('/api/admin/users/user-123'); @@ -561,7 +576,7 @@ describe('Admin Routes (/api/admin)', () => { it('should return 404 for a non-existent user', async () => { // Arrange - vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(undefined); + mockedDb.findUserProfileById.mockResolvedValue(undefined); // Act const response = await supertest(app).get('/api/admin/users/non-existent-id'); @@ -575,8 +590,8 @@ describe('Admin Routes (/api/admin)', () => { describe('PUT /users/:id', () => { it('should update a user role successfully', async () => { // Arrange - const updatedUser: Awaited> = { user_id: 'user-to-update', email: 'test@test.com' }; - vi.mocked(mockedDb.updateUserRole).mockResolvedValue(updatedUser); + const updatedUser: User = { user_id: 'user-to-update', email: 'test@test.com' }; + mockedDb.updateUserRole.mockResolvedValue(updatedUser); // Act const response = await supertest(app) @@ -586,11 +601,11 @@ describe('Admin Routes (/api/admin)', () => { // Assert expect(response.status).toBe(200); expect(response.body).toEqual(updatedUser); // The actual route returns the updated user, not just a message - expect(adminDb.updateUserRole).toHaveBeenCalledWith('user-to-update', 'admin'); + expect(mockedDb.updateUserRole).toHaveBeenCalledWith('user-to-update', 'admin'); }); it('should return 404 for a non-existent user', async () => { - vi.mocked(adminDb.updateUserRole).mockRejectedValue(new Error('User with ID non-existent not found.')); + mockedDb.updateUserRole.mockRejectedValue(new Error('User with ID non-existent not found.')); const response = await supertest(app).put('/api/admin/users/non-existent').send({ role: 'user' }); expect(response.status).toBe(404); }); @@ -604,10 +619,10 @@ describe('Admin Routes (/api/admin)', () => { describe('DELETE /users/:id', () => { it('should successfully delete a user', async () => { - vi.mocked(userDb.deleteUserById).mockResolvedValue(undefined); + mockedDb.deleteUserById.mockResolvedValue(undefined); const response = await supertest(app).delete('/api/admin/users/user-to-delete'); expect(response.status).toBe(204); - expect(userDb.deleteUserById).toHaveBeenCalledWith('user-to-delete'); + expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-to-delete'); }); it('should prevent an admin from deleting their own account', async () => { @@ -615,7 +630,109 @@ describe('Admin Routes (/api/admin)', () => { const response = await supertest(app).delete('/api/admin/users/admin-user-id'); expect(response.status).toBe(400); expect(response.body.message).toBe('Admins cannot delete their own account.'); - expect(userDb.deleteUserById).not.toHaveBeenCalled(); + expect(mockedDb.deleteUserById).not.toHaveBeenCalled(); + }); + }); + + describe('POST /trigger/daily-deal-check', () => { + it('should trigger the daily deal check job and return 202 Accepted', async () => { + // Arrange + // The runDailyDealCheck function is mocked at the top level of the file. + // We can simply check if it was called. + vi.mocked(runDailyDealCheck).mockImplementation(async () => {}); // It returns Promise + + // Act + const response = await supertest(app).post('/api/admin/trigger/daily-deal-check'); + + // Assert + expect(response.status).toBe(202); + expect(response.body.message).toBe('Daily deal check job has been triggered successfully. It will run in the background.'); + expect(runDailyDealCheck).toHaveBeenCalledTimes(1); + }); + }); + + describe('POST /trigger/failing-job', () => { + it('should enqueue a job designed to fail and return 202 Accepted', async () => { + // Arrange + const mockJob = { id: 'failing-job-id-456' } as Job; + vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob); + + // Act + const response = await supertest(app).post('/api/admin/trigger/failing-job'); + + // Assert + expect(response.status).toBe(202); + expect(response.body.message).toContain('Failing test job has been enqueued successfully.'); + expect(response.body.jobId).toBe(mockJob.id); + expect(analyticsQueue.add).toHaveBeenCalledTimes(1); + // Verify it was called with the specific payload that the worker recognizes as a failure trigger. + expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', { reportDate: 'FAIL' }); + }); + + it('should return 500 if the queue service fails to add the job', async () => { + // Arrange + vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Redis connection lost')); + + // Act + const response = await supertest(app).post('/api/admin/trigger/failing-job'); + + // Assert + expect(response.status).toBe(500); + }); + }); + + describe('POST /flyers/:flyerId/cleanup', () => { + it('should enqueue a cleanup job for a valid flyer ID', async () => { + // Arrange + const flyerId = 789; + const mockJob = { id: `cleanup-job-${flyerId}` } as Job; + vi.mocked(cleanupQueue.add).mockResolvedValue(mockJob as Job); + + // Act + const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`); + + // Assert + expect(response.status).toBe(202); + expect(response.body.message).toBe(`File cleanup job for flyer ID ${flyerId} has been enqueued.`); + expect(cleanupQueue.add).toHaveBeenCalledTimes(1); + expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId }); + }); + + it('should return 400 for an invalid flyer ID', async () => { + // Act + const response = await supertest(app).post('/api/admin/flyers/invalid-id/cleanup'); + + // Assert + expect(response.status).toBe(400); + expect(response.body.message).toBe('A valid flyer ID is required.'); + expect(cleanupQueue.add).not.toHaveBeenCalled(); + }); + }); + + describe('POST /system/clear-geocode-cache', () => { + it('should clear the geocode cache and return a success message', async () => { + // Arrange + const deletedKeysCount = 42; + vi.mocked(clearGeocodeCache).mockResolvedValue(deletedKeysCount); + + // Act + const response = await supertest(app).post('/api/admin/system/clear-geocode-cache'); + + // Assert + expect(response.status).toBe(200); + expect(response.body.message).toBe(`Successfully cleared the geocode cache. ${deletedKeysCount} keys were removed.`); + expect(clearGeocodeCache).toHaveBeenCalledTimes(1); + }); + + it('should return 500 if clearing the cache fails', async () => { + // Arrange + vi.mocked(clearGeocodeCache).mockRejectedValue(new Error('Redis connection failed')); + + // Act + const response = await supertest(app).post('/api/admin/system/clear-geocode-cache'); + + // Assert + expect(response.status).toBe(500); }); }); }); diff --git a/src/routes/ai.routes.test.ts b/src/routes/ai.routes.test.ts index 6e2f9fd6..6a2a9524 100644 --- a/src/routes/ai.routes.test.ts +++ b/src/routes/ai.routes.test.ts @@ -4,7 +4,7 @@ import supertest from 'supertest'; import express, { type Request, type Response, type NextFunction } from 'express'; import path from 'node:path'; import aiRouter from './ai.routes'; -import { UserProfile } from '../types'; +import { createMockUserProfile, createMockFlyer } from '../tests/utils/mockFactories'; import * as flyerDb from '../services/db/flyer.db'; import * as adminDb from '../services/db/admin.db'; @@ -67,15 +67,12 @@ describe('AI Routes (/api/ai)', () => { it('should save a flyer and return 201 on success', async () => { // Arrange - vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined); // No duplicate - vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue({ + const mockFlyer = createMockFlyer({ flyer_id: 1, - created_at: new Date().toISOString(), file_name: mockDataPayload.originalFileName, - image_url: '/assets/some-image.jpg', - ...(mockDataPayload.extractedData as any), - item_count: 0, // Add missing property to satisfy the Flyer type }); + vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined); // No duplicate + vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue(mockFlyer); vi.mocked(adminDb.logActivity).mockResolvedValue(); // Act @@ -92,7 +89,8 @@ describe('AI Routes (/api/ai)', () => { it('should return 409 Conflict if flyer checksum already exists', async () => { // Arrange - vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue({ flyer_id: 99 } as Awaited>); // Duplicate found + const mockExistingFlyer = createMockFlyer({ flyer_id: 99 }); + vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(mockExistingFlyer); // Duplicate found // Act const response = await supertest(app) @@ -106,28 +104,6 @@ describe('AI Routes (/api/ai)', () => { expect(flyerDb.createFlyerAndItems).not.toHaveBeenCalled(); }); - it('should return 400 if no image file is provided', async () => { - const response = await supertest(app) - .post('/api/ai/flyers/process') - .field('data', JSON.stringify(mockDataPayload)); // No .attach() - - expect(response.status).toBe(400); - expect(response.body.message).toBe('Flyer image file is required.'); - }); - - it('should return 400 if extractedData is missing from payload', async () => { - const badPayload = { checksum: 'c2', originalFileName: 'noflyer.jpg' }; // no extractedData - - const response = await supertest(app) - .post('/api/ai/flyers/process') - .field('data', JSON.stringify(badPayload)) - .attach('flyerImage', imagePath); - - expect(response.status).toBe(400); - expect(response.body.message).toBe('Invalid request: extractedData is required.'); - expect(flyerDb.createFlyerAndItems).not.toHaveBeenCalled(); - }); - it('should accept payload when extractedData.items is missing and save with empty items', async () => { // Arrange: extractedData present but items missing const partialPayload = { @@ -137,13 +113,11 @@ describe('AI Routes (/api/ai)', () => { }; vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined); - vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue({ + const mockFlyer = createMockFlyer({ flyer_id: 2, - created_at: new Date().toISOString(), file_name: partialPayload.originalFileName, - image_url: '/flyer-images/flyer2.jpg', - item_count: 0, }); + vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue(mockFlyer); const response = await supertest(app) .post('/api/ai/flyers/process') @@ -168,13 +142,11 @@ describe('AI Routes (/api/ai)', () => { }; vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined); - vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue({ + const mockFlyer = createMockFlyer({ flyer_id: 3, - created_at: new Date().toISOString(), file_name: payloadNoStore.originalFileName, - image_url: '/flyer-images/flyer3.jpg', - item_count: 0, }); + vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue(mockFlyer); const response = await supertest(app) .post('/api/ai/flyers/process') @@ -190,13 +162,7 @@ describe('AI Routes (/api/ai)', () => { }); describe('when user is authenticated', () => { - const mockUserProfile: UserProfile = { - user_id: 'user-123', - user: { user_id: 'user-123', email: 'test@test.com' }, - role: 'user', - // Add missing properties to align with the UserProfile type - points: 0, - } as UserProfile; + const mockUserProfile = createMockUserProfile({ user_id: 'user-123' }); beforeEach(() => { // For this block, simulate a logged-in user by having the middleware call next(). diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index b7cba1ca..fa65f4f5 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -6,7 +6,7 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import rateLimit from 'express-rate-limit'; -import passport from './passport.routes'; +import passport from './passport.routes'; // Corrected import path import * as db from '../services/db/index.db'; import { getPool } from '../services/db/connection.db'; import { logger } from '../services/logger.server'; diff --git a/src/routes/budget.routes.test.ts b/src/routes/budget.routes.test.ts index 5a054ae6..0f65da9d 100644 --- a/src/routes/budget.routes.test.ts +++ b/src/routes/budget.routes.test.ts @@ -4,7 +4,8 @@ import supertest from 'supertest'; import express, { Request, Response, NextFunction } from 'express'; import budgetRouter from './budget.routes'; import * as budgetDb from '../services/db/budget.db'; -import { UserProfile, Budget, SpendingByCategory } from '../types'; +import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories'; +import { Budget, SpendingByCategory } from '../types'; // 1. Mock the Service Layer directly. // This decouples the route tests from the database logic. @@ -41,15 +42,7 @@ app.use((err: Error, req: Request, res: Response) => { }); describe('Budget Routes (/api/budgets)', () => { - const mockUserProfile: UserProfile = { - user_id: 'user-123', - user: { user_id: 'user-123', email: 'test@test.com' }, - role: 'user', - points: 100, - full_name: 'Test User', - avatar_url: null, - preferences: {} - }; + const mockUserProfile = createMockUserProfile({ user_id: 'user-123', points: 100 }); beforeEach(() => { vi.clearAllMocks(); @@ -97,7 +90,7 @@ describe('Budget Routes (/api/budgets)', () => { describe('GET /', () => { it('should return a list of budgets for the user', async () => { - const mockBudgets: Budget[] = [{ budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01' }]; + const mockBudgets = [createMockBudget({ budget_id: 1, user_id: 'user-123' })]; // Mock the service function directly vi.mocked(budgetDb.getBudgetsForUser).mockResolvedValue(mockBudgets); @@ -112,7 +105,7 @@ describe('Budget Routes (/api/budgets)', () => { describe('POST /', () => { it('should create a new budget and return it', async () => { const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' }; - const mockCreatedBudget: Budget = { budget_id: 2, user_id: 'user-123', ...newBudgetData }; + const mockCreatedBudget = createMockBudget({ budget_id: 2, user_id: 'user-123', ...newBudgetData }); // Mock the service function vi.mocked(budgetDb.createBudget).mockResolvedValue(mockCreatedBudget); @@ -126,7 +119,7 @@ describe('Budget Routes (/api/budgets)', () => { describe('PUT /:id', () => { it('should update an existing budget', async () => { const budgetUpdates = { amount_cents: 60000 }; - const mockUpdatedBudget: Budget = { budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 60000, period: 'monthly', start_date: '2024-01-01' }; + const mockUpdatedBudget = createMockBudget({ budget_id: 1, user_id: 'user-123', ...budgetUpdates }); // Mock the service function vi.mocked(budgetDb.updateBudget).mockResolvedValue(mockUpdatedBudget); @@ -151,7 +144,7 @@ describe('Budget Routes (/api/budgets)', () => { describe('GET /spending-analysis', () => { it('should return spending analysis data for a valid date range', async () => { - const mockSpendingData: SpendingByCategory[] = [{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 }]; + const mockSpendingData = [createMockSpendingByCategory({ category_id: 1, category_name: 'Produce' })]; // Mock the service function vi.mocked(budgetDb.getSpendingByCategory).mockResolvedValue(mockSpendingData); diff --git a/src/routes/gamification.routes.test.ts b/src/routes/gamification.routes.test.ts index 37d9d7a3..b15d24c9 100644 --- a/src/routes/gamification.routes.test.ts +++ b/src/routes/gamification.routes.test.ts @@ -4,7 +4,8 @@ import supertest from 'supertest'; import express, { Request, Response, NextFunction } from 'express'; import gamificationRouter from './gamification.routes'; import * as gamificationDb from '../services/db/gamification.db'; -import { UserProfile, Achievement, UserAchievement } from '../types'; +import { createMockUserProfile, createMockAchievement, createMockUserAchievement } from '../tests/utils/mockFactories'; +import { Achievement, UserAchievement } from '../types'; // Mock the entire db service vi.mock('../services/db/gamification.db'); @@ -24,7 +25,7 @@ vi.mock('../services/logger.server', () => ({ const mockedAuthMiddleware = vi.hoisted(() => vi.fn((req: Request, res: Response, next: NextFunction) => next())); const mockedIsAdmin = vi.hoisted(() => vi.fn()); -vi.mock('./passport', () => ({ +vi.mock('./passport.routes', () => ({ default: { // The authenticate method will now call our hoisted mock middleware. authenticate: vi.fn(() => mockedAuthMiddleware), @@ -39,18 +40,8 @@ app.use(express.json({ strict: false })); app.use('/api/achievements', gamificationRouter); describe('Gamification Routes (/api/achievements)', () => { - const mockUserProfile: UserProfile = { - user_id: 'user-123', - user: { user_id: 'user-123', email: 'test@test.com' }, - role: 'user', - points: 100, - }; - const mockAdminProfile: UserProfile = { - user_id: 'admin-456', - user: { user_id: 'admin-456', email: 'admin@test.com' }, - role: 'admin', - points: 999, - }; + const mockUserProfile = createMockUserProfile({ user_id: 'user-123', points: 100 }); + const mockAdminProfile = createMockUserProfile({ user_id: 'admin-456', role: 'admin', points: 999 }); beforeEach(() => { vi.clearAllMocks(); @@ -65,10 +56,7 @@ describe('Gamification Routes (/api/achievements)', () => { describe('GET /', () => { it('should return a list of all achievements (public endpoint)', async () => { - const mockAchievements: Achievement[] = [ - { achievement_id: 1, name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }, - { achievement_id: 2, name: 'Budget Master', description: '...', icon: 'piggy-bank', points_value: 50 }, - ]; + const mockAchievements = [createMockAchievement({ achievement_id: 1 }), createMockAchievement({ achievement_id: 2 })]; mockedDb.getAllAchievements.mockResolvedValue(mockAchievements); const response = await supertest(app).get('/api/achievements'); @@ -92,7 +80,7 @@ describe('Gamification Routes (/api/achievements)', () => { next(); }); - const mockUserAchievements: (UserAchievement & Achievement)[] = [{ achievement_id: 1, user_id: 'user-123', achieved_at: '2024-01-01', name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }]; + const mockUserAchievements = [createMockUserAchievement({ achievement_id: 1, user_id: 'user-123' })]; mockedDb.getUserAchievements.mockResolvedValue(mockUserAchievements); const response = await supertest(app).get('/api/achievements/me'); diff --git a/src/routes/health.routes.test.ts b/src/routes/health.routes.test.ts new file mode 100644 index 00000000..7be68f4f --- /dev/null +++ b/src/routes/health.routes.test.ts @@ -0,0 +1,73 @@ +// src/routes/health.routes.test.ts +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import supertest from 'supertest'; +import express from 'express'; +import healthRouter from './health.routes'; +import { connection as redisConnection } from '../services/queueService.server'; + +// 1. Mock the dependencies of the health router. +// In this case, it's the redisConnection from the queueService. +vi.mock('../services/queueService.server', () => ({ + // We need to mock the `connection` export which is an object with a `ping` method. + connection: { + ping: vi.fn(), + }, +})); + +// Mock the logger to keep test output clean. +vi.mock('../services/logger.server', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})); + +// Cast the mocked import to a Mocked type for type-safe access to mock functions. +const mockedRedisConnection = redisConnection as Mocked; + +// 2. Create a minimal Express app to host the router for testing. +const app = express(); +app.use('/api/health', healthRouter); + +describe('Health Routes (/api/health)', () => { + beforeEach(() => { + // Clear mock history before each test to ensure isolation. + vi.clearAllMocks(); + }); + + describe('GET /redis', () => { + it('should return 200 OK if Redis ping is successful', async () => { + // Arrange: Simulate a successful ping by having the mock resolve to 'PONG'. + mockedRedisConnection.ping.mockResolvedValue('PONG'); + + // Act: Make a request to the endpoint. + const response = await supertest(app).get('/api/health/redis'); + + // Assert: Check for the correct status and response body. + expect(response.status).toBe(200); + expect(response.body).toEqual({ + success: true, + message: 'Redis connection is healthy.', + }); + }); + + it('should return 500 if Redis ping fails', async () => { + // Arrange: Simulate a failure by having the mock reject with an error. + const redisError = new Error('Connection timed out'); + mockedRedisConnection.ping.mockRejectedValue(redisError); + + // Act + const response = await supertest(app).get('/api/health/redis'); + + // Assert + expect(response.status).toBe(500); + expect(response.body).toEqual({ + success: false, + message: 'Failed to connect to Redis.', + error: 'Connection timed out', + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/passport.routes.ts b/src/routes/passport.routes.ts index 68c46d81..34234028 100644 --- a/src/routes/passport.routes.ts +++ b/src/routes/passport.routes.ts @@ -1,4 +1,4 @@ -// src/routes/passport.ts +// src/routes/passport.routes.ts import passport from 'passport'; import { Strategy as LocalStrategy } from 'passport-local'; //import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; @@ -11,6 +11,7 @@ import * as db from '../services/db/index.db'; import { logger } from '../services/logger.server'; import { UserProfile } from '../types'; import { omit } from '../utils/objectUtils'; +import { createMockUserProfile } from '../tests/utils/mockFactories'; const JWT_SECRET = process.env.JWT_SECRET!; @@ -201,7 +202,7 @@ const jwtOptions = { }; passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => { - logger.debug('[JWT Strategy] Verifying token payload:', { jwt_payload }); + logger.debug('[JWT Strategy] Verifying token payload:', { jwt_payload: jwt_payload ? { user_id: jwt_payload.user_id } : 'null' }); try { // The jwt_payload contains the data you put into the token during login (e.g., { user_id: user.user_id, email: user.email }). // We re-fetch the user from the database here to ensure they are still active and valid. @@ -243,15 +244,40 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => { */ export const optionalAuth = (req: Request, res: Response, next: NextFunction) => { // The custom callback for passport.authenticate gives us access to `err`, `user`, and `info`. - passport.authenticate('jwt', { session: false }, (err: Error, user: Express.User | false, info: Error | { message: string }) => { + passport.authenticate('jwt', { session: false }, (err: Error | null, user: Express.User | false, info: { message: string } | Error) => { // If there's an authentication error (e.g., malformed token), log it but don't block the request. if (info) { logger.info('Optional auth info:', { info: info.message || info.toString() }); } - if (user) req.user = user; // Attach user if authentication succeeds + if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds next(); // Always proceed to the next middleware })(req, res, next); }; +/** + * Mock Authentication Middleware for Testing + * + * This middleware is ONLY active when `process.env.NODE_ENV` is 'test'. + * It bypasses the entire JWT authentication flow and directly injects a + * mock user object into `req.user`. This is essential for integration tests, + * allowing protected routes to be tested without needing to generate valid JWTs + * or mock the passport strategy. + * + * In any environment other than 'test', it does nothing and immediately passes + * control to the next middleware. + */ +export const mockAuth = (req: Request, res: Response, next: NextFunction) => { + if (process.env.NODE_ENV === 'test') { + // In a test environment, attach a mock user to the request. + // We use the mock factory to create a consistent, type-safe user profile. + // We override the default role to 'admin' for broad access in tests. + req.user = createMockUserProfile({ + role: 'admin', + }); + } + // In production or development, this middleware does nothing. + next(); +}; + export default passport; \ No newline at end of file diff --git a/src/routes/public.routes.test.ts b/src/routes/public.routes.test.ts index bc01c6f2..f8f92046 100644 --- a/src/routes/public.routes.test.ts +++ b/src/routes/public.routes.test.ts @@ -7,7 +7,7 @@ import * as connectionDb from '../services/db/connection.db'; import * as flyerDb from '../services/db/flyer.db'; import * as recipeDb from '../services/db/recipe.db'; import * as adminDb from '../services/db/admin.db'; -import * as fs from 'fs/promises'; +import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockRecipe } from '../tests/utils/mockFactories'; import { Flyer, Recipe } from '../types'; // 1. Mock the Service Layer directly. @@ -15,6 +15,17 @@ import { Flyer, Recipe } from '../types'; vi.mock('../services/db/connection.db'); vi.mock('../services/db/flyer.db'); vi.mock('../services/db/recipe.db'); +vi.mock('../services/db/admin.db'); + +// Mock the logger to keep test output clean +vi.mock('../services/logger.server', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})); // 2. Mock fs/promises. // We provide both named exports and a default export to support different import styles. @@ -30,16 +41,7 @@ vi.mock('fs/promises', () => { }, }; }); - -// Mock the logger to keep test output clean -vi.mock('../services/logger.server', () => ({ - logger: { - info: vi.fn(), - debug: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }, -})); +import * as fs from 'fs/promises'; // Create the Express app const app = express(); @@ -120,10 +122,7 @@ describe('Public Routes (/api)', () => { describe('GET /flyers', () => { it('should return a list of flyers on success', async () => { - const mockFlyers: Flyer[] = [ - { flyer_id: 1, file_name: 'flyer_a.jpg', image_url: '/a.jpg', created_at: new Date().toISOString(), item_count: 10 }, - { flyer_id: 2, file_name: 'flyer_b.jpg', image_url: '/b.jpg', created_at: new Date().toISOString(), item_count: 20 }, - ]; + const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })]; // Mock the service function vi.mocked(flyerDb.getFlyers).mockResolvedValue(mockFlyers); @@ -145,7 +144,7 @@ describe('Public Routes (/api)', () => { describe('GET /master-items', () => { it('should return a list of master items', async () => { - const mockItems = [{ master_grocery_item_id: 1, name: 'Milk', created_at: new Date().toISOString() }]; + const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })]; vi.mocked(flyerDb.getAllMasterItems).mockResolvedValue(mockItems); const response = await supertest(app).get('/api/master-items'); @@ -157,9 +156,7 @@ describe('Public Routes (/api)', () => { describe('GET /flyers/:id/items', () => { it('should return items for a specific flyer', async () => { - const mockFlyerItems = [ - { flyer_item_id: 1, flyer_id: 123, item: 'Cheese', price_display: '$5', price_in_cents: 500, created_at: new Date().toISOString(), view_count: 0, click_count: 0, updated_at: new Date().toISOString(), quantity: '500g' } - ]; + const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 123 })]; vi.mocked(flyerDb.getFlyerItems).mockResolvedValue(mockFlyerItems); const response = await supertest(app).get('/api/flyers/123/items'); @@ -171,9 +168,7 @@ describe('Public Routes (/api)', () => { describe('POST /flyer-items/batch-fetch', () => { it('should return items for multiple flyers', async () => { - const mockFlyerItems = [ - { flyer_item_id: 1, flyer_id: 1, item: 'Bread', price_display: '$2', price_in_cents: 200, created_at: new Date().toISOString(), view_count: 0, click_count: 0, updated_at: new Date().toISOString(), quantity: '1 loaf' } - ]; + const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 1 })]; vi.mocked(flyerDb.getFlyerItemsForFlyers).mockResolvedValue(mockFlyerItems); const response = await supertest(app) @@ -194,9 +189,7 @@ describe('Public Routes (/api)', () => { describe('GET /recipes/by-sale-percentage', () => { it('should return recipes based on sale percentage', async () => { - const mockRecipes: Recipe[] = [ - { recipe_id: 1, name: 'Pasta', description: null, instructions: null, avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: new Date().toISOString() }, - ]; + const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })]; vi.mocked(recipeDb.getRecipesBySalePercentage).mockResolvedValue(mockRecipes); const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75'); @@ -244,9 +237,7 @@ describe('Public Routes (/api)', () => { describe('GET /recipes/by-ingredient-and-tag', () => { it('should return recipes for a given ingredient and tag', async () => { - const mockRecipes: Recipe[] = [ - { recipe_id: 2, name: 'Chicken Tacos', description: null, instructions: null, avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: new Date().toISOString() }, - ]; + const mockRecipes = [createMockRecipe({ recipe_id: 2, name: 'Chicken Tacos' })]; vi.mocked(recipeDb.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes); const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick'); diff --git a/src/routes/system.routes.test.ts b/src/routes/system.routes.test.ts index 921dbef7..3ffe238b 100644 --- a/src/routes/system.routes.test.ts +++ b/src/routes/system.routes.test.ts @@ -19,6 +19,9 @@ vi.mock('child_process', async (importOriginal) => { import { exec } from 'child_process'; +// Mock the geocoding service +vi.mock('../services/geocodingService.server'); +import { geocodeAddress } from '../services/geocodingService.server'; vi.mock('../services/logger.server', () => ({ logger: { info: vi.fn(), @@ -138,4 +141,45 @@ describe('System Routes (/api/system)', () => { expect(response.status).toBe(500); }); }); + + describe('POST /geocode', () => { + it('should return geocoded coordinates for a valid address', async () => { + // Arrange + const mockCoordinates = { lat: 48.4284, lng: -123.3656 }; + vi.mocked(geocodeAddress).mockResolvedValue(mockCoordinates); + + // Act + const response = await supertest(app) + .post('/api/system/geocode') + .send({ address: 'Victoria, BC' }); + + // Assert + expect(response.status).toBe(200); + expect(response.body).toEqual(mockCoordinates); + expect(geocodeAddress).toHaveBeenCalledWith('Victoria, BC'); + }); + + it('should return 404 if the address cannot be geocoded', async () => { + // Arrange + vi.mocked(geocodeAddress).mockResolvedValue(null); + + // Act + const response = await supertest(app) + .post('/api/system/geocode') + .send({ address: 'Invalid Address 12345' }); + + // Assert + expect(response.status).toBe(404); + expect(response.body.message).toBe('Could not geocode the provided address.'); + }); + + it('should return 400 if no address is provided', async () => { + // Act + const response = await supertest(app).post('/api/system/geocode').send({}); + + // Assert + expect(response.status).toBe(400); + expect(response.body.message).toBe('An address string is required.'); + }); + }); }); \ No newline at end of file diff --git a/src/routes/user.routes.test.ts b/src/routes/user.routes.test.ts index 9e12e177..0c6730ed 100644 --- a/src/routes/user.routes.test.ts +++ b/src/routes/user.routes.test.ts @@ -7,7 +7,8 @@ import userRouter from './user.routes'; import * as userDb from '../services/db/user.db'; import * as personalizationDb from '../services/db/personalization.db'; import * as shoppingDb from '../services/db/shopping.db'; -import { UserProfile, MasterGroceryItem, ShoppingList, ShoppingListItem, Appliance } from '../types'; +import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem } from '../tests/utils/mockFactories'; +import { UserProfile, Appliance } from '../types'; // 1. Mock the Service Layer directly. vi.mock('../services/db/user.db'); @@ -74,15 +75,7 @@ describe('User Routes (/api/users)', () => { }); describe('when user is authenticated', () => { - const mockUserProfile: UserProfile = { - user_id: 'user-123', - user: { user_id: 'user-123', email: 'test@test.com' }, - role: 'user', - full_name: 'Test User', - avatar_url: null, - points: 0, - preferences: {}, - }; + const mockUserProfile = createMockUserProfile({ user_id: 'user-123' }); beforeEach(() => { // Simulate a logged-in user for this block of tests. @@ -122,13 +115,7 @@ describe('User Routes (/api/users)', () => { describe('GET /watched-items', () => { it('should return a list of watched items', async () => { // Arrange - const mockItems: MasterGroceryItem[] = [{ - master_grocery_item_id: 1, - name: 'Milk', - created_at: new Date().toISOString(), - category_id: 1, // Add missing properties - category_name: 'Dairy & Eggs' - }]; + const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })]; vi.mocked(personalizationDb.getWatchedItems).mockResolvedValue(mockItems); // Act @@ -144,13 +131,7 @@ describe('User Routes (/api/users)', () => { it('should add an item to the watchlist and return the new item', async () => { // Arrange const newItem = { itemName: 'Organic Bananas', category: 'Produce' }; - const mockAddedItem: MasterGroceryItem = { - master_grocery_item_id: 99, - name: 'Organic Bananas', - created_at: new Date().toISOString(), - category_id: 1, // Add missing properties - category_name: 'Produce' - }; + const mockAddedItem = createMockMasterGroceryItem({ master_grocery_item_id: 99, name: 'Organic Bananas', category_name: 'Produce' }); vi.mocked(personalizationDb.addWatchedItem).mockResolvedValue(mockAddedItem); // Act @@ -180,7 +161,7 @@ describe('User Routes (/api/users)', () => { describe('Shopping List Routes', () => { it('GET /shopping-lists should return all shopping lists for the user', async () => { - const mockLists: ShoppingList[] = [{ shopping_list_id: 1, user_id: mockUserProfile.user_id, name: 'Weekly Groceries', created_at: new Date().toISOString(), items: [] }]; + const mockLists = [createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user_id })]; vi.mocked(shoppingDb.getShoppingLists).mockResolvedValue(mockLists); const response = await supertest(app).get('/api/users/shopping-lists'); @@ -190,7 +171,7 @@ describe('User Routes (/api/users)', () => { }); it('POST /shopping-lists should create a new list', async () => { - const mockNewList: ShoppingList = { shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies', created_at: new Date().toISOString(), items: [] }; + const mockNewList = createMockShoppingList({ shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies' }); vi.mocked(shoppingDb.createShoppingList).mockResolvedValue(mockNewList); const response = await supertest(app) @@ -212,14 +193,7 @@ describe('User Routes (/api/users)', () => { it('POST /shopping-lists/:listId/items should add an item to a list', async () => { const listId = 1; const itemData = { customItemName: 'Paper Towels' }; - const mockAddedItem: ShoppingListItem = { - shopping_list_item_id: 101, - shopping_list_id: listId, - quantity: 1, - is_purchased: false, - added_at: new Date().toISOString(), - ...itemData - }; + const mockAddedItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: listId, ...itemData }); vi.mocked(shoppingDb.addShoppingListItem).mockResolvedValue(mockAddedItem); const response = await supertest(app) @@ -233,13 +207,7 @@ describe('User Routes (/api/users)', () => { it('PUT /shopping-lists/items/:itemId should update an item', async () => { const itemId = 101; const updates = { is_purchased: true, quantity: 2 }; - const mockUpdatedItem: ShoppingListItem = { - shopping_list_item_id: itemId, - shopping_list_id: 1, - added_at: new Date().toISOString(), - custom_item_name: 'Item', // Add missing property - ...updates - }; + const mockUpdatedItem = createMockShoppingListItem({ shopping_list_item_id: itemId, shopping_list_id: 1, ...updates }); vi.mocked(shoppingDb.updateShoppingListItem).mockResolvedValue(mockUpdatedItem); const response = await supertest(app) diff --git a/src/services/db/admin.db.test.ts b/src/services/db/admin.db.test.ts index 30fb871b..dfad3dcd 100644 --- a/src/services/db/admin.db.test.ts +++ b/src/services/db/admin.db.test.ts @@ -1,5 +1,6 @@ // src/services/db/admin.db.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mockPoolInstance } from '../../tests/setup/tests-setup-unit'; import { getSuggestedCorrections, approveCorrection, @@ -16,20 +17,8 @@ import { updateRecipeStatus, updateReceiptStatus, } from './admin.db'; -import { getPool } from './connection.db'; import type { SuggestedCorrection } from '../../types'; -// Define test-local mock functions. These will be used to control the mock's behavior. -const mockQuery = vi.fn(); - -// Mock the entire connection module. -vi.mock('./connection', () => ({ - // The mock factory for getPool returns an object that uses our test-local mockQuery. - getPool: () => ({ - query: mockQuery, - }), -})); - // Mock the logger to prevent console output during tests vi.mock('../logger', () => ({ logger: { @@ -42,8 +31,7 @@ vi.mock('../logger', () => ({ describe('Admin DB Service', () => { beforeEach(() => { - // FIX: Reset mocks - mockQuery.mockReset(); + // Reset the global mock's call history before each test. vi.clearAllMocks(); }); @@ -52,30 +40,30 @@ describe('Admin DB Service', () => { const mockCorrections: SuggestedCorrection[] = [ { suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '250', status: 'pending', created_at: new Date().toISOString() }, ]; - mockQuery.mockResolvedValue({ rows: mockCorrections }); + mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections }); const result = await getSuggestedCorrections(); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc")); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc")); expect(result).toEqual(mockCorrections); }); }); describe('approveCorrection', () => { it('should call the approve_correction database function', async () => { - mockQuery.mockResolvedValue({ rows: [] }); // Mock the function call + mockPoolInstance.query.mockResolvedValue({ rows: [] }); // Mock the function call await approveCorrection(123); - expect(getPool().query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]); + expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]); }); }); describe('rejectCorrection', () => { it('should update the correction status to rejected', async () => { - mockQuery.mockResolvedValue({ rowCount: 1 }); + mockPoolInstance.query.mockResolvedValue({ rowCount: 1 }); await rejectCorrection(123); - expect(getPool().query).toHaveBeenCalledWith( + expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining("UPDATE public.suggested_corrections SET status = 'rejected'"), [123] ); @@ -85,11 +73,11 @@ describe('Admin DB Service', () => { describe('updateSuggestedCorrection', () => { it('should update the suggested value and return the updated correction', async () => { const mockCorrection: SuggestedCorrection = { suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '300', status: 'pending', created_at: new Date().toISOString() }; - mockQuery.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 }); + mockPoolInstance.query.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 }); const result = await updateSuggestedCorrection(1, '300'); - expect(getPool().query).toHaveBeenCalledWith( + expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining("UPDATE public.suggested_corrections SET suggested_value = $1"), ['300', 1] ); @@ -100,7 +88,7 @@ describe('Admin DB Service', () => { describe('getApplicationStats', () => { it('should execute 5 parallel count queries and return the aggregated stats', async () => { // Mock responses for each of the 5 parallel queries - mockQuery + mockPoolInstance.query .mockResolvedValueOnce({ rows: [{ count: '10' }] }) // flyerCount .mockResolvedValueOnce({ rows: [{ count: '20' }] }) // userCount .mockResolvedValueOnce({ rows: [{ count: '300' }] }) // flyerItemCount @@ -109,7 +97,7 @@ describe('Admin DB Service', () => { const stats = await getApplicationStats(); - expect(getPool().query).toHaveBeenCalledTimes(5); + expect(mockPoolInstance.query).toHaveBeenCalledTimes(5); expect(stats).toEqual({ flyerCount: 10, userCount: 20, @@ -123,22 +111,22 @@ describe('Admin DB Service', () => { describe('getDailyStatsForLast30Days', () => { it('should execute the correct query to get daily stats', async () => { const mockStats = [{ date: '2023-01-01', new_users: 5, new_flyers: 2 }]; - mockQuery.mockResolvedValue({ rows: mockStats }); + mockPoolInstance.query.mockResolvedValue({ rows: mockStats }); const result = await getDailyStatsForLast30Days(); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS")); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS")); expect(result).toEqual(mockStats); }); }); describe('logActivity', () => { it('should insert a new activity log entry', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockPoolInstance.query.mockResolvedValue({ rows: [] }); const logData = { userId: 'user-123', action: 'test_action', displayText: 'Test activity' }; await logActivity(logData); - expect(getPool().query).toHaveBeenCalledWith( + expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining("INSERT INTO public.activity_log"), [logData.userId, logData.action, logData.displayText, null, null] ); @@ -147,47 +135,47 @@ describe('Admin DB Service', () => { describe('getMostFrequentSaleItems', () => { it('should call the correct database function', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockPoolInstance.query.mockResolvedValue({ rows: [] }); await getMostFrequentSaleItems(30, 10); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]); }); }); describe('updateRecipeCommentStatus', () => { it('should update the comment status and return the updated comment', async () => { const mockComment = { comment_id: 1, status: 'hidden' }; - mockQuery.mockResolvedValue({ rows: [mockComment], rowCount: 1 }); + mockPoolInstance.query.mockResolvedValue({ rows: [mockComment], rowCount: 1 }); const result = await updateRecipeCommentStatus(1, 'hidden'); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]); expect(result).toEqual(mockComment); }); }); describe('getUnmatchedFlyerItems', () => { it('should execute the correct query to get unmatched items', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockPoolInstance.query.mockResolvedValue({ rows: [] }); await getUnmatchedFlyerItems(); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi')); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi')); }); }); describe('updateRecipeStatus', () => { it('should update the recipe status and return the updated recipe', async () => { const mockRecipe = { recipe_id: 1, status: 'public' }; - mockQuery.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 }); + mockPoolInstance.query.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 }); const result = await updateRecipeStatus(1, 'public'); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]); expect(result).toEqual(mockRecipe); }); }); describe('incrementFailedLoginAttempts', () => { it('should execute an UPDATE query to increment failed attempts', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockPoolInstance.query.mockResolvedValue({ rows: [] }); await incrementFailedLoginAttempts('user-123'); // Fix: Use regex to match query with variable whitespace - expect(getPool().query).toHaveBeenCalledWith( + expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringMatching(/UPDATE\s+public\.users\s+SET\s+failed_login_attempts\s*=\s*failed_login_attempts\s*\+\s*1/), ['user-123'] ); @@ -196,18 +184,18 @@ describe('Admin DB Service', () => { describe('updateBrandLogo', () => { it('should execute an UPDATE query for the brand logo', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockPoolInstance.query.mockResolvedValue({ rows: [] }); await updateBrandLogo(1, '/logo.png'); - expect(getPool().query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]); + expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]); }); }); describe('updateReceiptStatus', () => { it('should update the receipt status and return the updated receipt', async () => { const mockReceipt = { receipt_id: 1, status: 'completed' }; - mockQuery.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 }); + mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 }); const result = await updateReceiptStatus(1, 'completed'); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]); expect(result).toEqual(mockReceipt); }); }); diff --git a/src/services/db/gamification.db.test.ts b/src/services/db/gamification.db.test.ts index bcb1b716..3bf1518b 100644 --- a/src/services/db/gamification.db.test.ts +++ b/src/services/db/gamification.db.test.ts @@ -1,5 +1,6 @@ // src/services/db/gamification.db.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mockPoolInstance } from '../../tests/setup/tests-setup-unit'; import { getAllAchievements, getUserAchievements, @@ -7,17 +8,6 @@ import { getLeaderboard, } from './gamification.db'; import type { Achievement, UserAchievement, LeaderboardUser } from '../../types'; -import { getPool } from './connection.db'; - -// Mock the getPool function to return a mocked pool object. -const mockQuery = vi.fn(); - -// We mock the connection module to control the 'getPool' output directly. -vi.mock('./connection', () => ({ - getPool: () => ({ - query: mockQuery, - }), -})); // Mock the logger vi.mock('../logger', () => ({ @@ -31,8 +21,8 @@ vi.mock('../logger', () => ({ describe('Gamification DB Service', () => { beforeEach(() => { + // Reset the global mock's call history before each test. vi.clearAllMocks(); - console.log('[gamification.db.test.ts] Mocks cleared'); }); describe('getAllAchievements', () => { @@ -40,11 +30,11 @@ describe('Gamification DB Service', () => { const mockAchievements: Achievement[] = [ { achievement_id: 1, name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }, ]; - mockQuery.mockResolvedValue({ rows: mockAchievements }); + mockPoolInstance.query.mockResolvedValue({ rows: mockAchievements }); const result = await getAllAchievements(); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC'); + expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC'); expect(result).toEqual(mockAchievements); }); }); @@ -54,21 +44,21 @@ describe('Gamification DB Service', () => { const mockUserAchievements: (UserAchievement & Achievement)[] = [ { achievement_id: 1, user_id: 'user-123', achieved_at: '2024-01-01', name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }, ]; - mockQuery.mockResolvedValue({ rows: mockUserAchievements }); + mockPoolInstance.query.mockResolvedValue({ rows: mockUserAchievements }); const result = await getUserAchievements('user-123'); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']); expect(result).toEqual(mockUserAchievements); }); }); describe('awardAchievement', () => { it('should call the award_achievement database function with the correct parameters', async () => { - mockQuery.mockResolvedValue({ rows: [] }); // The function returns void + mockPoolInstance.query.mockResolvedValue({ rows: [] }); // The function returns void await awardAchievement('user-123', 'Test Achievement'); - expect(getPool().query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']); + expect(mockPoolInstance.query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']); }); }); @@ -78,12 +68,12 @@ describe('Gamification DB Service', () => { { user_id: 'user-1', full_name: 'User One', avatar_url: null, points: 500, rank: '1' }, { user_id: 'user-2', full_name: 'User Two', avatar_url: null, points: 450, rank: '2' }, ]; - mockQuery.mockResolvedValue({ rows: mockLeaderboard }); + mockPoolInstance.query.mockResolvedValue({ rows: mockLeaderboard }); const result = await getLeaderboard(10); - expect(getPool().query).toHaveBeenCalledTimes(1); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]); + expect(mockPoolInstance.query).toHaveBeenCalledTimes(1); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]); expect(result).toEqual(mockLeaderboard); }); }); diff --git a/src/tests/utils/mockFactories.ts b/src/tests/utils/mockFactories.ts new file mode 100644 index 00000000..1977a943 --- /dev/null +++ b/src/tests/utils/mockFactories.ts @@ -0,0 +1,361 @@ +// src/tests/utils/mockFactories.ts +import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeComment, ActivityLogItem } from '../../types'; + +/** + * Creates a mock UserProfile object for use in tests, ensuring type safety. + * + * @param overrides - An object containing properties to override the default mock values. + * This allows for easy customization of the mock user for specific test cases. + * For example: `createMockUserProfile({ role: 'admin', points: 500 })` + * @returns A complete and type-safe UserProfile object. + */ +export const createMockUserProfile = (overrides: Partial }> = {}): UserProfile => { + const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`; + + const defaultProfile: UserProfile = { + user_id: userId, + role: 'user', + points: 0, + full_name: 'Test User', + avatar_url: null, + preferences: {}, + address: null, + user: { + user_id: userId, + email: `${userId}@example.com`, + ...overrides.user, // Apply nested user overrides + }, + }; + + return { ...defaultProfile, ...overrides }; +}; + +/** + * Creates a mock Flyer object for use in tests, ensuring type safety. + * + * @param overrides - An object containing properties to override the default mock values, + * including nested properties for the `store`. + * e.g., `createMockFlyer({ item_count: 50, store: { name: 'Walmart' } })` + * @returns A complete and type-safe Flyer object. + */ +export const createMockFlyer = (overrides: Partial }> = {}): Flyer => { + const flyerId = overrides.flyer_id ?? Math.floor(Math.random() * 1000); + const storeId = overrides.store?.store_id ?? Math.floor(Math.random() * 100); + + const defaultFlyer: Flyer = { + flyer_id: flyerId, + created_at: new Date().toISOString(), + file_name: `flyer-${flyerId}.jpg`, + image_url: `/flyer-images/flyer-${flyerId}.jpg`, + icon_url: `/flyer-images/icons/icon-flyer-${flyerId}.webp`, + checksum: `checksum-${flyerId}`, + store_id: storeId, + valid_from: new Date().toISOString().split('T')[0], + valid_to: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 7 days from now + store_address: '123 Main St, Anytown, USA', + item_count: Math.floor(Math.random() * 100) + 10, + uploaded_by: null, + store: { + store_id: storeId, + created_at: new Date().toISOString(), + name: 'Mock Store', + logo_url: null, + }, + }; + + // Deep merge the store object and then merge the top-level properties. + return { ...defaultFlyer, ...overrides, store: { ...defaultFlyer.store, ...overrides.store } as Store }; +}; + +/** + * Creates a mock SuggestedCorrection object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe SuggestedCorrection object. + */ +export const createMockSuggestedCorrection = (overrides: Partial = {}): SuggestedCorrection => { + const defaultCorrection: SuggestedCorrection = { + suggested_correction_id: Math.floor(Math.random() * 1000), + flyer_item_id: Math.floor(Math.random() * 10000), + user_id: `user-${Math.random().toString(36).substring(2, 9)}`, + correction_type: 'price', + suggested_value: '$9.99', + status: 'pending', + created_at: new Date().toISOString(), + }; + + return { ...defaultCorrection, ...overrides }; +}; + +/** + * Creates a mock Brand object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe Brand object. + */ +export const createMockBrand = (overrides: Partial = {}): Brand => { + const brandId = overrides.brand_id ?? Math.floor(Math.random() * 100); + + const defaultBrand: Brand = { + brand_id: brandId, + name: `Brand ${brandId}`, + logo_url: null, + store_id: null, + store_name: null, + }; + + return { ...defaultBrand, ...overrides }; +}; + +/** + * Creates a mock FlyerItem object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe FlyerItem object. + */ +export const createMockFlyerItem = (overrides: Partial = {}): FlyerItem => { + const defaultItem: FlyerItem = { + flyer_item_id: Math.floor(Math.random() * 10000), + flyer_id: Math.floor(Math.random() * 1000), + created_at: new Date().toISOString(), + item: 'Mock Item', + price_display: '$1.99', + price_in_cents: 199, + quantity: 'each', + view_count: 0, + click_count: 0, + updated_at: new Date().toISOString(), + }; + + return { ...defaultItem, ...overrides }; +}; + +/** + * Creates a mock Recipe object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe Recipe object. + */ +export const createMockRecipe = (overrides: Partial = {}): Recipe => { + const recipeId = overrides.recipe_id ?? Math.floor(Math.random() * 1000); + + const defaultRecipe: Recipe = { + recipe_id: recipeId, + user_id: `user-${Math.random().toString(36).substring(2, 9)}`, + name: `Mock Recipe ${recipeId}`, + description: 'A delicious mock recipe.', + instructions: '1. Mock the ingredients. 2. Mock the cooking. 3. Enjoy!', + avg_rating: Math.random() * 5, + rating_count: Math.floor(Math.random() * 100), + fork_count: Math.floor(Math.random() * 20), + status: 'public', + created_at: new Date().toISOString(), + prep_time_minutes: 15, + cook_time_minutes: 30, + servings: 4, + }; + + return { ...defaultRecipe, ...overrides }; +}; + +/** + * Creates a mock RecipeComment object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe RecipeComment object. + */ +export const createMockRecipeComment = (overrides: Partial = {}): RecipeComment => { + const defaultComment: RecipeComment = { + recipe_comment_id: Math.floor(Math.random() * 10000), + recipe_id: Math.floor(Math.random() * 1000), + user_id: `user-${Math.random().toString(36).substring(2, 9)}`, + content: 'This is a mock comment.', + status: 'visible', + created_at: new Date().toISOString(), + user_full_name: 'Mock User', // This was correct + user_avatar_url: undefined, + }; + + return { ...defaultComment, ...overrides }; +}; + +/** + * Creates a mock ActivityLogItem object for use in tests. + * This factory handles the discriminated union nature of the ActivityLogItem type. + * By default, it creates a 'flyer_processed' log item. You can override the 'action' + * and 'details' to create other types of log items. + * + * @param overrides - An object containing properties to override the default mock values. + * e.g., `createMockActivityLogItem({ action: 'user_registered', details: { full_name: 'New User' } })` + * @returns A complete and type-safe ActivityLogItem object. + */ +export const createMockActivityLogItem = (overrides: Partial = {}): ActivityLogItem => { + const action = overrides.action ?? 'flyer_processed'; + + const baseLog = { + activity_log_id: Math.floor(Math.random() * 10000), + user_id: `user-${Math.random().toString(36).substring(2, 9)}`, + created_at: new Date().toISOString(), + }; + + let specificLog: ActivityLogItem; + + // Create a default log based on the action, which can then be overridden. + switch (action) { + case 'recipe_created': + specificLog = { + ...baseLog, + action: 'recipe_created', + display_text: 'Created a new recipe: Mock Recipe.', + icon: 'chef-hat', + details: { recipe_id: 1, recipe_name: 'Mock Recipe' }, + }; + break; + case 'user_registered': + specificLog = { + ...baseLog, + action: 'user_registered', + display_text: 'A new user has registered.', + icon: 'user-plus', + details: { full_name: 'New Mock User' }, + }; + break; + case 'list_shared': + specificLog = { + ...baseLog, + action: 'list_shared', + display_text: 'A shopping list was shared.', + icon: 'share-2', + details: { list_name: 'Mock List', shopping_list_id: 1, shared_with_name: 'Another User' }, + }; + break; + case 'flyer_processed': + default: + specificLog = { + ...baseLog, + action: 'flyer_processed', + display_text: 'Processed a new flyer for Mock Store.', + icon: 'file-check', + details: { flyer_id: 1, store_name: 'Mock Store' }, + }; + break; + } + + // Merge the generated log with any specific overrides provided. + // This allows for deep merging of the 'details' object. + return { ...specificLog, ...overrides, details: { ...specificLog.details, ...overrides.details } } as ActivityLogItem; +}; + +/** + * Creates a mock Achievement object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe Achievement object. + */ +export const createMockAchievement = (overrides: Partial = {}): Achievement => { + const defaultAchievement: Achievement = { + achievement_id: Math.floor(Math.random() * 100), + name: 'Mock Achievement', + description: 'A great accomplishment.', + icon: 'star', + points_value: 10, + }; + return { ...defaultAchievement, ...overrides }; +}; + +/** + * Creates a mock object representing a joined UserAchievement and Achievement for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe object representing the joined achievement data. + */ +export const createMockUserAchievement = (overrides: Partial = {}): UserAchievement & Achievement => { + const achievementId = overrides.achievement_id ?? Math.floor(Math.random() * 100); + const defaultUserAchievement: UserAchievement & Achievement = { + user_id: `user-${Math.random().toString(36).substring(2, 9)}`, + achievement_id: achievementId, + achieved_at: new Date().toISOString(), + // from Achievement + name: 'Mock User Achievement', + description: 'An achievement someone earned.', + icon: 'award', + points_value: 20, + }; + return { ...defaultUserAchievement, ...overrides }; +}; + +/** + * Creates a mock Budget object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe Budget object. + */ +export const createMockBudget = (overrides: Partial = {}): Budget => { + const defaultBudget: Budget = { + budget_id: Math.floor(Math.random() * 100), + user_id: `user-${Math.random().toString(36).substring(2, 9)}`, + name: 'Monthly Groceries', + amount_cents: 50000, + period: 'monthly', + start_date: new Date().toISOString().split('T')[0], + }; + return { ...defaultBudget, ...overrides }; +}; + +/** + * Creates a mock SpendingByCategory object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe SpendingByCategory object. + */ +export const createMockSpendingByCategory = (overrides: Partial = {}): SpendingByCategory => { + const defaultSpending: SpendingByCategory = { + category_id: Math.floor(Math.random() * 20) + 1, + category_name: 'Produce', + total_spent_cents: Math.floor(Math.random() * 20000) + 1000, + }; + return { ...defaultSpending, ...overrides }; +}; + +/** + * Creates a mock MasterGroceryItem object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe MasterGroceryItem object. + */ +export const createMockMasterGroceryItem = (overrides: Partial = {}): MasterGroceryItem => { + const defaultItem: MasterGroceryItem = { + master_grocery_item_id: Math.floor(Math.random() * 10000), + created_at: new Date().toISOString(), + name: 'Mock Master Item', + category_id: 1, + category_name: 'Pantry & Dry Goods', + }; + + return { ...defaultItem, ...overrides }; +}; + +/** + * Creates a mock ShoppingList object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe ShoppingList object. + */ +export const createMockShoppingList = (overrides: Partial = {}): ShoppingList => { + const defaultList: ShoppingList = { + shopping_list_id: Math.floor(Math.random() * 100), + user_id: `user-${Math.random().toString(36).substring(2, 9)}`, + name: 'My Mock List', + created_at: new Date().toISOString(), + items: [], + }; + + return { ...defaultList, ...overrides }; +}; + +/** + * Creates a mock ShoppingListItem object for use in tests. + * @param overrides - An object containing properties to override the default mock values. + * @returns A complete and type-safe ShoppingListItem object. + */ +export const createMockShoppingListItem = (overrides: Partial = {}): ShoppingListItem => { + const defaultItem: ShoppingListItem = { + shopping_list_item_id: Math.floor(Math.random() * 100000), + shopping_list_id: Math.floor(Math.random() * 100), + custom_item_name: 'Mock Shopping List Item', + quantity: 1, + is_purchased: false, + added_at: new Date().toISOString(), + }; + + return { ...defaultItem, ...overrides }; +}; \ No newline at end of file