lootsa tests fixes
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m35s

This commit is contained in:
2025-12-05 15:08:59 -08:00
parent 64afc5853d
commit 5cf1e610b3
14 changed files with 810 additions and 300 deletions

View File

@@ -5,7 +5,9 @@ import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import fs from 'node:fs/promises';
import adminRouter from './admin.routes'; // Correctly imported
import { UserProfile } from '../types';
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockActivityLogItem } from '../tests/utils/mockFactories';
import { Job } from 'bullmq';
import { UserProfile, SuggestedCorrection, Brand, Recipe, RecipeComment, User, UnmatchedFlyerItem } from '../types';
// Mock the specific DB modules used by the admin router.
// This is more robust than mocking the barrel file ('../services/db').
@@ -14,12 +16,29 @@ vi.mock('../services/db/flyer.db');
vi.mock('../services/db/recipe.db');
vi.mock('../services/db/user.db'); // Add mock for user.db
// Mock the background job service to test the trigger endpoint.
vi.mock('../services/backgroundJobService');
// Mock the geocoding service to test the cache clear endpoint.
vi.mock('../services/geocodingService.server');
// Mock the queue service to test job enqueuing endpoints.
vi.mock('../services/queueService.server', () => ({
flyerQueue: { add: vi.fn() },
emailQueue: { add: vi.fn() },
analyticsQueue: { add: vi.fn() },
cleanupQueue: { add: vi.fn() },
}));
// Import the mocked modules to control them in tests.
import * as adminDb from '../services/db/admin.db';
import * as flyerDb from '../services/db/flyer.db';
import * as recipeDb from '../services/db/recipe.db';
import * as userDb from '../services/db/user.db'; // Import the mocked user.db
const mockedDb = { ...adminDb, ...flyerDb, ...recipeDb, ...userDb } as Mocked<typeof adminDb & typeof flyerDb & typeof recipeDb & typeof userDb>;
import { runDailyDealCheck } from '../services/backgroundJobService';
import { analyticsQueue, cleanupQueue } from '../services/queueService.server';
import { clearGeocodeCache } from '../services/geocodingService.server';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
@@ -34,7 +53,7 @@ vi.mock('../services/logger.server', () => ({
// Use vi.hoisted to create a mutable mock function reference that can be controlled in tests.
const mockedIsAdmin = vi.hoisted(() => vi.fn());
vi.mock('./passport', () => ({
vi.mock('./passport.routes', () => ({
// Mock the default export (the passport instance)
default: {
// The 'authenticate' method returns a middleware function. We mock that.
@@ -99,22 +118,18 @@ describe('Admin Routes (/api/admin)', () => {
beforeEach(() => {
// Arrange: For all tests in this block, simulate a logged-in admin user.
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => {
// Attach a mock admin user profile to the request object.
// The UserProfile type has a nested `user` object.
req.user = {
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
role: 'admin',
// Add missing properties to align with the UserProfile type
points: 0,
} as UserProfile;
// Use the factory to create a mock admin user.
req.user = createMockUserProfile({ role: 'admin', user_id: 'admin-user-id' });
next(); // Grant access
});
});
it('GET /corrections should return corrections data', async () => {
// Arrange
const mockCorrections: Awaited<ReturnType<typeof mockedDb.getSuggestedCorrections>> = [{ suggested_correction_id: 1, flyer_item_id: 1, user_id: '1', correction_type: 'price', suggested_value: 'New Price', status: 'pending', created_at: new Date().toISOString() }];
vi.mocked(adminDb.getSuggestedCorrections).mockResolvedValue(mockCorrections);
const mockCorrections: SuggestedCorrection[] = [
createMockSuggestedCorrection({ suggested_correction_id: 1 }),
];
mockedDb.getSuggestedCorrections.mockResolvedValue(mockCorrections);
// Act
const response = await supertest(app).get('/api/admin/corrections');
@@ -122,17 +137,17 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockCorrections);
expect(adminDb.getSuggestedCorrections).toHaveBeenCalledTimes(1);
expect(mockedDb.getSuggestedCorrections).toHaveBeenCalledTimes(1);
});
describe('GET /brands', () => {
it('should return a list of all brands on success', async () => {
// Arrange
const mockBrands: Awaited<ReturnType<typeof mockedDb.getAllBrands>> = [
{ brand_id: 1, name: 'Brand A', logo_url: '/path/a.png' },
{ brand_id: 2, name: 'Brand B', logo_url: '/path/b.png' },
const mockBrands: Brand[] = [
createMockBrand({ brand_id: 1, name: 'Brand A' }),
createMockBrand({ brand_id: 2, name: 'Brand B' }),
];
vi.mocked(flyerDb.getAllBrands).mockResolvedValue(mockBrands);
mockedDb.getAllBrands.mockResolvedValue(mockBrands);
// Act
const response = await supertest(app).get('/api/admin/brands');
@@ -140,11 +155,11 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockBrands);
expect(flyerDb.getAllBrands).toHaveBeenCalledTimes(1);
expect(mockedDb.getAllBrands).toHaveBeenCalledTimes(1);
});
it('should return a 500 error if the database call fails', async () => {
vi.mocked(flyerDb.getAllBrands).mockRejectedValue(new Error('Failed to fetch brands'));
mockedDb.getAllBrands.mockRejectedValue(new Error('Failed to fetch brands'));
const response = await supertest(app).get('/api/admin/brands');
expect(response.status).toBe(500);
});
@@ -160,7 +175,7 @@ describe('Admin Routes (/api/admin)', () => {
storeCount: 12,
pendingCorrectionCount: 5,
};
vi.mocked(adminDb.getApplicationStats).mockResolvedValue(mockStats);
mockedDb.getApplicationStats.mockResolvedValue(mockStats);
// Act
const response = await supertest(app).get('/api/admin/stats');
@@ -168,11 +183,11 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockStats);
expect(adminDb.getApplicationStats).toHaveBeenCalledTimes(1);
expect(mockedDb.getApplicationStats).toHaveBeenCalledTimes(1);
});
it('should return a 500 error if the database call fails', async () => {
vi.mocked(adminDb.getApplicationStats).mockRejectedValue(new Error('Failed to fetch stats'));
mockedDb.getApplicationStats.mockRejectedValue(new Error('Failed to fetch stats'));
const response = await supertest(app).get('/api/admin/stats');
expect(response.status).toBe(500);
});
@@ -184,8 +199,8 @@ describe('Admin Routes (/api/admin)', () => {
const mockDailyStats = [
{ date: '2024-01-01', new_users: 5, new_flyers: 10 },
{ date: '2024-01-02', new_users: 3, new_flyers: 8 },
] as Awaited<ReturnType<typeof mockedDb.getDailyStatsForLast30Days>>;
vi.mocked(adminDb.getDailyStatsForLast30Days).mockResolvedValue(mockDailyStats);
];
mockedDb.getDailyStatsForLast30Days.mockResolvedValue(mockDailyStats);
// Act
const response = await supertest(app).get('/api/admin/stats/daily');
@@ -193,11 +208,11 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockDailyStats);
expect(adminDb.getDailyStatsForLast30Days).toHaveBeenCalledTimes(1);
expect(mockedDb.getDailyStatsForLast30Days).toHaveBeenCalledTimes(1);
});
it('should return a 500 error if the database call fails', async () => {
vi.mocked(adminDb.getDailyStatsForLast30Days).mockRejectedValue(new Error('Failed to fetch daily stats'));
mockedDb.getDailyStatsForLast30Days.mockRejectedValue(new Error('Failed to fetch daily stats'));
const response = await supertest(app).get('/api/admin/stats/daily');
expect(response.status).toBe(500);
});
@@ -206,11 +221,11 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /unmatched-items', () => {
it('should return a list of unmatched items on success', async () => {
// Arrange
const mockUnmatchedItems: Awaited<ReturnType<typeof mockedDb.getUnmatchedFlyerItems>> = [
const mockUnmatchedItems: UnmatchedFlyerItem[] = [
{ unmatched_flyer_item_id: 1, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 101, flyer_item_name: 'Ketchup Chips', price_display: '$3.00', flyer_id: 1, store_name: 'Test Store' },
{ unmatched_flyer_item_id: 2, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 102, flyer_item_name: 'Mystery Soda', price_display: '2 for $4.00', flyer_id: 1, store_name: 'Test Store' },
];
vi.mocked(adminDb.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
mockedDb.getUnmatchedFlyerItems.mockResolvedValue(mockUnmatchedItems);
// Act
const response = await supertest(app).get('/api/admin/unmatched-items');
@@ -218,11 +233,11 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnmatchedItems);
expect(adminDb.getUnmatchedFlyerItems).toHaveBeenCalledTimes(1);
expect(mockedDb.getUnmatchedFlyerItems).toHaveBeenCalledTimes(1);
});
it('should return a 500 error if the database call fails', async () => {
vi.mocked(adminDb.getUnmatchedFlyerItems).mockRejectedValue(new Error('Failed to fetch unmatched items'));
mockedDb.getUnmatchedFlyerItems.mockRejectedValue(new Error('Failed to fetch unmatched items'));
const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(500);
});
@@ -232,7 +247,7 @@ describe('Admin Routes (/api/admin)', () => {
it('should approve a correction and return a success message', async () => {
// Arrange
const correctionId = 123;
vi.mocked(adminDb.approveCorrection).mockResolvedValue(undefined); // Mock the DB call to succeed
mockedDb.approveCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed
// Act
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
@@ -240,14 +255,14 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Correction approved successfully.' });
expect(adminDb.approveCorrection).toHaveBeenCalledTimes(1);
expect(adminDb.approveCorrection).toHaveBeenCalledWith(correctionId);
expect(mockedDb.approveCorrection).toHaveBeenCalledTimes(1);
expect(mockedDb.approveCorrection).toHaveBeenCalledWith(correctionId);
});
it('should return a 500 error if the database call fails', async () => {
// Arrange
const correctionId = 456;
vi.mocked(adminDb.approveCorrection).mockRejectedValue(new Error('Database failure'));
mockedDb.approveCorrection.mockRejectedValue(new Error('Database failure'));
// Act
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
@@ -263,7 +278,7 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid correction ID provided.');
expect(adminDb.approveCorrection).not.toHaveBeenCalled();
expect(mockedDb.approveCorrection).not.toHaveBeenCalled();
});
});
@@ -271,7 +286,7 @@ describe('Admin Routes (/api/admin)', () => {
it('should reject a correction and return a success message', async () => {
// Arrange
const correctionId = 789;
vi.mocked(adminDb.rejectCorrection).mockResolvedValue(undefined); // Mock the DB call to succeed
mockedDb.rejectCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed
// Act
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
@@ -279,14 +294,14 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
expect(adminDb.rejectCorrection).toHaveBeenCalledTimes(1);
expect(adminDb.rejectCorrection).toHaveBeenCalledWith(correctionId);
expect(mockedDb.rejectCorrection).toHaveBeenCalledTimes(1);
expect(mockedDb.rejectCorrection).toHaveBeenCalledWith(correctionId);
});
it('should return a 500 error if the database call fails', async () => {
// Arrange
const correctionId = 987;
vi.mocked(adminDb.rejectCorrection).mockRejectedValue(new Error('Database failure'));
mockedDb.rejectCorrection.mockRejectedValue(new Error('Database failure'));
// Act
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
@@ -301,8 +316,8 @@ describe('Admin Routes (/api/admin)', () => {
// Arrange
const correctionId = 101;
const requestBody = { suggested_value: 'A new corrected value' };
const mockUpdatedCorrection: Awaited<ReturnType<typeof mockedDb.updateSuggestedCorrection>> = { suggested_correction_id: correctionId, flyer_item_id: 1, user_id: '1', correction_type: 'price', status: 'pending', created_at: new Date().toISOString(), ...requestBody };
vi.mocked(adminDb.updateSuggestedCorrection).mockResolvedValue(mockUpdatedCorrection);
const mockUpdatedCorrection = createMockSuggestedCorrection({ suggested_correction_id: correctionId, ...requestBody });
mockedDb.updateSuggestedCorrection.mockResolvedValue(mockUpdatedCorrection);
// Act: Use .send() to include a request body
const response = await supertest(app)
@@ -312,8 +327,8 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedCorrection);
expect(adminDb.updateSuggestedCorrection).toHaveBeenCalledTimes(1);
expect(adminDb.updateSuggestedCorrection).toHaveBeenCalledWith(correctionId, requestBody.suggested_value);
expect(mockedDb.updateSuggestedCorrection).toHaveBeenCalledTimes(1);
expect(mockedDb.updateSuggestedCorrection).toHaveBeenCalledWith(correctionId, requestBody.suggested_value);
});
it('should return a 400 error if suggested_value is missing from the body', async () => {
@@ -329,7 +344,7 @@ describe('Admin Routes (/api/admin)', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('A new suggested_value is required.');
// Ensure the database was not called
expect(adminDb.updateSuggestedCorrection).not.toHaveBeenCalled();
expect(mockedDb.updateSuggestedCorrection).not.toHaveBeenCalled();
});
it('should return a 404 error if the correction to update is not found', async () => {
@@ -337,7 +352,7 @@ describe('Admin Routes (/api/admin)', () => {
const correctionId = 999; // A non-existent ID
const requestBody = { suggested_value: 'This will fail' };
// Mock the DB function to throw a "not found" error, simulating the real DB behavior.
vi.mocked(adminDb.updateSuggestedCorrection).mockRejectedValue(new Error(`Correction with ID ${correctionId} not found.`));
mockedDb.updateSuggestedCorrection.mockRejectedValue(new Error(`Correction with ID ${correctionId} not found.`));
// Act
const response = await supertest(app)
@@ -354,7 +369,7 @@ describe('Admin Routes (/api/admin)', () => {
it('should upload a logo and update the brand', async () => {
// Arrange
const brandId = 55;
vi.mocked(adminDb.updateBrandLogo).mockResolvedValue(undefined); // Mock the DB call
mockedDb.updateBrandLogo.mockResolvedValue(undefined); // Mock the DB call
// Create a dummy file for supertest to attach.
// supertest needs a real file path to stream from.
@@ -373,8 +388,8 @@ describe('Admin Routes (/api/admin)', () => {
expect(response.body.logoUrl).toMatch(/^\/assets\/logoImage-/); // Check for the generated URL format
// Verify the database was updated with the correct brand ID and a generated URL
expect(adminDb.updateBrandLogo).toHaveBeenCalledTimes(1);
expect(adminDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'));
expect(mockedDb.updateBrandLogo).toHaveBeenCalledTimes(1);
expect(mockedDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'));
// Clean up the dummy file
await fs.unlink(dummyFilePath);
@@ -410,8 +425,8 @@ describe('Admin Routes (/api/admin)', () => {
// Arrange
const recipeId = 201;
const requestBody = { status: 'public' as const };
const mockUpdatedRecipe: Awaited<ReturnType<typeof mockedDb.updateRecipeStatus>> = { recipe_id: recipeId, status: 'public', name: 'Test Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, created_at: new Date().toISOString() };
vi.mocked(adminDb.updateRecipeStatus).mockResolvedValue(mockUpdatedRecipe);
const mockUpdatedRecipe = createMockRecipe({ recipe_id: recipeId, status: 'public' });
mockedDb.updateRecipeStatus.mockResolvedValue(mockUpdatedRecipe);
// Act
const response = await supertest(app)
@@ -421,8 +436,8 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedRecipe);
expect(adminDb.updateRecipeStatus).toHaveBeenCalledTimes(1);
expect(adminDb.updateRecipeStatus).toHaveBeenCalledWith(recipeId, 'public');
expect(mockedDb.updateRecipeStatus).toHaveBeenCalledTimes(1);
expect(mockedDb.updateRecipeStatus).toHaveBeenCalledWith(recipeId, 'public');
});
it('should return a 400 error for an invalid status value', async () => {
@@ -438,7 +453,7 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(400);
expect(response.body.message).toBe('A valid status (private, pending_review, public, rejected) is required.');
expect(adminDb.updateRecipeStatus).not.toHaveBeenCalled();
expect(mockedDb.updateRecipeStatus).not.toHaveBeenCalled();
});
});
@@ -447,8 +462,8 @@ describe('Admin Routes (/api/admin)', () => {
// Arrange
const commentId = 301;
const requestBody = { status: 'hidden' as const };
const mockUpdatedComment: Awaited<ReturnType<typeof mockedDb.updateRecipeCommentStatus>> = { recipe_comment_id: commentId, recipe_id: 1, user_id: '1', status: 'hidden', content: 'Test Comment', created_at: new Date().toISOString() };
vi.mocked(adminDb.updateRecipeCommentStatus).mockResolvedValue(mockUpdatedComment);
const mockUpdatedComment = createMockRecipeComment({ recipe_comment_id: commentId, status: 'hidden' });
mockedDb.updateRecipeCommentStatus.mockResolvedValue(mockUpdatedComment);
// Act
const response = await supertest(app)
@@ -458,26 +473,26 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedComment);
expect(adminDb.updateRecipeCommentStatus).toHaveBeenCalledTimes(1);
expect(adminDb.updateRecipeCommentStatus).toHaveBeenCalledWith(commentId, 'hidden');
expect(mockedDb.updateRecipeCommentStatus).toHaveBeenCalledTimes(1);
expect(mockedDb.updateRecipeCommentStatus).toHaveBeenCalledWith(commentId, 'hidden');
});
it('should return a 400 error for an invalid status value', async () => {
const response = await supertest(app).put('/api/admin/comments/302/status').send({ status: 'invalid' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('A valid status (visible, hidden, reported) is required.');
expect(adminDb.updateRecipeCommentStatus).not.toHaveBeenCalled();
expect(mockedDb.updateRecipeCommentStatus).not.toHaveBeenCalled();
});
});
describe('GET /users', () => {
it('should return a list of all users on success', async () => {
// Arrange
const mockUsers: Awaited<ReturnType<typeof mockedDb.getAllUsers>> = [
{ user_id: '1', email: 'user1@test.com', role: 'user', created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
const mockUsers: (User & { role: 'user' | 'admin', created_at: string, full_name: string | null, avatar_url: string | null })[] = [
{ user_id: '1', email: 'user1@test.com', role: 'user' as const, created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
];
vi.mocked(adminDb.getAllUsers).mockResolvedValue(mockUsers);
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
// Act
const response = await supertest(app).get('/api/admin/users');
@@ -485,11 +500,11 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUsers);
expect(adminDb.getAllUsers).toHaveBeenCalledTimes(1);
expect(mockedDb.getAllUsers).toHaveBeenCalledTimes(1);
});
it('should return a 500 error if the database call fails', async () => {
vi.mocked(adminDb.getAllUsers).mockRejectedValue(new Error('Failed to fetch users'));
mockedDb.getAllUsers.mockRejectedValue(new Error('Failed to fetch users'));
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(500);
});
@@ -498,8 +513,8 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /activity-log', () => {
it('should return a list of activity logs with default pagination', async () => {
// Arrange
const mockLogs: Awaited<ReturnType<typeof mockedDb.getActivityLog>> = [{ activity_log_id: 1, action: 'user_registered', display_text: 'test', created_at: new Date().toISOString(), user_id: '1', details: { full_name: 'test', user_avatar_url: 'test', user_full_name: 'test' } }];
vi.mocked(adminDb.getActivityLog).mockResolvedValue(mockLogs);
const mockLogs = [createMockActivityLogItem({ action: 'flyer_processed' })];
mockedDb.getActivityLog.mockResolvedValue(mockLogs);
// Act
const response = await supertest(app).get('/api/admin/activity-log');
@@ -507,34 +522,34 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLogs);
expect(adminDb.getActivityLog).toHaveBeenCalledTimes(1);
expect(mockedDb.getActivityLog).toHaveBeenCalledTimes(1);
// Check that default pagination values were used
// This makes the test more robust by verifying the correct parameters were passed.
expect(adminDb.getActivityLog).toHaveBeenCalledWith(50, 0);
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(50, 0);
});
it('should use limit and offset query parameters when provided', async () => {
vi.mocked(adminDb.getActivityLog).mockResolvedValue([]);
mockedDb.getActivityLog.mockResolvedValue([]);
await supertest(app).get('/api/admin/activity-log?limit=10&offset=20');
expect(adminDb.getActivityLog).toHaveBeenCalledTimes(1);
expect(adminDb.getActivityLog).toHaveBeenCalledWith(10, 20);
expect(mockedDb.getActivityLog).toHaveBeenCalledTimes(1);
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(10, 20);
});
it('should handle invalid pagination parameters gracefully', async () => {
vi.mocked(adminDb.getActivityLog).mockResolvedValue([]);
mockedDb.getActivityLog.mockResolvedValue([]);
// Act: Send non-numeric query parameters
await supertest(app).get('/api/admin/activity-log?limit=abc&offset=xyz');
// Assert: The route should fall back to the default values
expect(adminDb.getActivityLog).toHaveBeenCalledWith(50, 0);
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(50, 0);
});
it('should return a 500 error if the database call fails', async () => {
// Arrange
vi.mocked(adminDb.getActivityLog).mockRejectedValue(new Error('DB connection error'));
mockedDb.getActivityLog.mockRejectedValue(new Error('DB connection error'));
// Act
const response = await supertest(app).get('/api/admin/activity-log');
@@ -547,8 +562,8 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /users/:id', () => {
it('should fetch a single user successfully', async () => {
// Arrange
const mockUser: Awaited<ReturnType<typeof mockedDb.findUserProfileById>> = { user_id: 'user-123', role: 'user', points: 0 };
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(mockUser);
const mockUser = createMockUserProfile({ user_id: 'user-123' });
mockedDb.findUserProfileById.mockResolvedValue(mockUser);
// Act
const response = await supertest(app).get('/api/admin/users/user-123');
@@ -561,7 +576,7 @@ describe('Admin Routes (/api/admin)', () => {
it('should return 404 for a non-existent user', async () => {
// Arrange
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(undefined);
mockedDb.findUserProfileById.mockResolvedValue(undefined);
// Act
const response = await supertest(app).get('/api/admin/users/non-existent-id');
@@ -575,8 +590,8 @@ describe('Admin Routes (/api/admin)', () => {
describe('PUT /users/:id', () => {
it('should update a user role successfully', async () => {
// Arrange
const updatedUser: Awaited<ReturnType<typeof mockedDb.updateUserRole>> = { user_id: 'user-to-update', email: 'test@test.com' };
vi.mocked(mockedDb.updateUserRole).mockResolvedValue(updatedUser);
const updatedUser: User = { user_id: 'user-to-update', email: 'test@test.com' };
mockedDb.updateUserRole.mockResolvedValue(updatedUser);
// Act
const response = await supertest(app)
@@ -586,11 +601,11 @@ describe('Admin Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedUser); // The actual route returns the updated user, not just a message
expect(adminDb.updateUserRole).toHaveBeenCalledWith('user-to-update', 'admin');
expect(mockedDb.updateUserRole).toHaveBeenCalledWith('user-to-update', 'admin');
});
it('should return 404 for a non-existent user', async () => {
vi.mocked(adminDb.updateUserRole).mockRejectedValue(new Error('User with ID non-existent not found.'));
mockedDb.updateUserRole.mockRejectedValue(new Error('User with ID non-existent not found.'));
const response = await supertest(app).put('/api/admin/users/non-existent').send({ role: 'user' });
expect(response.status).toBe(404);
});
@@ -604,10 +619,10 @@ describe('Admin Routes (/api/admin)', () => {
describe('DELETE /users/:id', () => {
it('should successfully delete a user', async () => {
vi.mocked(userDb.deleteUserById).mockResolvedValue(undefined);
mockedDb.deleteUserById.mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/admin/users/user-to-delete');
expect(response.status).toBe(204);
expect(userDb.deleteUserById).toHaveBeenCalledWith('user-to-delete');
expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-to-delete');
});
it('should prevent an admin from deleting their own account', async () => {
@@ -615,7 +630,109 @@ describe('Admin Routes (/api/admin)', () => {
const response = await supertest(app).delete('/api/admin/users/admin-user-id');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Admins cannot delete their own account.');
expect(userDb.deleteUserById).not.toHaveBeenCalled();
expect(mockedDb.deleteUserById).not.toHaveBeenCalled();
});
});
describe('POST /trigger/daily-deal-check', () => {
it('should trigger the daily deal check job and return 202 Accepted', async () => {
// Arrange
// The runDailyDealCheck function is mocked at the top level of the file.
// We can simply check if it was called.
vi.mocked(runDailyDealCheck).mockImplementation(async () => {}); // It returns Promise<void>
// Act
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
// Assert
expect(response.status).toBe(202);
expect(response.body.message).toBe('Daily deal check job has been triggered successfully. It will run in the background.');
expect(runDailyDealCheck).toHaveBeenCalledTimes(1);
});
});
describe('POST /trigger/failing-job', () => {
it('should enqueue a job designed to fail and return 202 Accepted', async () => {
// Arrange
const mockJob = { id: 'failing-job-id-456' } as Job;
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
// Act
const response = await supertest(app).post('/api/admin/trigger/failing-job');
// Assert
expect(response.status).toBe(202);
expect(response.body.message).toContain('Failing test job has been enqueued successfully.');
expect(response.body.jobId).toBe(mockJob.id);
expect(analyticsQueue.add).toHaveBeenCalledTimes(1);
// Verify it was called with the specific payload that the worker recognizes as a failure trigger.
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', { reportDate: 'FAIL' });
});
it('should return 500 if the queue service fails to add the job', async () => {
// Arrange
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Redis connection lost'));
// Act
const response = await supertest(app).post('/api/admin/trigger/failing-job');
// Assert
expect(response.status).toBe(500);
});
});
describe('POST /flyers/:flyerId/cleanup', () => {
it('should enqueue a cleanup job for a valid flyer ID', async () => {
// Arrange
const flyerId = 789;
const mockJob = { id: `cleanup-job-${flyerId}` } as Job;
vi.mocked(cleanupQueue.add).mockResolvedValue(mockJob as Job);
// Act
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
// Assert
expect(response.status).toBe(202);
expect(response.body.message).toBe(`File cleanup job for flyer ID ${flyerId} has been enqueued.`);
expect(cleanupQueue.add).toHaveBeenCalledTimes(1);
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
});
it('should return 400 for an invalid flyer ID', async () => {
// Act
const response = await supertest(app).post('/api/admin/flyers/invalid-id/cleanup');
// Assert
expect(response.status).toBe(400);
expect(response.body.message).toBe('A valid flyer ID is required.');
expect(cleanupQueue.add).not.toHaveBeenCalled();
});
});
describe('POST /system/clear-geocode-cache', () => {
it('should clear the geocode cache and return a success message', async () => {
// Arrange
const deletedKeysCount = 42;
vi.mocked(clearGeocodeCache).mockResolvedValue(deletedKeysCount);
// Act
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
// Assert
expect(response.status).toBe(200);
expect(response.body.message).toBe(`Successfully cleared the geocode cache. ${deletedKeysCount} keys were removed.`);
expect(clearGeocodeCache).toHaveBeenCalledTimes(1);
});
it('should return 500 if clearing the cache fails', async () => {
// Arrange
vi.mocked(clearGeocodeCache).mockRejectedValue(new Error('Redis connection failed'));
// Act
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
// Assert
expect(response.status).toBe(500);
});
});
});

View File

@@ -4,7 +4,7 @@ import supertest from 'supertest';
import express, { type Request, type Response, type NextFunction } from 'express';
import path from 'node:path';
import aiRouter from './ai.routes';
import { UserProfile } from '../types';
import { createMockUserProfile, createMockFlyer } from '../tests/utils/mockFactories';
import * as flyerDb from '../services/db/flyer.db';
import * as adminDb from '../services/db/admin.db';
@@ -67,15 +67,12 @@ describe('AI Routes (/api/ai)', () => {
it('should save a flyer and return 201 on success', async () => {
// Arrange
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined); // No duplicate
vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue({
const mockFlyer = createMockFlyer({
flyer_id: 1,
created_at: new Date().toISOString(),
file_name: mockDataPayload.originalFileName,
image_url: '/assets/some-image.jpg',
...(mockDataPayload.extractedData as any),
item_count: 0, // Add missing property to satisfy the Flyer type
});
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined); // No duplicate
vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue(mockFlyer);
vi.mocked(adminDb.logActivity).mockResolvedValue();
// Act
@@ -92,7 +89,8 @@ describe('AI Routes (/api/ai)', () => {
it('should return 409 Conflict if flyer checksum already exists', async () => {
// Arrange
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue({ flyer_id: 99 } as Awaited<ReturnType<typeof flyerDb.findFlyerByChecksum>>); // Duplicate found
const mockExistingFlyer = createMockFlyer({ flyer_id: 99 });
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(mockExistingFlyer); // Duplicate found
// Act
const response = await supertest(app)
@@ -106,28 +104,6 @@ describe('AI Routes (/api/ai)', () => {
expect(flyerDb.createFlyerAndItems).not.toHaveBeenCalled();
});
it('should return 400 if no image file is provided', async () => {
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(mockDataPayload)); // No .attach()
expect(response.status).toBe(400);
expect(response.body.message).toBe('Flyer image file is required.');
});
it('should return 400 if extractedData is missing from payload', async () => {
const badPayload = { checksum: 'c2', originalFileName: 'noflyer.jpg' }; // no extractedData
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(badPayload))
.attach('flyerImage', imagePath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid request: extractedData is required.');
expect(flyerDb.createFlyerAndItems).not.toHaveBeenCalled();
});
it('should accept payload when extractedData.items is missing and save with empty items', async () => {
// Arrange: extractedData present but items missing
const partialPayload = {
@@ -137,13 +113,11 @@ describe('AI Routes (/api/ai)', () => {
};
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue({
const mockFlyer = createMockFlyer({
flyer_id: 2,
created_at: new Date().toISOString(),
file_name: partialPayload.originalFileName,
image_url: '/flyer-images/flyer2.jpg',
item_count: 0,
});
vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue(mockFlyer);
const response = await supertest(app)
.post('/api/ai/flyers/process')
@@ -168,13 +142,11 @@ describe('AI Routes (/api/ai)', () => {
};
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue({
const mockFlyer = createMockFlyer({
flyer_id: 3,
created_at: new Date().toISOString(),
file_name: payloadNoStore.originalFileName,
image_url: '/flyer-images/flyer3.jpg',
item_count: 0,
});
vi.mocked(flyerDb.createFlyerAndItems).mockResolvedValue(mockFlyer);
const response = await supertest(app)
.post('/api/ai/flyers/process')
@@ -190,13 +162,7 @@ describe('AI Routes (/api/ai)', () => {
});
describe('when user is authenticated', () => {
const mockUserProfile: UserProfile = {
user_id: 'user-123',
user: { user_id: 'user-123', email: 'test@test.com' },
role: 'user',
// Add missing properties to align with the UserProfile type
points: 0,
} as UserProfile;
const mockUserProfile = createMockUserProfile({ user_id: 'user-123' });
beforeEach(() => {
// For this block, simulate a logged-in user by having the middleware call next().

View File

@@ -6,7 +6,7 @@ import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import rateLimit from 'express-rate-limit';
import passport from './passport.routes';
import passport from './passport.routes'; // Corrected import path
import * as db from '../services/db/index.db';
import { getPool } from '../services/db/connection.db';
import { logger } from '../services/logger.server';

View File

@@ -4,7 +4,8 @@ import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import budgetRouter from './budget.routes';
import * as budgetDb from '../services/db/budget.db';
import { UserProfile, Budget, SpendingByCategory } from '../types';
import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories';
import { Budget, SpendingByCategory } from '../types';
// 1. Mock the Service Layer directly.
// This decouples the route tests from the database logic.
@@ -41,15 +42,7 @@ app.use((err: Error, req: Request, res: Response) => {
});
describe('Budget Routes (/api/budgets)', () => {
const mockUserProfile: UserProfile = {
user_id: 'user-123',
user: { user_id: 'user-123', email: 'test@test.com' },
role: 'user',
points: 100,
full_name: 'Test User',
avatar_url: null,
preferences: {}
};
const mockUserProfile = createMockUserProfile({ user_id: 'user-123', points: 100 });
beforeEach(() => {
vi.clearAllMocks();
@@ -97,7 +90,7 @@ describe('Budget Routes (/api/budgets)', () => {
describe('GET /', () => {
it('should return a list of budgets for the user', async () => {
const mockBudgets: Budget[] = [{ budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01' }];
const mockBudgets = [createMockBudget({ budget_id: 1, user_id: 'user-123' })];
// Mock the service function directly
vi.mocked(budgetDb.getBudgetsForUser).mockResolvedValue(mockBudgets);
@@ -112,7 +105,7 @@ describe('Budget Routes (/api/budgets)', () => {
describe('POST /', () => {
it('should create a new budget and return it', async () => {
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' };
const mockCreatedBudget: Budget = { budget_id: 2, user_id: 'user-123', ...newBudgetData };
const mockCreatedBudget = createMockBudget({ budget_id: 2, user_id: 'user-123', ...newBudgetData });
// Mock the service function
vi.mocked(budgetDb.createBudget).mockResolvedValue(mockCreatedBudget);
@@ -126,7 +119,7 @@ describe('Budget Routes (/api/budgets)', () => {
describe('PUT /:id', () => {
it('should update an existing budget', async () => {
const budgetUpdates = { amount_cents: 60000 };
const mockUpdatedBudget: Budget = { budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 60000, period: 'monthly', start_date: '2024-01-01' };
const mockUpdatedBudget = createMockBudget({ budget_id: 1, user_id: 'user-123', ...budgetUpdates });
// Mock the service function
vi.mocked(budgetDb.updateBudget).mockResolvedValue(mockUpdatedBudget);
@@ -151,7 +144,7 @@ describe('Budget Routes (/api/budgets)', () => {
describe('GET /spending-analysis', () => {
it('should return spending analysis data for a valid date range', async () => {
const mockSpendingData: SpendingByCategory[] = [{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 }];
const mockSpendingData = [createMockSpendingByCategory({ category_id: 1, category_name: 'Produce' })];
// Mock the service function
vi.mocked(budgetDb.getSpendingByCategory).mockResolvedValue(mockSpendingData);

View File

@@ -4,7 +4,8 @@ import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import gamificationRouter from './gamification.routes';
import * as gamificationDb from '../services/db/gamification.db';
import { UserProfile, Achievement, UserAchievement } from '../types';
import { createMockUserProfile, createMockAchievement, createMockUserAchievement } from '../tests/utils/mockFactories';
import { Achievement, UserAchievement } from '../types';
// Mock the entire db service
vi.mock('../services/db/gamification.db');
@@ -24,7 +25,7 @@ vi.mock('../services/logger.server', () => ({
const mockedAuthMiddleware = vi.hoisted(() => vi.fn((req: Request, res: Response, next: NextFunction) => next()));
const mockedIsAdmin = vi.hoisted(() => vi.fn());
vi.mock('./passport', () => ({
vi.mock('./passport.routes', () => ({
default: {
// The authenticate method will now call our hoisted mock middleware.
authenticate: vi.fn(() => mockedAuthMiddleware),
@@ -39,18 +40,8 @@ app.use(express.json({ strict: false }));
app.use('/api/achievements', gamificationRouter);
describe('Gamification Routes (/api/achievements)', () => {
const mockUserProfile: UserProfile = {
user_id: 'user-123',
user: { user_id: 'user-123', email: 'test@test.com' },
role: 'user',
points: 100,
};
const mockAdminProfile: UserProfile = {
user_id: 'admin-456',
user: { user_id: 'admin-456', email: 'admin@test.com' },
role: 'admin',
points: 999,
};
const mockUserProfile = createMockUserProfile({ user_id: 'user-123', points: 100 });
const mockAdminProfile = createMockUserProfile({ user_id: 'admin-456', role: 'admin', points: 999 });
beforeEach(() => {
vi.clearAllMocks();
@@ -65,10 +56,7 @@ describe('Gamification Routes (/api/achievements)', () => {
describe('GET /', () => {
it('should return a list of all achievements (public endpoint)', async () => {
const mockAchievements: Achievement[] = [
{ achievement_id: 1, name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 },
{ achievement_id: 2, name: 'Budget Master', description: '...', icon: 'piggy-bank', points_value: 50 },
];
const mockAchievements = [createMockAchievement({ achievement_id: 1 }), createMockAchievement({ achievement_id: 2 })];
mockedDb.getAllAchievements.mockResolvedValue(mockAchievements);
const response = await supertest(app).get('/api/achievements');
@@ -92,7 +80,7 @@ describe('Gamification Routes (/api/achievements)', () => {
next();
});
const mockUserAchievements: (UserAchievement & Achievement)[] = [{ achievement_id: 1, user_id: 'user-123', achieved_at: '2024-01-01', name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }];
const mockUserAchievements = [createMockUserAchievement({ achievement_id: 1, user_id: 'user-123' })];
mockedDb.getUserAchievements.mockResolvedValue(mockUserAchievements);
const response = await supertest(app).get('/api/achievements/me');

View File

@@ -0,0 +1,73 @@
// src/routes/health.routes.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import healthRouter from './health.routes';
import { connection as redisConnection } from '../services/queueService.server';
// 1. Mock the dependencies of the health router.
// In this case, it's the redisConnection from the queueService.
vi.mock('../services/queueService.server', () => ({
// We need to mock the `connection` export which is an object with a `ping` method.
connection: {
ping: vi.fn(),
},
}));
// Mock the logger to keep test output clean.
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
// Cast the mocked import to a Mocked type for type-safe access to mock functions.
const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection>;
// 2. Create a minimal Express app to host the router for testing.
const app = express();
app.use('/api/health', healthRouter);
describe('Health Routes (/api/health)', () => {
beforeEach(() => {
// Clear mock history before each test to ensure isolation.
vi.clearAllMocks();
});
describe('GET /redis', () => {
it('should return 200 OK if Redis ping is successful', async () => {
// Arrange: Simulate a successful ping by having the mock resolve to 'PONG'.
mockedRedisConnection.ping.mockResolvedValue('PONG');
// Act: Make a request to the endpoint.
const response = await supertest(app).get('/api/health/redis');
// Assert: Check for the correct status and response body.
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
message: 'Redis connection is healthy.',
});
});
it('should return 500 if Redis ping fails', async () => {
// Arrange: Simulate a failure by having the mock reject with an error.
const redisError = new Error('Connection timed out');
mockedRedisConnection.ping.mockRejectedValue(redisError);
// Act
const response = await supertest(app).get('/api/health/redis');
// Assert
expect(response.status).toBe(500);
expect(response.body).toEqual({
success: false,
message: 'Failed to connect to Redis.',
error: 'Connection timed out',
});
});
});
});

View File

@@ -1,4 +1,4 @@
// src/routes/passport.ts
// src/routes/passport.routes.ts
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
//import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
@@ -11,6 +11,7 @@ import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { omit } from '../utils/objectUtils';
import { createMockUserProfile } from '../tests/utils/mockFactories';
const JWT_SECRET = process.env.JWT_SECRET!;
@@ -201,7 +202,7 @@ const jwtOptions = {
};
passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
logger.debug('[JWT Strategy] Verifying token payload:', { jwt_payload });
logger.debug('[JWT Strategy] Verifying token payload:', { jwt_payload: jwt_payload ? { user_id: jwt_payload.user_id } : 'null' });
try {
// The jwt_payload contains the data you put into the token during login (e.g., { user_id: user.user_id, email: user.email }).
// We re-fetch the user from the database here to ensure they are still active and valid.
@@ -243,15 +244,40 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
*/
export const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
// The custom callback for passport.authenticate gives us access to `err`, `user`, and `info`.
passport.authenticate('jwt', { session: false }, (err: Error, user: Express.User | false, info: Error | { message: string }) => {
passport.authenticate('jwt', { session: false }, (err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
// If there's an authentication error (e.g., malformed token), log it but don't block the request.
if (info) {
logger.info('Optional auth info:', { info: info.message || info.toString() });
}
if (user) req.user = user; // Attach user if authentication succeeds
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds
next(); // Always proceed to the next middleware
})(req, res, next);
};
/**
* Mock Authentication Middleware for Testing
*
* This middleware is ONLY active when `process.env.NODE_ENV` is 'test'.
* It bypasses the entire JWT authentication flow and directly injects a
* mock user object into `req.user`. This is essential for integration tests,
* allowing protected routes to be tested without needing to generate valid JWTs
* or mock the passport strategy.
*
* In any environment other than 'test', it does nothing and immediately passes
* control to the next middleware.
*/
export const mockAuth = (req: Request, res: Response, next: NextFunction) => {
if (process.env.NODE_ENV === 'test') {
// In a test environment, attach a mock user to the request.
// We use the mock factory to create a consistent, type-safe user profile.
// We override the default role to 'admin' for broad access in tests.
req.user = createMockUserProfile({
role: 'admin',
});
}
// In production or development, this middleware does nothing.
next();
};
export default passport;

View File

@@ -7,7 +7,7 @@ import * as connectionDb from '../services/db/connection.db';
import * as flyerDb from '../services/db/flyer.db';
import * as recipeDb from '../services/db/recipe.db';
import * as adminDb from '../services/db/admin.db';
import * as fs from 'fs/promises';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockRecipe } from '../tests/utils/mockFactories';
import { Flyer, Recipe } from '../types';
// 1. Mock the Service Layer directly.
@@ -15,6 +15,17 @@ import { Flyer, Recipe } from '../types';
vi.mock('../services/db/connection.db');
vi.mock('../services/db/flyer.db');
vi.mock('../services/db/recipe.db');
vi.mock('../services/db/admin.db');
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
// 2. Mock fs/promises.
// We provide both named exports and a default export to support different import styles.
@@ -30,16 +41,7 @@ vi.mock('fs/promises', () => {
},
};
});
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
import * as fs from 'fs/promises';
// Create the Express app
const app = express();
@@ -120,10 +122,7 @@ describe('Public Routes (/api)', () => {
describe('GET /flyers', () => {
it('should return a list of flyers on success', async () => {
const mockFlyers: Flyer[] = [
{ flyer_id: 1, file_name: 'flyer_a.jpg', image_url: '/a.jpg', created_at: new Date().toISOString(), item_count: 10 },
{ flyer_id: 2, file_name: 'flyer_b.jpg', image_url: '/b.jpg', created_at: new Date().toISOString(), item_count: 20 },
];
const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })];
// Mock the service function
vi.mocked(flyerDb.getFlyers).mockResolvedValue(mockFlyers);
@@ -145,7 +144,7 @@ describe('Public Routes (/api)', () => {
describe('GET /master-items', () => {
it('should return a list of master items', async () => {
const mockItems = [{ master_grocery_item_id: 1, name: 'Milk', created_at: new Date().toISOString() }];
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(flyerDb.getAllMasterItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/master-items');
@@ -157,9 +156,7 @@ describe('Public Routes (/api)', () => {
describe('GET /flyers/:id/items', () => {
it('should return items for a specific flyer', async () => {
const mockFlyerItems = [
{ flyer_item_id: 1, flyer_id: 123, item: 'Cheese', price_display: '$5', price_in_cents: 500, created_at: new Date().toISOString(), view_count: 0, click_count: 0, updated_at: new Date().toISOString(), quantity: '500g' }
];
const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 123 })];
vi.mocked(flyerDb.getFlyerItems).mockResolvedValue(mockFlyerItems);
const response = await supertest(app).get('/api/flyers/123/items');
@@ -171,9 +168,7 @@ describe('Public Routes (/api)', () => {
describe('POST /flyer-items/batch-fetch', () => {
it('should return items for multiple flyers', async () => {
const mockFlyerItems = [
{ flyer_item_id: 1, flyer_id: 1, item: 'Bread', price_display: '$2', price_in_cents: 200, created_at: new Date().toISOString(), view_count: 0, click_count: 0, updated_at: new Date().toISOString(), quantity: '1 loaf' }
];
const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 1 })];
vi.mocked(flyerDb.getFlyerItemsForFlyers).mockResolvedValue(mockFlyerItems);
const response = await supertest(app)
@@ -194,9 +189,7 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/by-sale-percentage', () => {
it('should return recipes based on sale percentage', async () => {
const mockRecipes: Recipe[] = [
{ recipe_id: 1, name: 'Pasta', description: null, instructions: null, avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: new Date().toISOString() },
];
const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })];
vi.mocked(recipeDb.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');
@@ -244,9 +237,7 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/by-ingredient-and-tag', () => {
it('should return recipes for a given ingredient and tag', async () => {
const mockRecipes: Recipe[] = [
{ recipe_id: 2, name: 'Chicken Tacos', description: null, instructions: null, avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: new Date().toISOString() },
];
const mockRecipes = [createMockRecipe({ recipe_id: 2, name: 'Chicken Tacos' })];
vi.mocked(recipeDb.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick');

View File

@@ -19,6 +19,9 @@ vi.mock('child_process', async (importOriginal) => {
import { exec } from 'child_process';
// Mock the geocoding service
vi.mock('../services/geocodingService.server');
import { geocodeAddress } from '../services/geocodingService.server';
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
@@ -138,4 +141,45 @@ describe('System Routes (/api/system)', () => {
expect(response.status).toBe(500);
});
});
describe('POST /geocode', () => {
it('should return geocoded coordinates for a valid address', async () => {
// Arrange
const mockCoordinates = { lat: 48.4284, lng: -123.3656 };
vi.mocked(geocodeAddress).mockResolvedValue(mockCoordinates);
// Act
const response = await supertest(app)
.post('/api/system/geocode')
.send({ address: 'Victoria, BC' });
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockCoordinates);
expect(geocodeAddress).toHaveBeenCalledWith('Victoria, BC');
});
it('should return 404 if the address cannot be geocoded', async () => {
// Arrange
vi.mocked(geocodeAddress).mockResolvedValue(null);
// Act
const response = await supertest(app)
.post('/api/system/geocode')
.send({ address: 'Invalid Address 12345' });
// Assert
expect(response.status).toBe(404);
expect(response.body.message).toBe('Could not geocode the provided address.');
});
it('should return 400 if no address is provided', async () => {
// Act
const response = await supertest(app).post('/api/system/geocode').send({});
// Assert
expect(response.status).toBe(400);
expect(response.body.message).toBe('An address string is required.');
});
});
});

View File

@@ -7,7 +7,8 @@ import userRouter from './user.routes';
import * as userDb from '../services/db/user.db';
import * as personalizationDb from '../services/db/personalization.db';
import * as shoppingDb from '../services/db/shopping.db';
import { UserProfile, MasterGroceryItem, ShoppingList, ShoppingListItem, Appliance } from '../types';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem } from '../tests/utils/mockFactories';
import { UserProfile, Appliance } from '../types';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/user.db');
@@ -74,15 +75,7 @@ describe('User Routes (/api/users)', () => {
});
describe('when user is authenticated', () => {
const mockUserProfile: UserProfile = {
user_id: 'user-123',
user: { user_id: 'user-123', email: 'test@test.com' },
role: 'user',
full_name: 'Test User',
avatar_url: null,
points: 0,
preferences: {},
};
const mockUserProfile = createMockUserProfile({ user_id: 'user-123' });
beforeEach(() => {
// Simulate a logged-in user for this block of tests.
@@ -122,13 +115,7 @@ describe('User Routes (/api/users)', () => {
describe('GET /watched-items', () => {
it('should return a list of watched items', async () => {
// Arrange
const mockItems: MasterGroceryItem[] = [{
master_grocery_item_id: 1,
name: 'Milk',
created_at: new Date().toISOString(),
category_id: 1, // Add missing properties
category_name: 'Dairy & Eggs'
}];
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(personalizationDb.getWatchedItems).mockResolvedValue(mockItems);
// Act
@@ -144,13 +131,7 @@ describe('User Routes (/api/users)', () => {
it('should add an item to the watchlist and return the new item', async () => {
// Arrange
const newItem = { itemName: 'Organic Bananas', category: 'Produce' };
const mockAddedItem: MasterGroceryItem = {
master_grocery_item_id: 99,
name: 'Organic Bananas',
created_at: new Date().toISOString(),
category_id: 1, // Add missing properties
category_name: 'Produce'
};
const mockAddedItem = createMockMasterGroceryItem({ master_grocery_item_id: 99, name: 'Organic Bananas', category_name: 'Produce' });
vi.mocked(personalizationDb.addWatchedItem).mockResolvedValue(mockAddedItem);
// Act
@@ -180,7 +161,7 @@ describe('User Routes (/api/users)', () => {
describe('Shopping List Routes', () => {
it('GET /shopping-lists should return all shopping lists for the user', async () => {
const mockLists: ShoppingList[] = [{ shopping_list_id: 1, user_id: mockUserProfile.user_id, name: 'Weekly Groceries', created_at: new Date().toISOString(), items: [] }];
const mockLists = [createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user_id })];
vi.mocked(shoppingDb.getShoppingLists).mockResolvedValue(mockLists);
const response = await supertest(app).get('/api/users/shopping-lists');
@@ -190,7 +171,7 @@ describe('User Routes (/api/users)', () => {
});
it('POST /shopping-lists should create a new list', async () => {
const mockNewList: ShoppingList = { shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies', created_at: new Date().toISOString(), items: [] };
const mockNewList = createMockShoppingList({ shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies' });
vi.mocked(shoppingDb.createShoppingList).mockResolvedValue(mockNewList);
const response = await supertest(app)
@@ -212,14 +193,7 @@ describe('User Routes (/api/users)', () => {
it('POST /shopping-lists/:listId/items should add an item to a list', async () => {
const listId = 1;
const itemData = { customItemName: 'Paper Towels' };
const mockAddedItem: ShoppingListItem = {
shopping_list_item_id: 101,
shopping_list_id: listId,
quantity: 1,
is_purchased: false,
added_at: new Date().toISOString(),
...itemData
};
const mockAddedItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: listId, ...itemData });
vi.mocked(shoppingDb.addShoppingListItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app)
@@ -233,13 +207,7 @@ describe('User Routes (/api/users)', () => {
it('PUT /shopping-lists/items/:itemId should update an item', async () => {
const itemId = 101;
const updates = { is_purchased: true, quantity: 2 };
const mockUpdatedItem: ShoppingListItem = {
shopping_list_item_id: itemId,
shopping_list_id: 1,
added_at: new Date().toISOString(),
custom_item_name: 'Item', // Add missing property
...updates
};
const mockUpdatedItem = createMockShoppingListItem({ shopping_list_item_id: itemId, shopping_list_id: 1, ...updates });
vi.mocked(shoppingDb.updateShoppingListItem).mockResolvedValue(mockUpdatedItem);
const response = await supertest(app)

View File

@@ -1,5 +1,6 @@
// src/services/db/admin.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import {
getSuggestedCorrections,
approveCorrection,
@@ -16,20 +17,8 @@ import {
updateRecipeStatus,
updateReceiptStatus,
} from './admin.db';
import { getPool } from './connection.db';
import type { SuggestedCorrection } from '../../types';
// Define test-local mock functions. These will be used to control the mock's behavior.
const mockQuery = vi.fn();
// Mock the entire connection module.
vi.mock('./connection', () => ({
// The mock factory for getPool returns an object that uses our test-local mockQuery.
getPool: () => ({
query: mockQuery,
}),
}));
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
@@ -42,8 +31,7 @@ vi.mock('../logger', () => ({
describe('Admin DB Service', () => {
beforeEach(() => {
// FIX: Reset mocks
mockQuery.mockReset();
// Reset the global mock's call history before each test.
vi.clearAllMocks();
});
@@ -52,30 +40,30 @@ describe('Admin DB Service', () => {
const mockCorrections: SuggestedCorrection[] = [
{ suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '250', status: 'pending', created_at: new Date().toISOString() },
];
mockQuery.mockResolvedValue({ rows: mockCorrections });
mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections });
const result = await getSuggestedCorrections();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc"));
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc"));
expect(result).toEqual(mockCorrections);
});
});
describe('approveCorrection', () => {
it('should call the approve_correction database function', async () => {
mockQuery.mockResolvedValue({ rows: [] }); // Mock the function call
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // Mock the function call
await approveCorrection(123);
expect(getPool().query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]);
});
});
describe('rejectCorrection', () => {
it('should update the correction status to rejected', async () => {
mockQuery.mockResolvedValue({ rowCount: 1 });
mockPoolInstance.query.mockResolvedValue({ rowCount: 1 });
await rejectCorrection(123);
expect(getPool().query).toHaveBeenCalledWith(
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET status = 'rejected'"),
[123]
);
@@ -85,11 +73,11 @@ describe('Admin DB Service', () => {
describe('updateSuggestedCorrection', () => {
it('should update the suggested value and return the updated correction', async () => {
const mockCorrection: SuggestedCorrection = { suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '300', status: 'pending', created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 });
const result = await updateSuggestedCorrection(1, '300');
expect(getPool().query).toHaveBeenCalledWith(
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET suggested_value = $1"),
['300', 1]
);
@@ -100,7 +88,7 @@ describe('Admin DB Service', () => {
describe('getApplicationStats', () => {
it('should execute 5 parallel count queries and return the aggregated stats', async () => {
// Mock responses for each of the 5 parallel queries
mockQuery
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [{ count: '10' }] }) // flyerCount
.mockResolvedValueOnce({ rows: [{ count: '20' }] }) // userCount
.mockResolvedValueOnce({ rows: [{ count: '300' }] }) // flyerItemCount
@@ -109,7 +97,7 @@ describe('Admin DB Service', () => {
const stats = await getApplicationStats();
expect(getPool().query).toHaveBeenCalledTimes(5);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(5);
expect(stats).toEqual({
flyerCount: 10,
userCount: 20,
@@ -123,22 +111,22 @@ describe('Admin DB Service', () => {
describe('getDailyStatsForLast30Days', () => {
it('should execute the correct query to get daily stats', async () => {
const mockStats = [{ date: '2023-01-01', new_users: 5, new_flyers: 2 }];
mockQuery.mockResolvedValue({ rows: mockStats });
mockPoolInstance.query.mockResolvedValue({ rows: mockStats });
const result = await getDailyStatsForLast30Days();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS"));
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS"));
expect(result).toEqual(mockStats);
});
});
describe('logActivity', () => {
it('should insert a new activity log entry', async () => {
mockQuery.mockResolvedValue({ rows: [] });
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const logData = { userId: 'user-123', action: 'test_action', displayText: 'Test activity' };
await logActivity(logData);
expect(getPool().query).toHaveBeenCalledWith(
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("INSERT INTO public.activity_log"),
[logData.userId, logData.action, logData.displayText, null, null]
);
@@ -147,47 +135,47 @@ describe('Admin DB Service', () => {
describe('getMostFrequentSaleItems', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getMostFrequentSaleItems(30, 10);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]);
});
});
describe('updateRecipeCommentStatus', () => {
it('should update the comment status and return the updated comment', async () => {
const mockComment = { comment_id: 1, status: 'hidden' };
mockQuery.mockResolvedValue({ rows: [mockComment], rowCount: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockComment], rowCount: 1 });
const result = await updateRecipeCommentStatus(1, 'hidden');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]);
expect(result).toEqual(mockComment);
});
});
describe('getUnmatchedFlyerItems', () => {
it('should execute the correct query to get unmatched items', async () => {
mockQuery.mockResolvedValue({ rows: [] });
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getUnmatchedFlyerItems();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi'));
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi'));
});
});
describe('updateRecipeStatus', () => {
it('should update the recipe status and return the updated recipe', async () => {
const mockRecipe = { recipe_id: 1, status: 'public' };
mockQuery.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
const result = await updateRecipeStatus(1, 'public');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]);
expect(result).toEqual(mockRecipe);
});
});
describe('incrementFailedLoginAttempts', () => {
it('should execute an UPDATE query to increment failed attempts', async () => {
mockQuery.mockResolvedValue({ rows: [] });
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await incrementFailedLoginAttempts('user-123');
// Fix: Use regex to match query with variable whitespace
expect(getPool().query).toHaveBeenCalledWith(
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringMatching(/UPDATE\s+public\.users\s+SET\s+failed_login_attempts\s*=\s*failed_login_attempts\s*\+\s*1/),
['user-123']
);
@@ -196,18 +184,18 @@ describe('Admin DB Service', () => {
describe('updateBrandLogo', () => {
it('should execute an UPDATE query for the brand logo', async () => {
mockQuery.mockResolvedValue({ rows: [] });
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await updateBrandLogo(1, '/logo.png');
expect(getPool().query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]);
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]);
});
});
describe('updateReceiptStatus', () => {
it('should update the receipt status and return the updated receipt', async () => {
const mockReceipt = { receipt_id: 1, status: 'completed' };
mockQuery.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 });
const result = await updateReceiptStatus(1, 'completed');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]);
expect(result).toEqual(mockReceipt);
});
});

View File

@@ -1,5 +1,6 @@
// src/services/db/gamification.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import {
getAllAchievements,
getUserAchievements,
@@ -7,17 +8,6 @@ import {
getLeaderboard,
} from './gamification.db';
import type { Achievement, UserAchievement, LeaderboardUser } from '../../types';
import { getPool } from './connection.db';
// Mock the getPool function to return a mocked pool object.
const mockQuery = vi.fn();
// We mock the connection module to control the 'getPool' output directly.
vi.mock('./connection', () => ({
getPool: () => ({
query: mockQuery,
}),
}));
// Mock the logger
vi.mock('../logger', () => ({
@@ -31,8 +21,8 @@ vi.mock('../logger', () => ({
describe('Gamification DB Service', () => {
beforeEach(() => {
// Reset the global mock's call history before each test.
vi.clearAllMocks();
console.log('[gamification.db.test.ts] Mocks cleared');
});
describe('getAllAchievements', () => {
@@ -40,11 +30,11 @@ describe('Gamification DB Service', () => {
const mockAchievements: Achievement[] = [
{ achievement_id: 1, name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 },
];
mockQuery.mockResolvedValue({ rows: mockAchievements });
mockPoolInstance.query.mockResolvedValue({ rows: mockAchievements });
const result = await getAllAchievements();
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
expect(result).toEqual(mockAchievements);
});
});
@@ -54,21 +44,21 @@ describe('Gamification DB Service', () => {
const mockUserAchievements: (UserAchievement & Achievement)[] = [
{ achievement_id: 1, user_id: 'user-123', achieved_at: '2024-01-01', name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 },
];
mockQuery.mockResolvedValue({ rows: mockUserAchievements });
mockPoolInstance.query.mockResolvedValue({ rows: mockUserAchievements });
const result = await getUserAchievements('user-123');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']);
expect(result).toEqual(mockUserAchievements);
});
});
describe('awardAchievement', () => {
it('should call the award_achievement database function with the correct parameters', async () => {
mockQuery.mockResolvedValue({ rows: [] }); // The function returns void
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // The function returns void
await awardAchievement('user-123', 'Test Achievement');
expect(getPool().query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']);
expect(mockPoolInstance.query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']);
});
});
@@ -78,12 +68,12 @@ describe('Gamification DB Service', () => {
{ user_id: 'user-1', full_name: 'User One', avatar_url: null, points: 500, rank: '1' },
{ user_id: 'user-2', full_name: 'User Two', avatar_url: null, points: 450, rank: '2' },
];
mockQuery.mockResolvedValue({ rows: mockLeaderboard });
mockPoolInstance.query.mockResolvedValue({ rows: mockLeaderboard });
const result = await getLeaderboard(10);
expect(getPool().query).toHaveBeenCalledTimes(1);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]);
expect(result).toEqual(mockLeaderboard);
});
});

View File

@@ -0,0 +1,361 @@
// src/tests/utils/mockFactories.ts
import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeComment, ActivityLogItem } from '../../types';
/**
* Creates a mock UserProfile object for use in tests, ensuring type safety.
*
* @param overrides - An object containing properties to override the default mock values.
* This allows for easy customization of the mock user for specific test cases.
* For example: `createMockUserProfile({ role: 'admin', points: 500 })`
* @returns A complete and type-safe UserProfile object.
*/
export const createMockUserProfile = (overrides: Partial<UserProfile & { user: Partial<User> }> = {}): UserProfile => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const defaultProfile: UserProfile = {
user_id: userId,
role: 'user',
points: 0,
full_name: 'Test User',
avatar_url: null,
preferences: {},
address: null,
user: {
user_id: userId,
email: `${userId}@example.com`,
...overrides.user, // Apply nested user overrides
},
};
return { ...defaultProfile, ...overrides };
};
/**
* Creates a mock Flyer object for use in tests, ensuring type safety.
*
* @param overrides - An object containing properties to override the default mock values,
* including nested properties for the `store`.
* e.g., `createMockFlyer({ item_count: 50, store: { name: 'Walmart' } })`
* @returns A complete and type-safe Flyer object.
*/
export const createMockFlyer = (overrides: Partial<Flyer & { store: Partial<Store> }> = {}): Flyer => {
const flyerId = overrides.flyer_id ?? Math.floor(Math.random() * 1000);
const storeId = overrides.store?.store_id ?? Math.floor(Math.random() * 100);
const defaultFlyer: Flyer = {
flyer_id: flyerId,
created_at: new Date().toISOString(),
file_name: `flyer-${flyerId}.jpg`,
image_url: `/flyer-images/flyer-${flyerId}.jpg`,
icon_url: `/flyer-images/icons/icon-flyer-${flyerId}.webp`,
checksum: `checksum-${flyerId}`,
store_id: storeId,
valid_from: new Date().toISOString().split('T')[0],
valid_to: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 7 days from now
store_address: '123 Main St, Anytown, USA',
item_count: Math.floor(Math.random() * 100) + 10,
uploaded_by: null,
store: {
store_id: storeId,
created_at: new Date().toISOString(),
name: 'Mock Store',
logo_url: null,
},
};
// Deep merge the store object and then merge the top-level properties.
return { ...defaultFlyer, ...overrides, store: { ...defaultFlyer.store, ...overrides.store } as Store };
};
/**
* Creates a mock SuggestedCorrection object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe SuggestedCorrection object.
*/
export const createMockSuggestedCorrection = (overrides: Partial<SuggestedCorrection> = {}): SuggestedCorrection => {
const defaultCorrection: SuggestedCorrection = {
suggested_correction_id: Math.floor(Math.random() * 1000),
flyer_item_id: Math.floor(Math.random() * 10000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
correction_type: 'price',
suggested_value: '$9.99',
status: 'pending',
created_at: new Date().toISOString(),
};
return { ...defaultCorrection, ...overrides };
};
/**
* Creates a mock Brand object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Brand object.
*/
export const createMockBrand = (overrides: Partial<Brand> = {}): Brand => {
const brandId = overrides.brand_id ?? Math.floor(Math.random() * 100);
const defaultBrand: Brand = {
brand_id: brandId,
name: `Brand ${brandId}`,
logo_url: null,
store_id: null,
store_name: null,
};
return { ...defaultBrand, ...overrides };
};
/**
* Creates a mock FlyerItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe FlyerItem object.
*/
export const createMockFlyerItem = (overrides: Partial<FlyerItem> = {}): FlyerItem => {
const defaultItem: FlyerItem = {
flyer_item_id: Math.floor(Math.random() * 10000),
flyer_id: Math.floor(Math.random() * 1000),
created_at: new Date().toISOString(),
item: 'Mock Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'each',
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
};
return { ...defaultItem, ...overrides };
};
/**
* Creates a mock Recipe object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Recipe object.
*/
export const createMockRecipe = (overrides: Partial<Recipe> = {}): Recipe => {
const recipeId = overrides.recipe_id ?? Math.floor(Math.random() * 1000);
const defaultRecipe: Recipe = {
recipe_id: recipeId,
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
name: `Mock Recipe ${recipeId}`,
description: 'A delicious mock recipe.',
instructions: '1. Mock the ingredients. 2. Mock the cooking. 3. Enjoy!',
avg_rating: Math.random() * 5,
rating_count: Math.floor(Math.random() * 100),
fork_count: Math.floor(Math.random() * 20),
status: 'public',
created_at: new Date().toISOString(),
prep_time_minutes: 15,
cook_time_minutes: 30,
servings: 4,
};
return { ...defaultRecipe, ...overrides };
};
/**
* Creates a mock RecipeComment object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe RecipeComment object.
*/
export const createMockRecipeComment = (overrides: Partial<RecipeComment> = {}): RecipeComment => {
const defaultComment: RecipeComment = {
recipe_comment_id: Math.floor(Math.random() * 10000),
recipe_id: Math.floor(Math.random() * 1000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
content: 'This is a mock comment.',
status: 'visible',
created_at: new Date().toISOString(),
user_full_name: 'Mock User', // This was correct
user_avatar_url: undefined,
};
return { ...defaultComment, ...overrides };
};
/**
* Creates a mock ActivityLogItem object for use in tests.
* This factory handles the discriminated union nature of the ActivityLogItem type.
* By default, it creates a 'flyer_processed' log item. You can override the 'action'
* and 'details' to create other types of log items.
*
* @param overrides - An object containing properties to override the default mock values.
* e.g., `createMockActivityLogItem({ action: 'user_registered', details: { full_name: 'New User' } })`
* @returns A complete and type-safe ActivityLogItem object.
*/
export const createMockActivityLogItem = (overrides: Partial<ActivityLogItem> = {}): ActivityLogItem => {
const action = overrides.action ?? 'flyer_processed';
const baseLog = {
activity_log_id: Math.floor(Math.random() * 10000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
created_at: new Date().toISOString(),
};
let specificLog: ActivityLogItem;
// Create a default log based on the action, which can then be overridden.
switch (action) {
case 'recipe_created':
specificLog = {
...baseLog,
action: 'recipe_created',
display_text: 'Created a new recipe: Mock Recipe.',
icon: 'chef-hat',
details: { recipe_id: 1, recipe_name: 'Mock Recipe' },
};
break;
case 'user_registered':
specificLog = {
...baseLog,
action: 'user_registered',
display_text: 'A new user has registered.',
icon: 'user-plus',
details: { full_name: 'New Mock User' },
};
break;
case 'list_shared':
specificLog = {
...baseLog,
action: 'list_shared',
display_text: 'A shopping list was shared.',
icon: 'share-2',
details: { list_name: 'Mock List', shopping_list_id: 1, shared_with_name: 'Another User' },
};
break;
case 'flyer_processed':
default:
specificLog = {
...baseLog,
action: 'flyer_processed',
display_text: 'Processed a new flyer for Mock Store.',
icon: 'file-check',
details: { flyer_id: 1, store_name: 'Mock Store' },
};
break;
}
// Merge the generated log with any specific overrides provided.
// This allows for deep merging of the 'details' object.
return { ...specificLog, ...overrides, details: { ...specificLog.details, ...overrides.details } } as ActivityLogItem;
};
/**
* Creates a mock Achievement object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Achievement object.
*/
export const createMockAchievement = (overrides: Partial<Achievement> = {}): Achievement => {
const defaultAchievement: Achievement = {
achievement_id: Math.floor(Math.random() * 100),
name: 'Mock Achievement',
description: 'A great accomplishment.',
icon: 'star',
points_value: 10,
};
return { ...defaultAchievement, ...overrides };
};
/**
* Creates a mock object representing a joined UserAchievement and Achievement for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe object representing the joined achievement data.
*/
export const createMockUserAchievement = (overrides: Partial<UserAchievement & Achievement> = {}): UserAchievement & Achievement => {
const achievementId = overrides.achievement_id ?? Math.floor(Math.random() * 100);
const defaultUserAchievement: UserAchievement & Achievement = {
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
achievement_id: achievementId,
achieved_at: new Date().toISOString(),
// from Achievement
name: 'Mock User Achievement',
description: 'An achievement someone earned.',
icon: 'award',
points_value: 20,
};
return { ...defaultUserAchievement, ...overrides };
};
/**
* Creates a mock Budget object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Budget object.
*/
export const createMockBudget = (overrides: Partial<Budget> = {}): Budget => {
const defaultBudget: Budget = {
budget_id: Math.floor(Math.random() * 100),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
name: 'Monthly Groceries',
amount_cents: 50000,
period: 'monthly',
start_date: new Date().toISOString().split('T')[0],
};
return { ...defaultBudget, ...overrides };
};
/**
* Creates a mock SpendingByCategory object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe SpendingByCategory object.
*/
export const createMockSpendingByCategory = (overrides: Partial<SpendingByCategory> = {}): SpendingByCategory => {
const defaultSpending: SpendingByCategory = {
category_id: Math.floor(Math.random() * 20) + 1,
category_name: 'Produce',
total_spent_cents: Math.floor(Math.random() * 20000) + 1000,
};
return { ...defaultSpending, ...overrides };
};
/**
* Creates a mock MasterGroceryItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe MasterGroceryItem object.
*/
export const createMockMasterGroceryItem = (overrides: Partial<MasterGroceryItem> = {}): MasterGroceryItem => {
const defaultItem: MasterGroceryItem = {
master_grocery_item_id: Math.floor(Math.random() * 10000),
created_at: new Date().toISOString(),
name: 'Mock Master Item',
category_id: 1,
category_name: 'Pantry & Dry Goods',
};
return { ...defaultItem, ...overrides };
};
/**
* Creates a mock ShoppingList object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe ShoppingList object.
*/
export const createMockShoppingList = (overrides: Partial<ShoppingList> = {}): ShoppingList => {
const defaultList: ShoppingList = {
shopping_list_id: Math.floor(Math.random() * 100),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
name: 'My Mock List',
created_at: new Date().toISOString(),
items: [],
};
return { ...defaultList, ...overrides };
};
/**
* Creates a mock ShoppingListItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe ShoppingListItem object.
*/
export const createMockShoppingListItem = (overrides: Partial<ShoppingListItem> = {}): ShoppingListItem => {
const defaultItem: ShoppingListItem = {
shopping_list_item_id: Math.floor(Math.random() * 100000),
shopping_list_id: Math.floor(Math.random() * 100),
custom_item_name: 'Mock Shopping List Item',
quantity: 1,
is_purchased: false,
added_at: new Date().toISOString(),
};
return { ...defaultItem, ...overrides };
};