From e022a4a2cc11b2c4776548c6354131173c08e78d Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Mon, 8 Dec 2025 10:06:13 -0800 Subject: [PATCH] moar unit test ! --- src/services/aiApiClient.test.ts | 151 +++++++++--- src/services/aiService.server.test.ts | 72 ++++++ src/services/backgroundJobService.test.ts | 30 +++ src/services/db/admin.db.test.ts | 157 ++++++++++++- src/services/db/connection.db.test.ts | 11 + src/services/db/gamification.db.test.ts | 24 ++ src/services/db/notification.db.test.ts | 38 ++++ src/services/db/personalization.db.test.ts | 150 ++++++++---- src/services/db/recipe.db.test.ts | 105 +++++++-- src/services/db/recipe.db.ts | 30 +++ src/services/db/shopping.db.test.ts | 159 ++++++++++++- src/services/db/user.db.test.ts | 126 ++++++++++ src/services/emailService.server.test.ts | 37 ++- .../flyerProcessingService.server.test.ts | 50 ++++ src/services/geocodingService.server.test.ts | 215 ++++++++++++++++++ .../nominatimGeocodingService.server.test.ts | 74 ++++++ src/services/queueService.server.test.ts | 127 +++++++++++ src/services/queueService.workers.test.ts | 22 +- src/types.ts | 13 ++ 19 files changed, 1486 insertions(+), 105 deletions(-) create mode 100644 src/services/geocodingService.server.test.ts create mode 100644 src/services/nominatimGeocodingService.server.test.ts create mode 100644 src/services/queueService.server.test.ts diff --git a/src/services/aiApiClient.test.ts b/src/services/aiApiClient.test.ts index f81d2afe..accd1fb0 100644 --- a/src/services/aiApiClient.test.ts +++ b/src/services/aiApiClient.test.ts @@ -32,36 +32,39 @@ vi.mock('./apiClient', () => ({ const requestSpy = vi.fn(); const server = setupServer( - http.post('http://localhost/api/ai/:endpoint', async ({ request, params }): Promise>> => { - let body: Record = {}; - const contentType = request.headers.get('content-type'); + // Handler for all POST requests to the AI endpoints + http.post('http://localhost/api/ai/:endpoint', async ({ request, params }) => { + let body: Record | FormData = {}; + const contentType = request.headers.get('Content-Type'); if (contentType?.includes('application/json')) { - try { - body = await request.json() as Record; - } catch { /* ignore parse error */ } + const parsedBody = await request.json(); + // Ensure the parsed body is an object before assigning, as request.json() can return primitives. + if (typeof parsedBody === 'object' && parsedBody !== null && !Array.isArray(parsedBody)) { + body = parsedBody as Record; + } } else if (contentType?.includes('multipart/form-data')) { - try { - // This is the key part. We read the formData from the request. - const formData = await request.formData(); - // And then convert it to a plain object for easier assertions. - // This correctly preserves File objects. - const formDataBody: Record = {}; - for (const [key, value] of formData.entries()) { - formDataBody[key] = value; - } - formDataBody._isFormData = true; - body = formDataBody; - } catch { /* ignore parse error */ } + body = await request.formData(); } requestSpy({ endpoint: params.endpoint, method: request.method, body, + headers: request.headers, }); return HttpResponse.json({ success: true }); + }), + + // Handler for GET requests, specifically for job status + http.get('http://localhost/api/ai/jobs/:jobId/status', ({ params }) => { + requestSpy({ + endpoint: 'jobs', + method: 'GET', + jobId: params.jobId, + }); + return HttpResponse.json({ state: 'completed' }); }) ); @@ -76,6 +79,42 @@ describe('AI API Client (Network Mocking with MSW)', () => { afterAll(() => server.close()); + describe('uploadAndProcessFlyer', () => { + it('should construct FormData with file and checksum and send a POST request', async () => { + const mockFile = new File(['dummy-flyer-content'], 'flyer.pdf', { type: 'application/pdf' }); + const checksum = 'checksum-abc-123'; + + await aiApiClient.uploadAndProcessFlyer(mockFile, checksum); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const req = requestSpy.mock.calls[0][0]; + + expect(req.endpoint).toBe('upload-and-process'); + expect(req.method).toBe('POST'); + expect(req.body).toBeInstanceOf(FormData); + + const flyerFile = (req.body as FormData).get('flyerFile') as File; + const checksumValue = (req.body as FormData).get('checksum'); + + expect(flyerFile.name).toBe('flyer.pdf'); + expect(checksumValue).toBe(checksum); + }); + }); + + describe('getJobStatus', () => { + it('should send a GET request to the correct job status URL', async () => { + const jobId = 'job-id-456'; + await aiApiClient.getJobStatus(jobId); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const req = requestSpy.mock.calls[0][0]; + + expect(req.endpoint).toBe('jobs'); + expect(req.method).toBe('GET'); + expect(req.jobId).toBe(jobId); + }); + }); + describe('isImageAFlyer', () => { it('should construct FormData and send a POST request', async () => { const mockFile = new File(['dummy'], 'flyer.jpg', { type: 'image/jpeg' }); @@ -86,12 +125,9 @@ describe('AI API Client (Network Mocking with MSW)', () => { expect(req.endpoint).toBe('check-flyer'); expect(req.method).toBe('POST'); - expect(req.body).toHaveProperty('_isFormData', true); - // Relax the check: FormData polyfills might not preserve the filename. - // Instead, check that a Blob/File-like object with the correct type and non-zero size was sent. - expect(req.body.image).toBeDefined(); - expect(req.body.image.size).toBeGreaterThan(0); - expect(req.body.image.type).toBe('image/jpeg'); + expect(req.body).toBeInstanceOf(FormData); + const imageFile = (req.body as FormData).get('image') as File; + expect(imageFile.name).toBe('flyer.jpg'); }); }); @@ -104,10 +140,9 @@ describe('AI API Client (Network Mocking with MSW)', () => { const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('extract-address'); - expect(req.body).toHaveProperty('_isFormData', true); - expect(req.body.image).toBeDefined(); - expect(req.body.image.size).toBeGreaterThan(0); - expect(req.body.image.type).toBe('image/jpeg'); + expect(req.body).toBeInstanceOf(FormData); + const imageFile = (req.body as FormData).get('image') as File; + expect(imageFile.name).toBe('flyer.jpg'); }); }); @@ -120,13 +155,25 @@ describe('AI API Client (Network Mocking with MSW)', () => { const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('extract-logo'); - expect(req.body.images).toBeDefined(); - expect(req.body.images.size).toBeGreaterThan(0); - expect(req.body.images.type).toBe('image/jpeg'); + expect(req.body).toBeInstanceOf(FormData); + const imageFile = (req.body as FormData).get('images') as File; + expect(imageFile.name).toBe('logo.jpg'); + }); + }); + + describe('getQuickInsights', () => { + it('should send items as JSON in the body', async () => { + const items = [{ item: 'apple' }]; + await aiApiClient.getQuickInsights(items, 'test-token'); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const req = requestSpy.mock.calls[0][0]; + + expect(req.endpoint).toBe('quick-insights'); + expect(req.body).toEqual({ items }); }); }); - // ... (Rest of tests remain unchanged) describe('getDeepDiveAnalysis', () => { it('should send items as JSON in the body', async () => { const items = [{ item: 'apple' }]; @@ -179,6 +226,46 @@ describe('AI API Client (Network Mocking with MSW)', () => { }); }); + describe('planTripWithMaps', () => { + it('should send items, store, and location as JSON in the body', async () => { + const items: any[] = [{ item: 'bread' }]; + const store = { name: 'Test Store' } as any; + const userLocation = { latitude: 45, longitude: -75 } as any; + + await aiApiClient.planTripWithMaps(items, store, userLocation); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const req = requestSpy.mock.calls[0][0]; + + expect(req.endpoint).toBe('plan-trip'); + expect(req.body).toEqual({ items, store, userLocation }); + }); + }); + + describe('rescanImageArea', () => { + it('should construct FormData with image, cropArea, and extractionType', async () => { + const mockFile = new File(['dummy-content'], 'flyer-page.jpg', { type: 'image/jpeg' }); + const cropArea = { x: 10, y: 20, width: 100, height: 50 }; + const extractionType = 'item_details' as const; + + await aiApiClient.rescanImageArea(mockFile, cropArea, extractionType); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const req = requestSpy.mock.calls[0][0]; + + expect(req.endpoint).toBe('rescan-area'); + expect(req.body).toBeInstanceOf(FormData); + + const imageFile = (req.body as FormData).get('image') as File; + const cropAreaValue = (req.body as FormData).get('cropArea'); + const extractionTypeValue = (req.body as FormData).get('extractionType'); + + expect(imageFile.name).toBe('flyer-page.jpg'); + expect(cropAreaValue).toBe(JSON.stringify(cropArea)); + expect(extractionTypeValue).toBe(extractionType); + }); + }); + describe('startVoiceSession', () => { it('should throw an error as it is not implemented', () => { const mockCallbacks = { diff --git a/src/services/aiService.server.test.ts b/src/services/aiService.server.test.ts index 4d7d9185..a57ace76 100644 --- a/src/services/aiService.server.test.ts +++ b/src/services/aiService.server.test.ts @@ -61,6 +61,15 @@ describe('AI Service (Server)', () => { 'AI response did not contain a valid JSON array.' ); }); + + it('should throw an error if the AI API call fails', async () => { + const apiError = new Error('API limit reached'); + mockAiClient.generateContent.mockRejectedValue(apiError); + mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); + + await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg')) + .rejects.toThrow(apiError); + }); }); describe('extractCoreDataFromFlyerImage', () => { @@ -97,6 +106,26 @@ describe('AI Service (Server)', () => { 'AI response did not contain a valid JSON object.' ); }); + + it('should throw an error if the AI response contains malformed JSON', async () => { + // Arrange: AI returns a string that looks like JSON but is invalid + mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }', candidates: [] }); + mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); + + // Act & Assert + await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems)) + .rejects.toThrow('Failed to parse structured data from the AI response.'); + }); + + it('should throw an error if the AI API call fails', async () => { + // Arrange: AI client's method rejects + const apiError = new Error('API call failed'); + mockAiClient.generateContent.mockRejectedValue(apiError); + mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); + + // Act & Assert + await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems)).rejects.toThrow(apiError); + }); }); describe('extractTextFromImageArea', () => { @@ -135,5 +164,48 @@ describe('AI Service (Server)', () => { expect(aiCallArgs.contents[0].parts[0].text).toContain('What is the store name in this image?'); expect(result.text).toBe('Super Store'); }); + + it('should throw an error if the AI API call fails', async () => { + const apiError = new Error('API Error'); + mockAiClient.generateContent.mockRejectedValue(apiError); + mockToBuffer.mockResolvedValue(Buffer.from('cropped-image-data')); + + await expect(aiServiceInstance.extractTextFromImageArea('path', 'image/jpeg', { x: 0, y: 0, width: 10, height: 10 }, 'dates')) + .rejects.toThrow(apiError); + }); + }); + + describe('planTripWithMaps', () => { + const mockUserLocation = { latitude: 45, longitude: -75, accuracy: 10, altitude: null, altitudeAccuracy: null, heading: null, speed: null }; + const mockStore = { name: 'Test Store' }; + + it('should call the AI with the correct prompt and return text and sources', async () => { + const mockAiResponse = { + text: 'The best route is...', + candidates: [{ + groundingMetadata: { + groundingAttributions: [{ web: { uri: 'http://maps.google.com/mock', title: 'Mock Map' } }] + } + }] + }; + mockAiClient.generateContent.mockResolvedValue(mockAiResponse); + + const result = await aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation as any); + + expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1); + const aiCallArgs = mockAiClient.generateContent.mock.calls[0][0] as any; + expect(aiCallArgs.contents[0].parts[0].text).toContain('My current location is latitude 45, longitude -75'); + expect(result.text).toBe('The best route is...'); + expect(result.sources).toHaveLength(1); + expect(result.sources[0].title).toBe('Mock Map'); + }); + + it('should throw an error if the AI API call fails', async () => { + const apiError = new Error('API call failed'); + mockAiClient.generateContent.mockRejectedValue(apiError); + + await expect(aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation as any)) + .rejects.toThrow(apiError); + }); }); }); \ No newline at end of file diff --git a/src/services/backgroundJobService.test.ts b/src/services/backgroundJobService.test.ts index 3bfbfae9..d2fc5481 100644 --- a/src/services/backgroundJobService.test.ts +++ b/src/services/backgroundJobService.test.ts @@ -164,6 +164,24 @@ describe('Background Job Service', () => { expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1); }); + it('should log an error and release the lock if runDailyDealCheck fails', async () => { + const jobError = new Error('Cron job failed'); + vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockRejectedValue(jobError); + startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue); + + const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1]; + await dailyDealCheckCallback(); + + expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + '[BackgroundJob] Cron job for daily deal check failed unexpectedly.', + { error: jobError } + ); + // It should run again, proving the lock was released in the finally block + await dailyDealCheckCallback(); + expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(2); + }); + it('should prevent runDailyDealCheck from running if it is already in progress', async () => { // Use fake timers to control promise resolution vi.useFakeTimers(); @@ -193,5 +211,17 @@ describe('Background Job Service', () => { expect(mockAnalyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', expect.any(Object), expect.any(Object)); }); + + it('should log an error if enqueuing the analytics job fails', async () => { + const queueError = new Error('Redis is down'); + vi.mocked(mockAnalyticsQueue.add).mockRejectedValue(queueError); + startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue); + + const analyticsJobCallback = mockCronSchedule.mock.calls[1][1]; + await analyticsJobCallback(); + + expect(mockAnalyticsQueue.add).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith('[BackgroundJob] Failed to enqueue daily analytics job.', { error: queueError }); + }); }); }); \ No newline at end of file diff --git a/src/services/db/admin.db.test.ts b/src/services/db/admin.db.test.ts index ef280b3d..a5d8d2d6 100644 --- a/src/services/db/admin.db.test.ts +++ b/src/services/db/admin.db.test.ts @@ -16,8 +16,11 @@ import { getUnmatchedFlyerItems, updateRecipeStatus, updateReceiptStatus, + getActivityLog, + getAllUsers, + updateUserRole, } from './admin.db'; -import type { SuggestedCorrection } from '../../types'; +import type { SuggestedCorrection, AdminUserView, User } from '../../types'; // Mock the logger to prevent console output during tests vi.mock('../logger', () => ({ @@ -47,6 +50,12 @@ describe('Admin DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc")); expect(result).toEqual(mockCorrections); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getSuggestedCorrections()).rejects.toThrow('Failed to retrieve suggested corrections.'); + }); }); describe('approveCorrection', () => { @@ -56,6 +65,12 @@ describe('Admin DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]); }); + + it('should throw an error if the database function fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(approveCorrection(123)).rejects.toThrow('Failed to approve correction.'); + }); }); describe('rejectCorrection', () => { @@ -68,6 +83,20 @@ describe('Admin DB Service', () => { [123] ); }); + + it('should not throw an error if the correction is already processed (rowCount is 0)', async () => { + mockPoolInstance.query.mockResolvedValue({ rowCount: 0 }); + // The function should complete without throwing an error. + await expect(rejectCorrection(123)).resolves.toBeUndefined(); + // We can also check that a warning was logged. + const { logger } = await import('../logger'); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('was not found or not in \'pending\' state')); + }); + + it('should throw an error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(rejectCorrection(123)).rejects.toThrow('Failed to reject correction.'); + }); }); describe('updateSuggestedCorrection', () => { @@ -83,6 +112,18 @@ describe('Admin DB Service', () => { ); expect(result).toEqual(mockCorrection); }); + + it('should throw an error if the correction is not found (rowCount is 0)', async () => { + mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] }); + await expect(updateSuggestedCorrection(999, 'new value')).rejects.toThrow( + "Correction with ID 999 not found or is not in 'pending' state." + ); + }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(updateSuggestedCorrection(1, 'new value')).rejects.toThrow('Failed to update suggested correction.'); + }); }); describe('getApplicationStats', () => { @@ -128,6 +169,12 @@ describe('Admin DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS")); expect(result).toEqual(mockStats); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getDailyStatsForLast30Days()).rejects.toThrow('Failed to retrieve daily statistics.'); + }); }); describe('logActivity', () => { @@ -141,6 +188,12 @@ describe('Admin DB Service', () => { [logData.userId, logData.action, logData.displayText, null, null] ); }); + + it('should not throw an error if the database query fails (non-critical)', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + const logData = { action: 'test_action', displayText: 'Test activity' }; + await expect(logActivity(logData)).resolves.toBeUndefined(); + }); }); describe('getMostFrequentSaleItems', () => { @@ -149,6 +202,12 @@ describe('Admin DB Service', () => { await getMostFrequentSaleItems(30, 10); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getMostFrequentSaleItems(30, 10)).rejects.toThrow('Failed to get most frequent sale items.'); + }); }); describe('updateRecipeCommentStatus', () => { @@ -159,6 +218,17 @@ describe('Admin DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]); expect(result).toEqual(mockComment); }); + + it('should throw an error if the comment is not found (rowCount is 0)', async () => { + mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] }); + await expect(updateRecipeCommentStatus(999, 'hidden')).rejects.toThrow('Recipe comment with ID 999 not found.'); + }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(updateRecipeCommentStatus(1, 'hidden')).rejects.toThrow('Failed to update recipe comment status.'); + }); }); describe('getUnmatchedFlyerItems', () => { @@ -167,6 +237,12 @@ describe('Admin DB Service', () => { await getUnmatchedFlyerItems(); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi')); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getUnmatchedFlyerItems()).rejects.toThrow('Failed to retrieve unmatched flyer items.'); + }); }); describe('updateRecipeStatus', () => { @@ -177,6 +253,17 @@ describe('Admin DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]); expect(result).toEqual(mockRecipe); }); + + it('should throw an error if the recipe is not found (rowCount is 0)', async () => { + mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] }); + await expect(updateRecipeStatus(999, 'public')).rejects.toThrow('Recipe with ID 999 not found.'); + }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(updateRecipeStatus(1, 'public')).rejects.toThrow('Failed to update recipe status.'); + }); }); describe('incrementFailedLoginAttempts', () => { @@ -190,6 +277,12 @@ describe('Admin DB Service', () => { ['user-123'] ); }); + + it('should not throw an error if the database query fails (non-critical)', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(incrementFailedLoginAttempts('user-123')).resolves.toBeUndefined(); + }); }); describe('updateBrandLogo', () => { @@ -198,6 +291,12 @@ describe('Admin DB Service', () => { await updateBrandLogo(1, '/logo.png'); expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(updateBrandLogo(1, '/logo.png')).rejects.toThrow('Failed to update brand logo in database.'); + }); }); describe('updateReceiptStatus', () => { @@ -208,5 +307,61 @@ describe('Admin DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]); expect(result).toEqual(mockReceipt); }); + + it('should throw an error if the receipt is not found (rowCount is 0)', async () => { + mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] }); + await expect(updateReceiptStatus(999, 'completed')).rejects.toThrow('Receipt with ID 999 not found.'); + }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(updateReceiptStatus(1, 'completed')).rejects.toThrow('Failed to update receipt status.'); + }); + }); + + describe('getActivityLog', () => { + it('should call the get_activity_log database function', async () => { + mockPoolInstance.query.mockResolvedValue({ rows: [] }); + await getActivityLog(50, 0); + expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_activity_log($1, $2)', [50, 0]); + }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getActivityLog(50, 0)).rejects.toThrow('Failed to retrieve activity log.'); + }); + }); + + describe('getAllUsers', () => { + it('should return a list of all users for the admin view', async () => { + const mockUsers: AdminUserView[] = [{ user_id: '1', email: 'test@test.com', created_at: '', role: 'user', full_name: 'Test', avatar_url: null }]; + mockPoolInstance.query.mockResolvedValue({ rows: mockUsers }); + const result = await getAllUsers(); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users u JOIN public.profiles p')); + expect(result).toEqual(mockUsers); + }); + }); + + describe('updateUserRole', () => { + it('should update the user role and return the updated user', async () => { + const mockUser: User = { user_id: '1', email: 'test@test.com' }; + mockPoolInstance.query.mockResolvedValue({ rows: [mockUser], rowCount: 1 }); + const result = await updateUserRole('1', 'admin'); + expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *', ['admin', '1']); + expect(result).toEqual(mockUser); + }); + + it('should throw an error if the user is not found (rowCount is 0)', async () => { + mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] }); + await expect(updateUserRole('999', 'admin')).rejects.toThrow('User with ID 999 not found.'); + }); + + it('should re-throw a generic error if the database query fails for other reasons', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(updateUserRole('1', 'admin')).rejects.toThrow('DB Error'); + }); }); }); \ No newline at end of file diff --git a/src/services/db/connection.db.test.ts b/src/services/db/connection.db.test.ts index 23f94c43..5a67be62 100644 --- a/src/services/db/connection.db.test.ts +++ b/src/services/db/connection.db.test.ts @@ -80,6 +80,17 @@ describe('DB Connection Service', () => { expect(missingTables).toEqual([]); }); + it('should throw an error if the database query fails', async () => { + const { getPool, checkTablesExist } = await import('./connection.db'); + const pool = getPool(); + const dbError = new Error('DB Connection Failed'); + (pool.query as Mock).mockRejectedValue(dbError); + + const tableNames = ['users']; + + await expect(checkTablesExist(tableNames)).rejects.toThrow('Failed to check for tables in database.'); + }); + it('should return an array of missing tables', async () => { const { getPool, checkTablesExist } = await import('./connection.db'); const pool = getPool(); diff --git a/src/services/db/gamification.db.test.ts b/src/services/db/gamification.db.test.ts index f3725240..a7e84155 100644 --- a/src/services/db/gamification.db.test.ts +++ b/src/services/db/gamification.db.test.ts @@ -41,6 +41,12 @@ describe('Gamification DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC'); expect(result).toEqual(mockAchievements); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getAllAchievements()).rejects.toThrow('Failed to retrieve achievements.'); + }); }); describe('getUserAchievements', () => { @@ -55,6 +61,12 @@ describe('Gamification DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']); expect(result).toEqual(mockUserAchievements); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getUserAchievements('user-123')).rejects.toThrow('Failed to retrieve user achievements.'); + }); }); describe('awardAchievement', () => { @@ -71,6 +83,12 @@ describe('Gamification DB Service', () => { mockPoolInstance.query.mockRejectedValue(dbError); await expect(awardAchievement('non-existent-user', 'Non-existent Achievement')).rejects.toThrow('The specified user or achievement does not exist.'); }); + + it('should re-throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(awardAchievement('user-123', 'Test Achievement')).rejects.toThrow('DB Error'); + }); }); describe('getLeaderboard', () => { @@ -87,5 +105,11 @@ describe('Gamification DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]); expect(result).toEqual(mockLeaderboard); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getLeaderboard(10)).rejects.toThrow('Failed to retrieve leaderboard.'); + }); }); }); \ No newline at end of file diff --git a/src/services/db/notification.db.test.ts b/src/services/db/notification.db.test.ts index 4b03a26f..f583f8ec 100644 --- a/src/services/db/notification.db.test.ts +++ b/src/services/db/notification.db.test.ts @@ -44,6 +44,12 @@ describe('Notification DB Service', () => { ); expect(result).toEqual(mockNotifications); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(getNotificationsForUser('user-123', 10, 5)).rejects.toThrow('Failed to retrieve notifications.'); + }); }); describe('createNotification', () => { @@ -61,6 +67,12 @@ describe('Notification DB Service', () => { mockPoolInstance.query.mockRejectedValue(dbError); await expect(createNotification('non-existent-user', 'Test')).rejects.toThrow('The specified user does not exist.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(createNotification('user-123', 'Test')).rejects.toThrow('Failed to create notification.'); + }); }); describe('createBulkNotifications', () => { @@ -70,6 +82,13 @@ describe('Notification DB Service', () => { await createBulkNotifications(notificationsToCreate); expect(mockPoolInstance.connect).toHaveBeenCalledTimes(1); + // Check that the query was called with the correct unnest structure + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])'), + [['u1'], ['msg'], [null]] + ); + // Check that the client was released + expect(vi.mocked(mockPoolInstance.connect).mock.results[0].value.release).toHaveBeenCalled(); }); it('should not query the database if the notifications array is empty', async () => { @@ -84,6 +103,13 @@ describe('Notification DB Service', () => { const notificationsToCreate = [{ user_id: 'non-existent', content: "msg" }]; await expect(createBulkNotifications(notificationsToCreate)).rejects.toThrow('One or more of the specified users do not exist.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + const notificationsToCreate = [{ user_id: 'u1', content: "msg" }]; + await expect(createBulkNotifications(notificationsToCreate)).rejects.toThrow('Failed to create bulk notifications.'); + }); }); describe('markNotificationAsRead', () => { @@ -102,6 +128,12 @@ describe('Notification DB Service', () => { await expect(markNotificationAsRead(999, 'user-abc')) .rejects.toThrow('Notification not found or user does not have permission.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(markNotificationAsRead(123, 'user-abc')).rejects.toThrow('Failed to mark notification as read.'); + }); }); describe('markAllNotificationsAsRead', () => { @@ -116,5 +148,11 @@ describe('Notification DB Service', () => { ['user-xyz'] ); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(markAllNotificationsAsRead('user-xyz')).rejects.toThrow('Failed to mark notifications as read.'); + }); }); }); \ No newline at end of file diff --git a/src/services/db/personalization.db.test.ts b/src/services/db/personalization.db.test.ts index ccf3aaf7..8554345e 100644 --- a/src/services/db/personalization.db.test.ts +++ b/src/services/db/personalization.db.test.ts @@ -1,5 +1,6 @@ // src/services/db/personalization.db.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mockPoolInstance } from '../../tests/setup/mock-db'; import { getAllMasterItems, getWatchedItems, @@ -19,13 +20,13 @@ import { setUserAppliances, getRecipesForUserDiets, } from './personalization.db'; -import type { MasterGroceryItem } from '../../types'; -import { getPool } from './connection.db'; -import { Pool } from 'pg'; +import type { MasterGroceryItem, UserAppliance } from '../../types'; -const mockQuery = vi.fn(); -const mockRelease = vi.fn(); -const mockConnect = vi.fn().mockResolvedValue({ query: mockQuery, release: mockRelease }); +// Un-mock the module we are testing to ensure we use the real implementation. +vi.unmock('./personalization.db'); + +const mockQuery = mockPoolInstance.query; +const mockConnect = mockPoolInstance.connect; // Mock the logger to prevent console output during tests vi.mock('../logger', () => ({ @@ -39,21 +40,10 @@ vi.mock('../logger', () => ({ describe('Personalization DB Service', () => { beforeEach(() => { - mockQuery.mockReset(); - vi.mocked(Pool).mockImplementation(function() { - console.log('[DEBUG] personalization.db.test.ts: Local Pool mock instantiated via "new"'); - return { - query: mockQuery, - connect: mockConnect, - release: mockRelease, - on: vi.fn(), - end: vi.fn(), - totalCount: 0, - idleCount: 0, - waitingCount: 0, - } as unknown as Pool; - }); vi.clearAllMocks(); + // Simulate the client returned by connect() having a release method + const mockClient = { ...mockPoolInstance, release: vi.fn() }; + vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any); }); describe('getAllMasterItems', () => { @@ -63,9 +53,15 @@ describe('Personalization DB Service', () => { const result = await getAllMasterItems(); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.master_grocery_items ORDER BY name ASC'); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.master_grocery_items ORDER BY name ASC'); expect(result).toEqual(mockItems); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockQuery.mockRejectedValue(dbError); + await expect(getAllMasterItems()).rejects.toThrow('Failed to retrieve master grocery items.'); + }); }); describe('getWatchedItems', () => { @@ -75,21 +71,25 @@ describe('Personalization DB Service', () => { const result = await getWatchedItems('user-123'); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.master_grocery_items mgi'), ['user-123']); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.master_grocery_items mgi'), ['user-123']); expect(result).toEqual(mockItems); }); + + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockQuery.mockRejectedValue(dbError); + await expect(getWatchedItems('user-123')).rejects.toThrow('Failed to retrieve watched items.'); + }); }); describe('addWatchedItem', () => { it('should execute a transaction to add a watched item', async () => { const mockItem: MasterGroceryItem = { master_grocery_item_id: 1, name: 'New Item', created_at: '' }; // Provide mock responses for all queries in the transaction: BEGIN, SELECT, SELECT, INSERT, COMMIT - mockQuery - .mockResolvedValueOnce({ rows: [] }) // BEGIN + mockQuery // BEGIN is handled by the mock client .mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category .mockResolvedValueOnce({ rows: [mockItem] }) // Find/create master item .mockResolvedValueOnce({ rows: [] }) // Insert into watchlist - .mockResolvedValueOnce({ rows: [] }); // COMMIT await addWatchedItem('user-123', 'New Item', 'Produce'); @@ -101,12 +101,33 @@ describe('Personalization DB Service', () => { expect(mockQuery).toHaveBeenCalledWith('COMMIT'); }); - it('should throw ForeignKeyConstraintError if an appliance ID is invalid', async () => { - const dbError = new Error('violates foreign key constraint'); - (dbError as any).code = '23503'; - mockQuery.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError); // Mock DELETE success, INSERT fail + it('should create a new master item if it does not exist', async () => { + const mockNewItem: MasterGroceryItem = { master_grocery_item_id: 2, name: 'Brand New Item', created_at: '' }; + mockQuery + .mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category + .mockResolvedValueOnce({ rows: [] }) // Find master item (not found) + .mockResolvedValueOnce({ rows: [mockNewItem] }) // INSERT new master item + .mockResolvedValueOnce({ rows: [] }); // Insert into watchlist - await expect(setUserAppliances('user-123', [999])).rejects.toThrow('One or more of the specified appliance IDs are invalid.'); + const result = await addWatchedItem('user-123', 'Brand New Item', 'Produce'); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.master_grocery_items'), ['Brand New Item', 1]); + expect(result).toEqual(mockNewItem); + }); + + it('should throw an error if the category is not found', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); // Find category (not found) + + await expect(addWatchedItem('user-123', 'Some Item', 'Fake Category')).rejects.toThrow("Category 'Fake Category' not found."); + expect(mockQuery).toHaveBeenCalledWith('ROLLBACK'); + }); + + it('should rollback the transaction on a generic error', async () => { + const dbError = new Error('DB Error'); + mockQuery.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }).mockRejectedValueOnce(dbError); + + await expect(addWatchedItem('user-123', 'Failing Item', 'Produce')).rejects.toThrow('Failed to add item to watchlist.'); + expect(mockQuery).toHaveBeenCalledWith('ROLLBACK'); }); }); @@ -114,7 +135,20 @@ describe('Personalization DB Service', () => { it('should execute a DELETE query', async () => { mockQuery.mockResolvedValue({ rows: [] }); await removeWatchedItem('user-123', 1); - expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]); + expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]); + }); + it('should throw an error if the database query fails', async () => { + const dbError = new Error('DB Error'); + mockQuery.mockRejectedValue(dbError); + await expect(removeWatchedItem('user-123', 1)).rejects.toThrow('Failed to remove item from watchlist.'); + }); + }); + + describe('removeWatchedItem', () => { + it('should execute a DELETE query', async () => { + mockQuery.mockResolvedValue({ rows: [] }); + await removeWatchedItem('user-123', 1); + expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]); }); }); @@ -122,7 +156,7 @@ describe('Personalization DB Service', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await findRecipesFromPantry('user-123'); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', ['user-123']); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', ['user-123']); }); }); @@ -130,7 +164,7 @@ describe('Personalization DB Service', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await recommendRecipesForUser('user-123', 5); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.recommend_recipes_for_user($1, $2)', ['user-123', 5]); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.recommend_recipes_for_user($1, $2)', ['user-123', 5]); }); }); @@ -138,7 +172,7 @@ describe('Personalization DB Service', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await getBestSalePricesForUser('user-123'); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_user($1)', ['user-123']); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_user($1)', ['user-123']); }); }); @@ -146,7 +180,7 @@ describe('Personalization DB Service', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await getBestSalePricesForAllUsers(); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_all_users()'); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_all_users()'); }); }); @@ -154,7 +188,7 @@ describe('Personalization DB Service', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await suggestPantryItemConversions(1); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.suggest_pantry_item_conversions($1)', [1]); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.suggest_pantry_item_conversions($1)', [1]); }); }); @@ -162,7 +196,7 @@ describe('Personalization DB Service', () => { it('should execute a SELECT query to find the owner', async () => { mockQuery.mockResolvedValue({ rows: [{ user_id: 'user-123' }] }); const result = await findPantryItemOwner(1); - expect(getPool().query).toHaveBeenCalledWith('SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [1]); + expect(mockQuery).toHaveBeenCalledWith('SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [1]); expect(result?.user_id).toBe('user-123'); }); }); @@ -171,7 +205,7 @@ describe('Personalization DB Service', () => { it('should execute a SELECT query to get all restrictions', async () => { mockQuery.mockResolvedValue({ rows: [] }); await getDietaryRestrictions(); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.dietary_restrictions ORDER BY type, name'); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.dietary_restrictions ORDER BY type, name'); }); }); @@ -179,7 +213,7 @@ describe('Personalization DB Service', () => { it('should execute a SELECT query with a JOIN', async () => { mockQuery.mockResolvedValue({ rows: [] }); await getUserDietaryRestrictions('user-123'); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.dietary_restrictions dr'), ['user-123']); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.dietary_restrictions dr'), ['user-123']); }); }); @@ -194,7 +228,7 @@ describe('Personalization DB Service', () => { expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']); // FIX: Make assertion robust for array parameters - expect(mockQuery).toHaveBeenCalledWith( + expect(mockQuery).toHaveBeenNthCalledWith(2, expect.stringContaining('INSERT INTO public.user_dietary_restrictions'), ['user-123', [1, 2]] ); @@ -215,7 +249,7 @@ describe('Personalization DB Service', () => { it('should execute a SELECT query to get all appliances', async () => { mockQuery.mockResolvedValue({ rows: [] }); await getAppliances(); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.appliances ORDER BY name'); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.appliances ORDER BY name'); }); }); @@ -223,19 +257,27 @@ describe('Personalization DB Service', () => { it('should execute a SELECT query with a JOIN', async () => { mockQuery.mockResolvedValue({ rows: [] }); await getUserAppliances('user-123'); - expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), ['user-123']); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), ['user-123']); }); }); describe('setUserAppliances', () => { it('should execute a transaction to set appliances', async () => { - mockQuery.mockResolvedValue({ rows: [] }); - await setUserAppliances('user-123', [1, 2]); + const mockNewAppliances: UserAppliance[] = [ + { user_id: 'user-123', appliance_id: 1 }, + { user_id: 'user-123', appliance_id: 2 }, + ]; + mockQuery + .mockResolvedValueOnce({ rows: [] }) // DELETE + .mockResolvedValueOnce({ rows: mockNewAppliances }); // INSERT ... RETURNING + + const result = await setUserAppliances('user-123', [1, 2]); expect(mockConnect).toHaveBeenCalled(); expect(mockQuery).toHaveBeenCalledWith('BEGIN'); expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']); expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_appliances'), expect.any(Array)); expect(mockQuery).toHaveBeenCalledWith('COMMIT'); + expect(result).toEqual(mockNewAppliances); }); it('should throw ForeignKeyConstraintError if an appliance ID is invalid', async () => { @@ -245,13 +287,31 @@ describe('Personalization DB Service', () => { await expect(setUserAppliances('user-123', [999])).rejects.toThrow('One or more of the specified appliance IDs are invalid.'); }); + + it('should handle an empty array of appliance IDs', async () => { + mockQuery.mockResolvedValue({ rows: [] }); + const result = await setUserAppliances('user-123', []); + expect(mockConnect).toHaveBeenCalled(); + expect(mockQuery).toHaveBeenCalledWith('BEGIN'); + expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']); + // The INSERT query should NOT be called + expect(mockQuery).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_appliances')); + expect(mockQuery).toHaveBeenCalledWith('COMMIT'); + expect(result).toEqual([]); + }); + + it('should rollback transaction on generic error', async () => { + mockQuery.mockRejectedValueOnce(new Error('DB Error')); + await expect(setUserAppliances('user-123', [1])).rejects.toThrow('Failed to set user appliances.'); + expect(mockQuery).toHaveBeenCalledWith('ROLLBACK'); + }); }); describe('getRecipesForUserDiets', () => { it('should call the correct database function', async () => { mockQuery.mockResolvedValue({ rows: [] }); await getRecipesForUserDiets('user-123'); - expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_for_user_diets($1)', ['user-123']); + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_for_user_diets($1)', ['user-123']); }); }); }); \ No newline at end of file diff --git a/src/services/db/recipe.db.test.ts b/src/services/db/recipe.db.test.ts index ec72f101..7ecc5884 100644 --- a/src/services/db/recipe.db.test.ts +++ b/src/services/db/recipe.db.test.ts @@ -1,5 +1,6 @@ // src/services/db/recipe.db.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mockPoolInstance } from '../../tests/setup/mock-db'; import { getRecipesBySalePercentage, getRecipesByMinSaleIngredients, @@ -7,13 +8,16 @@ import { getUserFavoriteRecipes, addFavoriteRecipe, removeFavoriteRecipe, + getRecipeById, getRecipeComments, addRecipeComment, forkRecipe, } from './recipe.db'; -import { Pool } from 'pg'; -const mockQuery = vi.fn(); +// Un-mock the module we are testing to ensure we use the real implementation. +vi.unmock('./recipe.db'); + +const mockQuery = mockPoolInstance.query; import type { Recipe, FavoriteRecipe, RecipeComment } from '../../types'; // Mock the logger to prevent console output during tests @@ -28,19 +32,6 @@ vi.mock('../logger', () => ({ describe('Recipe DB Service', () => { beforeEach(() => { - // FIX: Use mockImplementation with a standard function to support 'new Pool()' - vi.mocked(Pool).mockImplementation(function() { - console.log('[DEBUG] recipe.db.test.ts: Local Pool mock instantiated via "new"'); - return { - query: mockQuery, - connect: vi.fn().mockResolvedValue({ query: mockQuery, release: vi.fn() }), - on: vi.fn(), - end: vi.fn(), - totalCount: 0, - idleCount: 0, - waitingCount: 0, - } as unknown as Pool; - }); vi.clearAllMocks(); }); @@ -50,6 +41,12 @@ describe('Recipe DB Service', () => { await getRecipesBySalePercentage(50); expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [50]); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(getRecipesBySalePercentage(50)).rejects.toThrow('Failed to get recipes by sale percentage.'); + }); }); describe('getRecipesByMinSaleIngredients', () => { @@ -58,6 +55,12 @@ describe('Recipe DB Service', () => { await getRecipesByMinSaleIngredients(3); expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [3]); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(getRecipesByMinSaleIngredients(3)).rejects.toThrow('Failed to get recipes by minimum sale ingredients.'); + }); }); describe('findRecipesByIngredientAndTag', () => { @@ -66,6 +69,12 @@ describe('Recipe DB Service', () => { await findRecipesByIngredientAndTag('chicken', 'quick'); expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', ['chicken', 'quick']); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(findRecipesByIngredientAndTag('chicken', 'quick')).rejects.toThrow('Failed to find recipes by ingredient and tag.'); + }); }); describe('getUserFavoriteRecipes', () => { @@ -74,6 +83,12 @@ describe('Recipe DB Service', () => { await getUserFavoriteRecipes('user-123'); expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_user_favorite_recipes($1)', ['user-123']); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(getUserFavoriteRecipes('user-123')).rejects.toThrow('Failed to get favorite recipes.'); + }); }); describe('addFavoriteRecipe', () => { @@ -93,6 +108,19 @@ describe('Recipe DB Service', () => { mockQuery.mockRejectedValue(dbError); await expect(addFavoriteRecipe('user-123', 999)).rejects.toThrow('The specified user or recipe does not exist.'); }); + + it('should return undefined if the favorite already exists (ON CONFLICT)', async () => { + // When ON CONFLICT DO NOTHING happens, the RETURNING clause does not execute, so rows is empty. + mockQuery.mockResolvedValue({ rows: [] }); + const result = await addFavoriteRecipe('user-123', 1); + expect(result).toBeUndefined(); + }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(addFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to add favorite recipe.'); + }); }); describe('removeFavoriteRecipe', () => { @@ -101,6 +129,28 @@ describe('Recipe DB Service', () => { await removeFavoriteRecipe('user-123', 1); expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1]); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(removeFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to remove favorite recipe.'); + }); + }); + + describe('getRecipeById', () => { + it('should execute a SELECT query and return the recipe', async () => { + const mockRecipe: Recipe = { recipe_id: 1, name: 'Test Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: '' }; + mockQuery.mockResolvedValue({ rows: [mockRecipe] }); + const result = await getRecipeById(1); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipes r'), [1]); + expect(result).toEqual(mockRecipe); + }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(getRecipeById(1)).rejects.toThrow('Failed to retrieve recipe.'); + }); }); describe('getRecipeComments', () => { @@ -109,6 +159,12 @@ describe('Recipe DB Service', () => { await getRecipeComments(1); expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipe_comments rc'), [1]); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(getRecipeComments(1)).rejects.toThrow('Failed to get recipe comments.'); + }); }); describe('addRecipeComment', () => { @@ -121,6 +177,19 @@ describe('Recipe DB Service', () => { expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.recipe_comments'), [1, 'user-123', 'Great!', undefined]); expect(result).toEqual(mockComment); }); + + it('should throw ForeignKeyConstraintError if recipe, user, or parent comment does not exist', async () => { + const dbError = new Error('violates foreign key constraint'); + (dbError as any).code = '23503'; + mockQuery.mockRejectedValue(dbError); + await expect(addRecipeComment(999, 'user-123', 'Fail')).rejects.toThrow('The specified recipe, user, or parent comment does not exist.'); + }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(addRecipeComment(1, 'user-123', 'Fail')).rejects.toThrow('Failed to add recipe comment.'); + }); }); describe('forkRecipe', () => { @@ -132,5 +201,11 @@ describe('Recipe DB Service', () => { expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.fork_recipe($1, $2)', ['user-123', 1]); expect(result).toEqual(mockRecipe); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockQuery.mockRejectedValue(dbError); + await expect(forkRecipe('user-123', 1)).rejects.toThrow('Failed to fork recipe.'); + }); }); }); \ No newline at end of file diff --git a/src/services/db/recipe.db.ts b/src/services/db/recipe.db.ts index c94b76bc..aa165dd3 100644 --- a/src/services/db/recipe.db.ts +++ b/src/services/db/recipe.db.ts @@ -101,6 +101,36 @@ export async function removeFavoriteRecipe(userId: string, recipeId: number): Pr } } +/** + * Retrieves a single recipe by its ID, including its ingredients and tags. + * @param recipeId The ID of the recipe to retrieve. + * @returns A promise that resolves to the Recipe object or undefined if not found. + */ +export async function getRecipeById(recipeId: number): Promise { + try { + // This query uses json_agg to fetch the recipe and its related ingredients and tags in one go, + // preventing the N+1 query problem. + const query = ` + SELECT + r.*, + COALESCE(json_agg(DISTINCT jsonb_build_object('recipe_ingredient_id', ri.recipe_ingredient_id, 'master_item_name', mgi.name, 'quantity', ri.quantity, 'unit', ri.unit)) FILTER (WHERE ri.recipe_ingredient_id IS NOT NULL), '[]') AS ingredients, + COALESCE(json_agg(DISTINCT jsonb_build_object('tag_id', t.tag_id, 'name', t.name)) FILTER (WHERE t.tag_id IS NOT NULL), '[]') AS tags + FROM public.recipes r + LEFT JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id + LEFT JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id + LEFT JOIN public.recipe_tags rt ON r.recipe_id = rt.recipe_id + LEFT JOIN public.tags t ON rt.tag_id = t.tag_id + WHERE r.recipe_id = $1 + GROUP BY r.recipe_id; + `; + const res = await getPool().query(query, [recipeId]); + return res.rows[0]; + } catch (error) { + logger.error('Database error in getRecipeById:', { error, recipeId }); + throw new Error('Failed to retrieve recipe.'); + } +} + /** * Retrieves all comments for a specific recipe. * @param recipeId The ID of the recipe. diff --git a/src/services/db/shopping.db.test.ts b/src/services/db/shopping.db.test.ts index 648468d5..242a4594 100644 --- a/src/services/db/shopping.db.test.ts +++ b/src/services/db/shopping.db.test.ts @@ -1,6 +1,6 @@ // src/services/db/shopping.db.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { mockPoolInstance } from '../../tests/setup/tests-setup-unit'; +import { mockPoolInstance } from '../../tests/setup/mock-db'; import { createMockShoppingList, createMockShoppingListItem } from '../../tests/utils/mockFactories'; // Un-mock the module we are testing to ensure we use the real implementation. @@ -14,6 +14,13 @@ import { updateShoppingListItem, removeShoppingListItem, completeShoppingList, + generateShoppingListForMenuPlan, + addMenuPlanToShoppingList, + getPantryLocations, + createPantryLocation, + getShoppingTripHistory, + createReceipt, + findReceiptOwner, } from './shopping.db'; // Mock the logger to prevent console output during tests @@ -65,6 +72,12 @@ describe('Shopping DB Service', () => { mockPoolInstance.query.mockRejectedValue(dbError); await expect(createShoppingList('non-existent-user', 'Wont work')).rejects.toThrow('The specified user does not exist.'); }); + + it('should throw a generic error if the database query fails for other reasons', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(createShoppingList('user-1', 'New List')).rejects.toThrow('Failed to create shopping list.'); + }); }); describe('deleteShoppingList', () => { @@ -78,6 +91,12 @@ describe('Shopping DB Service', () => { mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' }); await expect(deleteShoppingList(999, 'user-1')).rejects.toThrow('Shopping list not found or user does not have permission to delete.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(deleteShoppingList(1, 'user-1')).rejects.toThrow('Failed to delete shopping list.'); + }); }); describe('addShoppingListItem', () => { @@ -111,6 +130,12 @@ describe('Shopping DB Service', () => { mockPoolInstance.query.mockRejectedValue(dbError); await expect(addShoppingListItem(999, { masterItemId: 999 })).rejects.toThrow('The specified shopping list or master item does not exist.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(addShoppingListItem(1, { customItemName: 'Test' })).rejects.toThrow('Failed to add item to shopping list.'); + }); }); describe('updateShoppingListItem', () => { @@ -131,6 +156,12 @@ describe('Shopping DB Service', () => { mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'UPDATE' }); await expect(updateShoppingListItem(999, { quantity: 5 })).rejects.toThrow('Shopping list item not found.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(updateShoppingListItem(1, { is_purchased: true })).rejects.toThrow('Failed to update shopping list item.'); + }); }); describe('removeShoppingListItem', () => { @@ -144,6 +175,12 @@ describe('Shopping DB Service', () => { mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' }); await expect(removeShoppingListItem(999)).rejects.toThrow('Shopping list item not found.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(removeShoppingListItem(1)).rejects.toThrow('Failed to remove item from shopping list.'); + }); }); describe('completeShoppingList', () => { @@ -160,5 +197,125 @@ describe('Shopping DB Service', () => { mockPoolInstance.query.mockRejectedValue(dbError); await expect(completeShoppingList(999, 'user-123')).rejects.toThrow('The specified shopping list does not exist.'); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Function Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(completeShoppingList(1, 'user-123')).rejects.toThrow('Failed to complete shopping list.'); + }); + }); + + describe('generateShoppingListForMenuPlan', () => { + it('should call the correct database function and return items', async () => { + const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }]; + mockPoolInstance.query.mockResolvedValue({ rows: mockItems }); + const result = await generateShoppingListForMenuPlan(1, 'user-1'); + expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [1, 'user-1']); + expect(result).toEqual(mockItems); + }); + + it('should throw an error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(generateShoppingListForMenuPlan(1, 'user-1')).rejects.toThrow('Failed to generate shopping list for menu plan.'); + }); + }); + + describe('addMenuPlanToShoppingList', () => { + it('should call the correct database function and return added items', async () => { + const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }]; + mockPoolInstance.query.mockResolvedValue({ rows: mockItems }); + const result = await addMenuPlanToShoppingList(1, 10, 'user-1'); + expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [1, 10, 'user-1']); + expect(result).toEqual(mockItems); + }); + + it('should throw an error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(addMenuPlanToShoppingList(1, 10, 'user-1')).rejects.toThrow('Failed to add menu plan to shopping list.'); + }); + }); + + describe('getPantryLocations', () => { + it('should return a list of pantry locations for a user', async () => { + const mockLocations = [{ pantry_location_id: 1, name: 'Fridge', user_id: 'user-1' }]; + mockPoolInstance.query.mockResolvedValue({ rows: mockLocations }); + const result = await getPantryLocations('user-1'); + expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', ['user-1']); + expect(result).toEqual(mockLocations); + }); + + it('should throw an error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(getPantryLocations('user-1')).rejects.toThrow('Failed to get pantry locations.'); + }); + }); + + describe('createPantryLocation', () => { + it('should insert a new pantry location and return it', async () => { + const mockLocation = { pantry_location_id: 1, name: 'Freezer', user_id: 'user-1' }; + mockPoolInstance.query.mockResolvedValue({ rows: [mockLocation] }); + const result = await createPantryLocation('user-1', 'Freezer'); + expect(mockPoolInstance.query).toHaveBeenCalledWith('INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *', ['user-1', 'Freezer']); + expect(result).toEqual(mockLocation); + }); + + it('should throw UniqueConstraintError on duplicate name', async () => { + const dbError = new Error('duplicate key value violates unique constraint'); + (dbError as any).code = '23505'; + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(createPantryLocation('user-1', 'Fridge')).rejects.toThrow('A pantry location with this name already exists.'); + }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(createPantryLocation('user-1', 'Pantry')).rejects.toThrow('Failed to create pantry location.'); + }); + }); + + describe('getShoppingTripHistory', () => { + it('should return a list of shopping trips for a user', async () => { + const mockTrips = [{ shopping_trip_id: 1, user_id: 'user-1', items: [] }]; + mockPoolInstance.query.mockResolvedValue({ rows: mockTrips }); + const result = await getShoppingTripHistory('user-1'); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_trips st'), ['user-1']); + expect(result).toEqual(mockTrips); + }); + + it('should throw an error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(getShoppingTripHistory('user-1')).rejects.toThrow('Failed to retrieve shopping trip history.'); + }); + }); + + describe('createReceipt', () => { + it('should insert a new receipt and return it', async () => { + const mockReceipt = { receipt_id: 1, user_id: 'user-1', receipt_image_url: 'url', status: 'pending' }; + mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] }); + const result = await createReceipt('user-1', 'url'); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.receipts'), ['user-1', 'url']); + expect(result).toEqual(mockReceipt); + }); + + it('should throw ForeignKeyConstraintError if user does not exist', async () => { + const dbError = new Error('violates foreign key constraint'); + (dbError as any).code = '23503'; + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(createReceipt('non-existent-user', 'url')).rejects.toThrow('The specified user does not exist.'); + }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(createReceipt('user-1', 'url')).rejects.toThrow('Failed to create receipt record.'); + }); + }); + + describe('findReceiptOwner', () => { + it('should return the user_id of the receipt owner', async () => { + const mockOwner = { user_id: 'owner-123' }; + mockPoolInstance.query.mockResolvedValue({ rows: [mockOwner] }); + const result = await findReceiptOwner(1); + expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT user_id FROM public.receipts WHERE receipt_id = $1', [1]); + expect(result).toEqual(mockOwner); + }); }); }); \ No newline at end of file diff --git a/src/services/db/user.db.test.ts b/src/services/db/user.db.test.ts index 5224a9c1..babe0672 100644 --- a/src/services/db/user.db.test.ts +++ b/src/services/db/user.db.test.ts @@ -24,6 +24,7 @@ import { unfollowUser, getUserFeed, logSearchQuery, + deleteRefreshToken, } from './user.db'; import { resetFailedLoginAttempts } from '../db/user.db'; @@ -54,6 +55,12 @@ describe('User DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE email = $1'), ['test@example.com']); expect(result).toEqual(mockUser); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(findUserByEmail('test@example.com')).rejects.toThrow('Failed to retrieve user from database.'); + }); }); describe('createUser', () => { @@ -90,6 +97,14 @@ describe('User DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN'); expect(mockPoolInstance.query).toHaveBeenCalledWith('ROLLBACK'); }); + + it('should throw UniqueConstraintError if the email already exists', async () => { + const dbError = new Error('duplicate key value violates unique constraint'); + (dbError as any).code = '23505'; + mockPoolInstance.query.mockRejectedValue(dbError); + + await expect(createUser('exists@example.com', 'pass', {})).rejects.toThrow('A user with this email address already exists.'); + }); }); describe('findUserById', () => { @@ -98,6 +113,12 @@ describe('User DB Service', () => { await findUserById('123'); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE user_id = $1'), ['123']); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(findUserById('123')).rejects.toThrow('Failed to retrieve user by ID from database.'); + }); }); describe('findUserWithPasswordHashById', () => { @@ -106,6 +127,12 @@ describe('User DB Service', () => { await findUserWithPasswordHashById('123'); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT user_id, email, password_hash'), ['123']); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(findUserWithPasswordHashById('123')).rejects.toThrow('Failed to retrieve user with sensitive data by ID from database.'); + }); }); describe('findUserProfileById', () => { @@ -115,6 +142,12 @@ describe('User DB Service', () => { // The actual query uses 'p.user_id' due to the join alias expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']); }); + + it('should throw a generic error if the database query fails', async () => { + const dbError = new Error('DB Connection Error'); + mockPoolInstance.query.mockRejectedValue(dbError); + await expect(findUserProfileById('123')).rejects.toThrow('Failed to retrieve user profile from database.'); + }); }); describe('updateUserProfile', () => { @@ -126,6 +159,21 @@ describe('User DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.profiles'), expect.any(Array)); }); + + it('should fetch the current profile if no update fields are provided', async () => { + const mockProfile: Profile = { user_id: '123', full_name: 'Current Name', role: 'user', points: 0 }; + mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] }); + + const result = await updateUserProfile('123', {}); + + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']); + expect(result).toEqual(mockProfile); + }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(updateUserProfile('123', { full_name: 'Fail' })).rejects.toThrow('Failed to update user profile in database.'); + }); }); describe('updateUserPreferences', () => { @@ -134,6 +182,11 @@ describe('User DB Service', () => { await updateUserPreferences('123', { darkMode: true }); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("SET preferences = COALESCE(preferences, '{}'::jsonb) || $1"), [{ darkMode: true }, '123']); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(updateUserPreferences('123', { darkMode: true })).rejects.toThrow('Failed to update user preferences in database.'); + }); }); describe('updateUserPassword', () => { @@ -142,6 +195,11 @@ describe('User DB Service', () => { await updateUserPassword('123', 'newhash'); expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET password_hash = $1 WHERE user_id = $2', ['newhash', '123']); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(updateUserPassword('123', 'newhash')).rejects.toThrow('Failed to update user password in database.'); + }); }); describe('deleteUserById', () => { @@ -150,6 +208,11 @@ describe('User DB Service', () => { await deleteUserById('123'); expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.users WHERE user_id = $1', ['123']); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(deleteUserById('123')).rejects.toThrow('Failed to delete user from database.'); + }); }); describe('saveRefreshToken', () => { @@ -158,6 +221,11 @@ describe('User DB Service', () => { await saveRefreshToken('123', 'new-token'); expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET refresh_token = $1 WHERE user_id = $2', ['new-token', '123']); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(saveRefreshToken('123', 'new-token')).rejects.toThrow('Failed to save refresh token.'); + }); }); describe('findUserByRefreshToken', () => { @@ -166,6 +234,22 @@ describe('User DB Service', () => { await findUserByRefreshToken('a-token'); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE refresh_token = $1'), ['a-token']); }); + + it('should return undefined if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + const result = await findUserByRefreshToken('a-token'); + expect(result).toBeUndefined(); + }); + }); + + describe('deleteRefreshToken', () => { + it('should execute an UPDATE query to set the refresh token to NULL', async () => { + mockPoolInstance.query.mockResolvedValue({ rows: [] }); + await deleteRefreshToken('a-token'); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + 'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', ['a-token'] + ); + }); }); describe('createPasswordResetToken', () => { @@ -176,6 +260,12 @@ describe('User DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE user_id = $1', ['123']); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.password_reset_tokens'), ['123', 'token-hash', expires]); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + const expires = new Date(); + await expect(createPasswordResetToken('123', 'token-hash', expires)).rejects.toThrow('Failed to create password reset token.'); + }); }); describe('getValidResetTokens', () => { @@ -184,6 +274,11 @@ describe('User DB Service', () => { await getValidResetTokens(); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE expires_at > NOW()')); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(getValidResetTokens()).rejects.toThrow('Failed to retrieve valid reset tokens.'); + }); }); describe('deleteResetToken', () => { @@ -210,6 +305,11 @@ describe('User DB Service', () => { expect(getWatchedItems).toHaveBeenCalledWith('123'); expect(getShoppingLists).toHaveBeenCalledWith('123'); }); + + it('should throw an error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.'); + }); }); describe('followUser', () => { @@ -229,6 +329,11 @@ describe('User DB Service', () => { mockPoolInstance.query.mockRejectedValue(dbError); await expect(followUser('user-1', 'non-existent-user')).rejects.toThrow('One or both of the specified users do not exist.'); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(followUser('user-1', 'user-2')).rejects.toThrow('Failed to follow user.'); + }); }); describe('unfollowUser', () => { @@ -237,6 +342,11 @@ describe('User DB Service', () => { await unfollowUser('user-1', 'user-2'); expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', ['user-1', 'user-2']); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(unfollowUser('user-1', 'user-2')).rejects.toThrow('Failed to unfollow user.'); + }); }); describe('getUserFeed', () => { @@ -245,6 +355,11 @@ describe('User DB Service', () => { await getUserFeed('user-123', 10, 20); expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_user_feed($1, $2, $3)', ['user-123', 10, 20]); }); + + it('should throw a generic error if the database query fails', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(getUserFeed('user-123', 10, 20)).rejects.toThrow('Failed to retrieve user feed.'); + }); }); describe('logSearchQuery', () => { @@ -254,6 +369,12 @@ describe('User DB Service', () => { await logSearchQuery(queryData); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.search_queries'), [queryData.userId, queryData.queryText, queryData.resultCount, queryData.wasSuccessful]); }); + + it('should not throw an error if the database query fails (non-critical)', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + const queryData = { userId: 'user-123', queryText: 'apples', resultCount: 5, wasSuccessful: true }; + await expect(logSearchQuery(queryData)).resolves.toBeUndefined(); + }); }); describe('resetFailedLoginAttempts', () => { @@ -262,5 +383,10 @@ describe('User DB Service', () => { await resetFailedLoginAttempts('user-123', '192.168.1.1'); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.users SET failed_login_attempts = 0'), ['user-123', '192.168.1.1']); }); + + it('should not throw an error if the database query fails (non-critical)', async () => { + mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); + await expect(resetFailedLoginAttempts('user-123', '192.168.1.1')).resolves.toBeUndefined(); + }); }); }); \ No newline at end of file diff --git a/src/services/emailService.server.test.ts b/src/services/emailService.server.test.ts index 0dd9a459..7ebd9e54 100644 --- a/src/services/emailService.server.test.ts +++ b/src/services/emailService.server.test.ts @@ -34,7 +34,7 @@ vi.mock('./logger.server', () => ({ })); // Now that mocks are set up, we can import the service under test. -import { sendPasswordResetEmail, sendWelcomeEmail } from './emailService.server'; +import { sendPasswordResetEmail, sendWelcomeEmail, sendDealNotificationEmail } from './emailService.server'; describe('Email Service (Server)', () => { beforeEach(async () => { @@ -100,4 +100,39 @@ describe('Email Service (Server)', () => { expect(mailOptions.html).toContain('

Hello there,

'); }); }); + + describe('sendDealNotificationEmail', () => { + const mockDeals = [ + { item_name: 'Apples', best_price_in_cents: 199, store_name: 'Green Grocer' }, + { item_name: 'Milk', best_price_in_cents: 350, store_name: 'Dairy Farm' }, + ]; + + it('should send a personalized email with a list of deals', async () => { + const to = 'deal.hunter@example.com'; + const name = 'Deal Hunter'; + + await sendDealNotificationEmail(to, name, mockDeals as any); + + expect(mocks.sendMail).toHaveBeenCalledTimes(1); + const mailOptions = mocks.sendMail.mock.calls[0][0] as { to: string, subject: string, text: string, html: string }; + + expect(mailOptions.to).toBe(to); + expect(mailOptions.subject).toBe('New Deals Found on Your Watched Items!'); + expect(mailOptions.html).toContain('

Hi Deal Hunter,

'); + expect(mailOptions.html).toContain('Apples is on sale for $1.99 at Green Grocer!'); + expect(mailOptions.html).toContain('Milk is on sale for $3.50 at Dairy Farm!'); + }); + + it('should send a generic email when name is null', async () => { + const to = 'anonymous.user@example.com'; + + await sendDealNotificationEmail(to, null, mockDeals as any); + + expect(mocks.sendMail).toHaveBeenCalledTimes(1); + const mailOptions = mocks.sendMail.mock.calls[0][0] as { to: string, subject: string, html: string }; + + expect(mailOptions.to).toBe(to); + expect(mailOptions.html).toContain('

Hi there,

'); + }); + }); }); \ No newline at end of file diff --git a/src/services/flyerProcessingService.server.test.ts b/src/services/flyerProcessingService.server.test.ts index 27d75d1a..6163a332 100644 --- a/src/services/flyerProcessingService.server.test.ts +++ b/src/services/flyerProcessingService.server.test.ts @@ -163,6 +163,24 @@ describe('FlyerProcessingService', () => { expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); }); + + it('should throw an error if the database service fails', async () => { + const job = createMockJob({}); + const dbError = new Error('Database transaction failed'); + mockedDb.createFlyerAndItems.mockRejectedValue(dbError); + + await expect(service.processJob(job)).rejects.toThrow('Database transaction failed'); + + expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' }); + expect(mockCleanupQueue.add).not.toHaveBeenCalled(); + }); + + it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => { + const job = createMockJob({}); + mockedDb.createFlyerAndItems.mockRejectedValue(new Error('DB Error')); + await expect(service.processJob(job)).rejects.toThrow(); + expect(mockCleanupQueue.add).not.toHaveBeenCalled(); + }); }); describe('prepareImageInputs (Step 1)', () => { @@ -189,6 +207,24 @@ describe('FlyerProcessingService', () => { expect(result.createdImagePaths).toHaveLength(2); expect(result.createdImagePaths[0]).toContain('flyer-1.jpg'); }); + + it('should throw an error if PDF conversion command fails', async () => { + const job = createMockJob({}); + const execError = new Error('pdftocairo not found'); + mocks.execAsync.mockRejectedValue(execError); + + await expect(service.prepareImageInputs('/tmp/flyer.pdf', job)).rejects.toThrow(execError); + }); + + it('should throw an error if PDF conversion produces no images', async () => { + const job = createMockJob({}); + // Mock readdir to return an empty array + mocks.readdir.mockResolvedValue([]); + + await expect(service.prepareImageInputs('/tmp/flyer.pdf', job)).rejects.toThrow( + 'PDF conversion resulted in 0 images for file: /tmp/flyer.pdf. The PDF might be blank or corrupt.' + ); + }); }); describe('extractFlyerDataWithAI (Step 2)', () => { @@ -212,6 +248,20 @@ describe('FlyerProcessingService', () => { '123 Main St' ); }); + + it('should throw an error if AI response fails Zod validation', async () => { + const imagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }]; + const jobData: FlyerJobData = { filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg', checksum: 'checksum-123' }; + const invalidAiResponse = { + store_name: 'Invalid Store', + items: [{ item: 'Bad Item', price_in_cents: 'not-a-number' }], // price_in_cents should be a number + }; + vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidAiResponse as any); + + await expect(service.extractFlyerDataWithAI(imagePaths, jobData)).rejects.toThrow( + 'AI response validation failed. The returned data structure is incorrect.' + ); + }); }); describe('saveProcessedFlyerData (Step 3)', () => { diff --git a/src/services/geocodingService.server.test.ts b/src/services/geocodingService.server.test.ts new file mode 100644 index 00000000..fd7c5700 --- /dev/null +++ b/src/services/geocodingService.server.test.ts @@ -0,0 +1,215 @@ +// src/services/geocodingService.server.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest'; + +// --- Hoisted Mocks --- +const mocks = vi.hoisted(() => ({ + // Mock the redis connection object from queueService + mockRedis: { + get: vi.fn(), + set: vi.fn(), + scan: vi.fn(), + del: vi.fn(), + }, + // Mock the fallback geocoding service + mockGeocodeWithNominatim: vi.fn(), +})); + +// --- Mock Modules --- +vi.mock('./queueService.server', () => ({ + connection: mocks.mockRedis, +})); + +vi.mock('./nominatimGeocodingService.server', () => ({ + geocodeWithNominatim: mocks.mockGeocodeWithNominatim, +})); + +vi.mock('./logger.server', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Import the service to be tested AFTER all mocks are set up +import { geocodeAddress, clearGeocodeCache } from './geocodingService.server'; +import { logger } from './logger.server'; + +describe('Geocoding Service', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock the global fetch function + vi.stubGlobal('fetch', vi.fn()); + // Restore process.env to its original state before each test + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // Restore process.env after each test + process.env = originalEnv; + }); + + describe('geocodeAddress', () => { + const address = '123 Main St, Anytown'; + const cacheKey = `geocode:${address}`; + const coordinates = { lat: 45.0, lng: -75.0 }; + + it('should return coordinates from Redis cache if available', async () => { + // Arrange: Mock Redis to return a cached result + mocks.mockRedis.get.mockResolvedValue(JSON.stringify(coordinates)); + + // Act + const result = await geocodeAddress(address); + + // Assert + expect(result).toEqual(coordinates); + expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey); + expect(fetch).not.toHaveBeenCalled(); + expect(mocks.mockGeocodeWithNominatim).not.toHaveBeenCalled(); + }); + + it('should log a warning but continue if Redis GET fails', async () => { + // Arrange: Mock Redis 'get' to fail, but Google API to succeed + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + mocks.mockRedis.get.mockRejectedValue(new Error('Redis down')); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }), + } as Response); + + // Act + const result = await geocodeAddress(address); + + // Assert + expect(result).toEqual(coordinates); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Redis GET command failed'), expect.any(Object)); + expect(fetch).toHaveBeenCalled(); // Should still proceed to fetch + }); + + it('should fetch from Google, return coordinates, and cache the result on cache miss', async () => { + // Arrange + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + mocks.mockRedis.get.mockResolvedValue(null); // Cache miss + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }), + } as Response); + + // Act + const result = await geocodeAddress(address); + + // Assert + expect(result).toEqual(coordinates); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('maps.googleapis.com')); + expect(mocks.mockRedis.set).toHaveBeenCalledWith(cacheKey, JSON.stringify(coordinates), 'EX', expect.any(Number)); + expect(mocks.mockGeocodeWithNominatim).not.toHaveBeenCalled(); + }); + + it('should fall back to Nominatim if Google API key is missing', async () => { + // Arrange + delete process.env.GOOGLE_MAPS_API_KEY; + mocks.mockRedis.get.mockResolvedValue(null); + mocks.mockGeocodeWithNominatim.mockResolvedValue(coordinates); + + // Act + const result = await geocodeAddress(address); + + // Assert + expect(result).toEqual(coordinates); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('GOOGLE_MAPS_API_KEY is not set')); + expect(fetch).not.toHaveBeenCalled(); + expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address); + expect(mocks.mockRedis.set).toHaveBeenCalled(); + }); + + it('should fall back to Nominatim if Google API returns a non-OK status', async () => { + // Arrange + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + mocks.mockRedis.get.mockResolvedValue(null); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ status: 'ZERO_RESULTS', results: [] }), + } as Response); + mocks.mockGeocodeWithNominatim.mockResolvedValue(coordinates); + + // Act + const result = await geocodeAddress(address); + + // Assert + expect(result).toEqual(coordinates); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Geocoding with Google failed'), expect.any(Object)); + expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address); + }); + + it('should fall back to Nominatim if Google API fetch call fails', async () => { + // Arrange + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + mocks.mockRedis.get.mockResolvedValue(null); + vi.mocked(fetch).mockRejectedValue(new Error('Network Error')); + mocks.mockGeocodeWithNominatim.mockResolvedValue(coordinates); + + // Act + const result = await geocodeAddress(address); + + // Assert + expect(result).toEqual(coordinates); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('An error occurred while calling the Google Maps'), expect.any(Object)); + expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address); + }); + + it('should return null if both Google and Nominatim fail', async () => { + // Arrange + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + mocks.mockRedis.get.mockResolvedValue(null); + vi.mocked(fetch).mockRejectedValue(new Error('Network Error')); + mocks.mockGeocodeWithNominatim.mockResolvedValue(null); // Nominatim also fails + + // Act + const result = await geocodeAddress(address); + + // Assert + expect(result).toBeNull(); + expect(mocks.mockRedis.set).not.toHaveBeenCalled(); // Should not cache a null result + }); + }); + + describe('clearGeocodeCache', () => { + it('should scan and delete keys in batches', async () => { + // Arrange: Mock SCAN to run twice + mocks.mockRedis.scan + .mockResolvedValueOnce(['10', ['geocode:key1', 'geocode:key2']]) // First batch + .mockResolvedValueOnce(['0', ['geocode:key3']]); // Second batch, cursor is '0' to end loop + + // Mock DEL to return the number of keys it "deleted" + mocks.mockRedis.del + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(1); + + // Act + const result = await clearGeocodeCache(); + + // Assert + expect(result).toBe(3); // 2 + 1 + expect(mocks.mockRedis.scan).toHaveBeenCalledTimes(2); + expect(mocks.mockRedis.del).toHaveBeenCalledTimes(2); + expect(mocks.mockRedis.del).toHaveBeenCalledWith(['geocode:key1', 'geocode:key2']); + expect(mocks.mockRedis.del).toHaveBeenCalledWith(['geocode:key3']); + }); + + it('should return 0 if no keys match the pattern', async () => { + // Arrange: Mock SCAN to find no keys + mocks.mockRedis.scan.mockResolvedValueOnce(['0', []]); + + // Act + const result = await clearGeocodeCache(); + + // Assert + expect(result).toBe(0); + expect(mocks.mockRedis.scan).toHaveBeenCalledTimes(1); + expect(mocks.mockRedis.del).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/services/nominatimGeocodingService.server.test.ts b/src/services/nominatimGeocodingService.server.test.ts new file mode 100644 index 00000000..15ec710b --- /dev/null +++ b/src/services/nominatimGeocodingService.server.test.ts @@ -0,0 +1,74 @@ +// src/services/nominatimGeocodingService.server.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the logger to prevent console output and allow for assertions +vi.mock('./logger.server', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Import the function to be tested after setting up mocks +import { geocodeWithNominatim } from './nominatimGeocodingService.server'; +import { logger } from './logger.server'; + +describe('Nominatim Geocoding Service', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock the global fetch function before each test + vi.stubGlobal('fetch', vi.fn()); + }); + + it('should return coordinates for a valid address', async () => { + // Arrange: Mock a successful API response from Nominatim + const mockApiResponse = [{ + lat: '48.4284', + lon: '-123.3656', + display_name: 'Victoria, BC, Canada', + }]; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + } as Response); + + // Act + const result = await geocodeWithNominatim('Victoria, BC'); + + // Assert + expect(result).toEqual({ lat: 48.4284, lng: -123.3656 }); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Successfully geocoded address'), expect.any(Object)); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should return null if the API returns no results', async () => { + // Arrange: Mock an empty array response + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => [], + } as Response); + + // Act + const result = await geocodeWithNominatim('NonExistentPlace 12345'); + + // Assert + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith('[NominatimService] Geocoding failed or returned no results.', { address: 'NonExistentPlace 12345' }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should return null and log an error if the fetch call fails', async () => { + // Arrange: Mock the fetch call to reject with an error + const networkError = new Error('Network request failed'); + vi.mocked(fetch).mockRejectedValue(networkError); + + // Act + const result = await geocodeWithNominatim('Any Address'); + + // Assert + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith('[NominatimService] An error occurred while calling the Nominatim API.', { error: networkError }); + }); +}); \ No newline at end of file diff --git a/src/services/queueService.server.test.ts b/src/services/queueService.server.test.ts new file mode 100644 index 00000000..de2112fc --- /dev/null +++ b/src/services/queueService.server.test.ts @@ -0,0 +1,127 @@ +// src/services/queueService.server.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// --- Hoisted Mocks --- +const mocks = vi.hoisted(() => { + // Create a mock EventEmitter to simulate IORedis connection events. + const mockRedisConnection = new EventEmitter(); + // Add a mock 'ping' method required by other tests. + (mockRedisConnection as any).ping = vi.fn().mockResolvedValue('PONG'); + + // Mock the Worker class from bullmq + const MockWorker = vi.fn(function (this: any, name: string) { + this.name = name; + this.on = vi.fn(); + this.close = vi.fn().mockResolvedValue(undefined); + this.isRunning = vi.fn().mockReturnValue(true); + return this; + }); + + return { + mockRedisConnection, + MockWorker, + // Mock the Queue class from bullmq + MockQueue: vi.fn(function (this: any, name: string) { + this.name = name; + this.add = vi.fn(); + this.close = vi.fn().mockResolvedValue(undefined); + return this; + }), + }; +}); + +// --- Mock Modules --- +vi.mock('bullmq', () => ({ + Worker: mocks.MockWorker, + Queue: mocks.MockQueue, +})); + +vi.mock('ioredis', () => ({ + // Mock the default export which is the IORedis class constructor + default: vi.fn(() => mocks.mockRedisConnection), +})); + +vi.mock('./logger.server', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock other dependencies that are not the focus of this test file. +vi.mock('./aiService.server'); +vi.mock('./emailService.server'); +vi.mock('./db/index.db'); + +describe('Queue Service Setup and Lifecycle', () => { + let logger: any; + let gracefulShutdown: (signal: string) => Promise; + let flyerWorker: any, emailWorker: any, analyticsWorker: any, cleanupWorker: any; + + beforeEach(async () => { + vi.clearAllMocks(); + // Reset modules to re-evaluate the queueService.server.ts file with fresh mocks + vi.resetModules(); + + // Dynamically import the modules after mocks are set up + const queueService = await import('./queueService.server'); + const loggerModule = await import('./logger.server'); + + // Capture the imported instances for use in tests + logger = loggerModule.logger; + gracefulShutdown = queueService.gracefulShutdown; + flyerWorker = queueService.flyerWorker; + emailWorker = queueService.emailWorker; + analyticsWorker = queueService.analyticsWorker; + cleanupWorker = queueService.cleanupWorker; + }); + + it('should log a success message when Redis connects', () => { + // Act: Simulate the 'connect' event on the mock Redis connection + mocks.mockRedisConnection.emit('connect'); + + // Assert: Check if the logger was called with the expected message + expect(logger.info).toHaveBeenCalledWith('[Redis] Connection established successfully.'); + }); + + it('should log an error message when Redis connection fails', () => { + const redisError = new Error('Connection refused'); + mocks.mockRedisConnection.emit('error', redisError); + expect(logger.error).toHaveBeenCalledWith('[Redis] Connection error.', { error: redisError }); + }); + + it('should attach completion and failure listeners to all workers', () => { + // The workers are instantiated when the module is imported in beforeEach. + // We just need to check that the 'on' method was called for each event. + const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker]; + for (const worker of workers) { + expect(worker.on).toHaveBeenCalledWith('completed', expect.any(Function)); + expect(worker.on).toHaveBeenCalledWith('failed', expect.any(Function)); + } + }); + + describe('gracefulShutdown', () => { + let processExitSpy: any; + + beforeEach(() => { + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + }); + + afterEach(() => { + processExitSpy.mockRestore(); + }); + + it('should close all workers and exit the process', async () => { + await gracefulShutdown('SIGINT'); + expect(flyerWorker.close).toHaveBeenCalled(); + expect(emailWorker.close).toHaveBeenCalled(); + expect(analyticsWorker.close).toHaveBeenCalled(); + expect(cleanupWorker.close).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('[Shutdown] All workers have been closed.'); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + }); +}); \ No newline at end of file diff --git a/src/services/queueService.workers.test.ts b/src/services/queueService.workers.test.ts index 55b763de..b6ebc5c7 100644 --- a/src/services/queueService.workers.test.ts +++ b/src/services/queueService.workers.test.ts @@ -1,6 +1,6 @@ // src/services/queueService.workers.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Job } from 'bullmq'; +import { Job, Worker } from 'bullmq'; // --- Hoisted Mocks --- const mocks = vi.hoisted(() => ({ @@ -27,8 +27,17 @@ vi.mock('./logger.server', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); -// Import the workers after all mocks are set up. -import { emailWorker, analyticsWorker, cleanupWorker } from './queueService.server'; +// Mock bullmq to capture the processor function passed to the Worker constructor +vi.mock('bullmq'); + +// Import the module under test AFTER the mocks are set up. +// This will trigger the instantiation of the workers. +import './queueService.server'; + +// Capture the processor functions from the mocked Worker constructor calls +const emailProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'email-sending')?.[1] as (job: Job) => Promise; +const analyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'analytics-reporting')?.[1] as (job: Job) => Promise; +const cleanupProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'file-cleanup')?.[1] as (job: Job) => Promise; // Helper to create a mock BullMQ Job object const createMockJob = (data: T): Job => { @@ -51,9 +60,6 @@ describe('Queue Workers', () => { }); describe('emailWorker', () => { - // The emailWorker's processor is the second argument to its constructor. - const emailProcessor = emailWorker.processJob as (job: Job) => Promise; - it('should call emailService.sendEmail with the job data', async () => { const jobData = { to: 'test@example.com', @@ -79,8 +85,6 @@ describe('Queue Workers', () => { }); describe('analyticsWorker', () => { - const analyticsProcessor = analyticsWorker.processJob as (job: Job) => Promise; - it('should complete successfully for a valid report date', async () => { vi.useFakeTimers(); const job = createMockJob({ reportDate: '2024-01-01' }); @@ -103,8 +107,6 @@ describe('Queue Workers', () => { }); describe('cleanupWorker', () => { - const cleanupProcessor = cleanupWorker.processJob as (job: Job) => Promise; - it('should call unlink for each path provided in the job data', async () => { const jobData = { flyerId: 123, diff --git a/src/types.ts b/src/types.ts index 091a313d..89f52c57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -831,4 +831,17 @@ export interface LeaderboardUser { avatar_url: string | null; points: number; rank: string; // RANK() returns a bigint, which the pg driver returns as a string. +} + +/** + * Defines the shape of the user data returned for the admin user list. + * This is a public-facing type and does not include sensitive fields. + */ +export interface AdminUserView { + user_id: string; + email: string; + created_at: string; + role: 'admin' | 'user'; + full_name: string | null; + avatar_url: string | null; } \ No newline at end of file