moar unit test !
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 6m42s

This commit is contained in:
2025-12-08 10:06:13 -08:00
parent 66a2585efc
commit e022a4a2cc
19 changed files with 1486 additions and 105 deletions

View File

@@ -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<HttpResponse<Record<string, unknown>>> => {
let body: Record<string, unknown> = {};
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<string, unknown> | FormData = {};
const contentType = request.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
try {
body = await request.json() as Record<string, unknown>;
} 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<string, unknown>;
}
} 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<string, unknown> = {};
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 = {

View File

@@ -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);
});
});
});

View File

@@ -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 });
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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();

View File

@@ -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.');
});
});
});

View File

@@ -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.');
});
});
});

View File

@@ -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']);
});
});
});

View File

@@ -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.');
});
});
});

View File

@@ -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<Recipe | undefined> {
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<Recipe>(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.

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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('<p>Hello there,</p>');
});
});
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('<h1>Hi Deal Hunter,</h1>');
expect(mailOptions.html).toContain('<strong>Apples</strong> is on sale for <strong>$1.99</strong> at Green Grocer!');
expect(mailOptions.html).toContain('<strong>Milk</strong> is on sale for <strong>$3.50</strong> 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('<h1>Hi there,</h1>');
});
});
});

View File

@@ -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)', () => {

View File

@@ -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();
});
});
});

View File

@@ -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 });
});
});

View File

@@ -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<void>;
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);
});
});
});

View File

@@ -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<void>;
const analyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'analytics-reporting')?.[1] as (job: Job) => Promise<void>;
const cleanupProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'file-cleanup')?.[1] as (job: Job) => Promise<void>;
// Helper to create a mock BullMQ Job object
const createMockJob = <T>(data: T): Job<T> => {
@@ -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<void>;
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<void>;
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<void>;
it('should call unlink for each path provided in the job data', async () => {
const jobData = {
flyerId: 123,

View File

@@ -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;
}