Compare commits

...

20 Commits

Author SHA1 Message Date
Gitea Actions
a7a30cf983 ci: Bump version to 0.9.12 [skip ci] 2026-01-04 01:01:26 +05:00
0bc0676b33 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m39s
2026-01-03 12:00:20 -08:00
Gitea Actions
73484d3eb4 ci: Bump version to 0.9.11 [skip ci] 2026-01-03 23:52:31 +05:00
b3253d5bbc more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m17s
2026-01-03 10:51:44 -08:00
Gitea Actions
54f3769e90 ci: Bump version to 0.9.10 [skip ci] 2026-01-03 13:34:20 +05:00
bad6f74ee6 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m21s
2026-01-03 00:33:47 -08:00
Gitea Actions
bcf16168b6 ci: Bump version to 0.9.9 [skip ci] 2026-01-03 13:03:37 +05:00
498fbd9e0e more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m5s
2026-01-03 00:02:09 -08:00
Gitea Actions
007ff8e538 ci: Bump version to 0.9.8 [skip ci] 2026-01-03 11:34:34 +05:00
1fc70e3915 extend timers duration - prevent jobs from timing out after 30secs, increased to 4mins
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m56s
2026-01-02 22:33:51 -08:00
Gitea Actions
d891e47e02 ci: Bump version to 0.9.7 [skip ci] 2026-01-03 10:36:05 +05:00
08c39afde4 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m33s
2026-01-02 21:33:31 -08:00
Gitea Actions
c579543b8a ci: Bump version to 0.9.6 [skip ci] 2026-01-03 09:31:41 +05:00
0d84137786 test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 23m17s
2026-01-02 20:31:08 -08:00
Gitea Actions
20ee30c4b4 ci: Bump version to 0.9.5 [skip ci] 2026-01-03 08:52:26 +05:00
93612137e3 test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m23s
2026-01-02 19:51:10 -08:00
Gitea Actions
6e70f08e3c ci: Bump version to 0.9.4 [skip ci] 2026-01-03 07:59:50 +05:00
459f5f7976 sql fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m36s
2026-01-02 18:59:16 -08:00
Gitea Actions
a2e6331ddd ci: Bump version to 0.9.3 [skip ci] 2026-01-03 07:28:11 +05:00
13cd30bec9 sql fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m51s
2026-01-02 18:27:42 -08:00
41 changed files with 1323 additions and 352 deletions

View File

@@ -52,6 +52,7 @@ module.exports = {
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
WORKER_LOCK_DURATION: '120000',
},
// Test Environment Settings
env_test: {
@@ -74,6 +75,7 @@ module.exports = {
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
WORKER_LOCK_DURATION: '120000',
},
// Development Environment Settings
env_development: {
@@ -97,6 +99,7 @@ module.exports = {
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
WORKER_LOCK_DURATION: '120000',
},
},
{

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.2",
"version": "0.9.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.2",
"version": "0.9.12",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.9.2",
"version": "0.9.12",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -1079,6 +1079,8 @@ $$;
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
-- Returns: TABLE(...) - A set of records including user details and deal information.
-- =================================================================
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_all_users();
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
RETURNS TABLE(
user_id uuid,

View File

@@ -2809,6 +2809,8 @@ CREATE TRIGGER on_recipe_fork
-- It replaces the need to call get_best_sale_prices_for_user for each user individually.
-- Returns: TABLE(...) - A set of records including user details and deal information.
-- =================================================================
DROP FUNCTION IF EXISTS public.get_best_sale_prices_for_all_users();
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_all_users()
RETURNS TABLE(
user_id uuid,

View File

@@ -110,8 +110,8 @@ async function main() {
validTo.setDate(today.getDate() + 5);
const flyerQuery = `
INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to)
VALUES ('safeway-flyer.jpg', 'https://example.com/flyer-images/safeway-flyer.jpg', 'a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0', ${storeMap.get('Safeway')}, $1, $2)
INSERT INTO public.flyers (file_name, image_url, icon_url, checksum, store_id, valid_from, valid_to)
VALUES ('safeway-flyer.jpg', 'https://example.com/flyer-images/safeway-flyer.jpg', 'https://example.com/flyer-images/icons/safeway-flyer.jpg', 'a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0', ${storeMap.get('Safeway')}, $1, $2)
RETURNING flyer_id;
`;
const flyerRes = await client.query<{ flyer_id: number }>(flyerQuery, [

View File

@@ -1,5 +1,5 @@
// src/middleware/errorHandler.test.ts
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
@@ -98,12 +98,15 @@ describe('errorHandler Middleware', () => {
vi.clearAllMocks();
consoleErrorSpy.mockClear(); // Clear spy for console.error
// Ensure NODE_ENV is set to 'test' for console.error logging
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
});
afterEach(() => {
vi.unstubAllEnvs(); // Clean up environment variable stubs after each test
});
afterAll(() => {
consoleErrorSpy.mockRestore(); // Restore console.error after all tests
delete process.env.NODE_ENV; // Clean up environment variable
});
it('should return a generic 500 error for a standard Error object', async () => {
@@ -293,11 +296,7 @@ describe('errorHandler Middleware', () => {
describe('when NODE_ENV is "production"', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
afterAll(() => {
process.env.NODE_ENV = 'test'; // Reset for other test files
vi.stubEnv('NODE_ENV', 'production');
});
it('should return a generic message with an error ID for a 500 error', async () => {

View File

@@ -109,20 +109,19 @@ describe('Multer Middleware Directory Creation', () => {
describe('createUploadMiddleware', () => {
const mockFile = { originalname: 'test.png' } as Express.Multer.File;
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@user.com' } });
let originalNodeEnv: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
originalNodeEnv = process.env.NODE_ENV;
vi.unstubAllEnvs();
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
vi.unstubAllEnvs();
});
describe('Avatar Storage', () => {
it('should generate a unique filename for an authenticated user', () => {
process.env.NODE_ENV = 'production';
vi.stubEnv('NODE_ENV', 'production');
createUploadMiddleware({ storageType: 'avatar' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
@@ -150,7 +149,7 @@ describe('createUploadMiddleware', () => {
});
it('should use a predictable filename in test environment', () => {
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
createUploadMiddleware({ storageType: 'avatar' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
@@ -164,7 +163,7 @@ describe('createUploadMiddleware', () => {
describe('Flyer Storage', () => {
it('should generate a unique, sanitized filename in production environment', () => {
process.env.NODE_ENV = 'production';
vi.stubEnv('NODE_ENV', 'production');
const mockFlyerFile = {
fieldname: 'flyerFile',
originalname: 'My Flyer (Special!).pdf',
@@ -184,7 +183,7 @@ describe('createUploadMiddleware', () => {
it('should generate a predictable filename in test environment', () => {
// This test covers lines 43-46
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
const mockFlyerFile = {
fieldname: 'flyerFile',
originalname: 'test-flyer.jpg',

View File

@@ -208,6 +208,34 @@ router.post(
},
);
/**
* POST /api/ai/upload-legacy - Process a flyer upload from a legacy client.
* This is an authenticated route that processes the flyer synchronously.
* This is used for integration testing the legacy upload flow.
*/
router.post(
'/upload-legacy',
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('flyerFile'),
async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'No flyer file uploaded.' });
}
const userProfile = req.user as UserProfile;
const newFlyer = await aiService.processLegacyFlyerUpload(req.file, req.body, userProfile, req.log);
res.status(200).json(newFlyer);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
logger.warn(`Duplicate legacy flyer upload attempt blocked.`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
}
next(error);
}
},
);
/**
* NEW ENDPOINT: Checks the status of a background job.
*/

View File

@@ -618,21 +618,19 @@ describe('Passport Configuration', () => {
describe('mockAuth Middleware', () => {
const mockNext: NextFunction = vi.fn();
let mockRes: Partial<Response>;
let originalNodeEnv: string | undefined;
const mockRes: Partial<Response> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
};
beforeEach(() => {
mockRes = { status: vi.fn().mockReturnThis(), json: vi.fn() };
originalNodeEnv = process.env.NODE_ENV;
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
// Unstub env variables before each test in this block to ensure a clean state.
vi.unstubAllEnvs();
});
it('should attach a mock admin user to req when NODE_ENV is "test"', () => {
// Arrange
process.env.NODE_ENV = 'test';
vi.stubEnv('NODE_ENV', 'test');
const mockReq = {} as Request;
// Act
@@ -646,7 +644,7 @@ describe('Passport Configuration', () => {
it('should do nothing and call next() when NODE_ENV is not "test"', () => {
// Arrange
process.env.NODE_ENV = 'production';
vi.stubEnv('NODE_ENV', 'production');
const mockReq = {} as Request;
// Act

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createTestApp } from '../tests/utils/createTestApp';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/index.db', () => ({
reactionRepo: {
getReactions: vi.fn(),
getReactionSummary: vi.fn(),
toggleReaction: vi.fn(),
},
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock Passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
() => (req: any, res: any, next: any) => {
// If we are testing the unauthenticated state (no user injected), simulate 401.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
},
),
},
}));
// Import the router and mocked DB AFTER all mocks are defined.
import reactionsRouter from './reactions.routes';
import { reactionRepo } from '../services/db/index.db';
import { mockLogger } from '../tests/utils/mockLogger';
const expectLogger = expect.objectContaining({
info: expect.any(Function),
error: expect.any(Function),
});
describe('Reaction Routes (/api/reactions)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /', () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
it('should return a list of reactions', async () => {
const mockReactions = [{ id: 1, reaction_type: 'like', entity_id: '123' }];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
const response = await supertest(app).get('/api/reactions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockReactions);
expect(reactionRepo.getReactions).toHaveBeenCalledWith({}, expectLogger);
});
it('should filter by query parameters', async () => {
const mockReactions = [{ id: 1, reaction_type: 'like' }];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
const query = { userId: validUuid, entityType: 'recipe', entityId: '1' };
const response = await supertest(app).get('/api/reactions').query(query);
expect(response.status).toBe(200);
expect(reactionRepo.getReactions).toHaveBeenCalledWith(
expect.objectContaining(query),
expectLogger
);
});
it('should return 500 on database error', async () => {
const error = new Error('DB Error');
vi.mocked(reactionRepo.getReactions).mockRejectedValue(error);
const response = await supertest(app).get('/api/reactions');
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error fetching user reactions'
);
});
});
describe('GET /summary', () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
it('should return reaction summary for an entity', async () => {
const mockSummary = { like: 10, love: 5 };
vi.mocked(reactionRepo.getReactionSummary).mockResolvedValue(mockSummary as any);
const response = await supertest(app)
.get('/api/reactions/summary')
.query({ entityType: 'recipe', entityId: '123' });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockSummary);
expect(reactionRepo.getReactionSummary).toHaveBeenCalledWith(
'recipe',
'123',
expectLogger
);
});
it('should return 400 if required parameters are missing', async () => {
const response = await supertest(app).get('/api/reactions/summary');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('required');
});
it('should return 500 on database error', async () => {
const error = new Error('DB Error');
vi.mocked(reactionRepo.getReactionSummary).mockRejectedValue(error);
const response = await supertest(app)
.get('/api/reactions/summary')
.query({ entityType: 'recipe', entityId: '123' });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error fetching reaction summary'
);
});
});
describe('POST /toggle', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
const app = createTestApp({
router: reactionsRouter,
basePath: '/api/reactions',
authenticatedUser: mockUser,
});
const validBody = {
entity_type: 'recipe',
entity_id: '123',
reaction_type: 'like',
};
it('should return 201 when a reaction is added', async () => {
const mockResult = { ...validBody, id: 1, user_id: 'user-123' };
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult as any);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(201);
expect(response.body).toEqual({ message: 'Reaction added.', reaction: mockResult });
expect(reactionRepo.toggleReaction).toHaveBeenCalledWith(
{ user_id: 'user-123', ...validBody },
expectLogger
);
});
it('should return 200 when a reaction is removed', async () => {
// Returning null/false from toggleReaction implies the reaction was removed
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Reaction removed.' });
});
it('should return 400 if body is invalid', async () => {
const response = await supertest(app)
.post('/api/reactions/toggle')
.send({ entity_type: 'recipe' }); // Missing other required fields
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
it('should return 401 if not authenticated', async () => {
const unauthApp = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
const response = await supertest(unauthApp)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(401);
});
it('should return 500 on database error', async () => {
const error = new Error('DB Error');
vi.mocked(reactionRepo.toggleReaction).mockRejectedValue(error);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error, body: validBody },
'Error toggling user reaction'
);
});
});
});

View File

@@ -1,7 +1,7 @@
// src/routes/recipe.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories';
import { createMockRecipe, createMockRecipeComment, createMockUserProfile } from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -16,9 +16,31 @@ vi.mock('../services/db/index.db', () => ({
},
}));
// Mock AI Service
vi.mock('../services/aiService.server', () => ({
aiService: {
generateRecipeSuggestion: vi.fn(),
},
}));
// Mock Passport
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
() => (req: any, res: any, next: any) => {
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
},
),
},
}));
// Import the router and mocked DB AFTER all mocks are defined.
import recipeRouter from './recipe.routes';
import * as db from '../services/db/index.db';
import { aiService } from '../services/aiService.server';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the logger to keep test output clean
@@ -229,4 +251,71 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.body.errors[0].message).toContain('received NaN');
});
});
describe('POST /suggest', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/recipes',
authenticatedUser: mockUser,
});
it('should return a recipe suggestion', async () => {
const ingredients = ['chicken', 'rice'];
const mockSuggestion = 'Chicken and Rice Casserole...';
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients });
expect(response.status).toBe(200);
expect(response.body).toEqual({ suggestion: mockSuggestion });
expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith(ingredients, expectLogger);
});
it('should return 503 if AI service returns null', async () => {
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(null);
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients: ['water'] });
expect(response.status).toBe(503);
expect(response.body.message).toContain('unavailable');
});
it('should return 400 if ingredients list is empty', async () => {
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients: [] });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('At least one ingredient is required');
});
it('should return 401 if not authenticated', async () => {
const unauthApp = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
const response = await supertest(unauthApp)
.post('/api/recipes/suggest')
.send({ ingredients: ['chicken'] });
expect(response.status).toBe(401);
});
it('should return 500 on service error', async () => {
const error = new Error('AI Error');
vi.mocked(aiService.generateRecipeSuggestion).mockRejectedValue(error);
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients: ['chicken'] });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error generating recipe suggestion'
);
});
});
});

View File

@@ -24,58 +24,8 @@ import { cleanupFiles } from '../tests/utils/cleanupFiles';
import { logger } from '../services/logger.server';
import { userService } from '../services/userService';
// 1. Mock the Service Layer directly.
// The user.routes.ts file imports from '.../db/index.db'. We need to mock that module.
vi.mock('../services/db/index.db', () => ({
// Repository instances
userRepo: {
findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(),
updateUserPreferences: vi.fn(),
},
personalizationRepo: {
getWatchedItems: vi.fn(),
removeWatchedItem: vi.fn(),
addWatchedItem: vi.fn(),
getUserDietaryRestrictions: vi.fn(),
setUserDietaryRestrictions: vi.fn(),
getUserAppliances: vi.fn(),
setUserAppliances: vi.fn(),
},
shoppingRepo: {
getShoppingLists: vi.fn(),
createShoppingList: vi.fn(),
deleteShoppingList: vi.fn(),
addShoppingListItem: vi.fn(),
updateShoppingListItem: vi.fn(),
removeShoppingListItem: vi.fn(),
getShoppingListById: vi.fn(), // Added missing mock
},
recipeRepo: {
deleteRecipe: vi.fn(),
updateRecipe: vi.fn(),
},
addressRepo: {
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
},
notificationRepo: {
getNotificationsForUser: vi.fn(),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
},
}));
// Mock userService
vi.mock('../services/userService', () => ({
userService: {
updateUserAvatar: vi.fn(),
updateUserPassword: vi.fn(),
deleteUserAccount: vi.fn(),
getUserAddress: vi.fn(),
upsertUserAddress: vi.fn(),
},
}));
// Mocks for db/index.db, userService, and logger are now centralized in `src/tests/setup/tests-setup-unit.ts`.
// This avoids repetition across test files.
// Mock the logger
vi.mock('../services/logger.server', async () => ({
@@ -122,10 +72,10 @@ describe('User Routes (/api/users)', () => {
describe('Avatar Upload Directory Creation', () => {
it('should log an error if avatar directory creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
const mkdirError = new Error('EACCES: permission denied'); // This error is specific to the fs.mkdir mock.
// Reset modules to force re-import with a new mock implementation
vi.resetModules();
// Set up the mock *before* the module is re-imported
// Set up the mock *before* the module is re-imported.
vi.doMock('node:fs/promises', () => ({
default: {
// We only need to mock mkdir for this test.
@@ -133,6 +83,10 @@ describe('User Routes (/api/users)', () => {
},
}));
const { logger } = await import('../services/logger.server');
// Stub NODE_ENV to ensure the relevant code path is executed if it depends on it.
// Although the mkdir call itself doesn't depend on NODE_ENV, this is good practice
// when re-importing modules that might have conditional logic based on it.
vi.stubEnv('NODE_ENV', 'test');
// Act: Dynamically import the router to trigger the top-level fs.mkdir call
await import('./user.routes');
@@ -142,6 +96,7 @@ describe('User Routes (/api/users)', () => {
{ error: mkdirError },
'Failed to create multer storage directories on startup.',
);
vi.unstubAllEnvs(); // Clean up the stubbed environment variable.
vi.doUnmock('node:fs/promises'); // Clean up
});
});
@@ -1075,7 +1030,7 @@ describe('User Routes (/api/users)', () => {
it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = createMockUserProfile({
...mockUserProfile,
avatar_url: '/uploads/avatars/new-avatar.png',
avatar_url: 'http://localhost:3001/uploads/avatars/new-avatar.png',
});
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile);
@@ -1087,7 +1042,7 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('/uploads/avatars/'); // This was a duplicate, fixed.
expect(response.body.avatar_url).toContain('http://localhost:3001/uploads/avatars/');
expect(userService.updateUserAvatar).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expect.any(Object),

View File

@@ -11,7 +11,11 @@ import {
DuplicateFlyerError,
type RawFlyerItem,
} from './aiService.server';
import { createMockMasterGroceryItem, createMockFlyer } from '../tests/utils/mockFactories';
import {
createMockMasterGroceryItem,
createMockFlyer,
createMockUserProfile,
} from '../tests/utils/mockFactories';
import { ValidationError } from './db/errors.db';
import { AiFlyerDataSchema } from '../types/ai';
@@ -102,6 +106,8 @@ interface MockFlyer {
updated_at: string;
}
const baseUrl = 'http://localhost:3001';
describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service
const mockAiClient = { generateContent: vi.fn() };
@@ -136,45 +142,29 @@ describe('AI Service (Server)', () => {
});
describe('Constructor', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset process.env before each test in this block
vi.unstubAllEnvs();
vi.unstubAllEnvs(); // Force-removes all environment mocking
vi.resetModules(); // Important to re-evaluate the service file
process.env = { ...originalEnv };
console.log('CONSTRUCTOR beforeEach: process.env reset.');
});
afterEach(() => {
// Restore original environment variables
vi.unstubAllEnvs();
process.env = originalEnv;
console.log('CONSTRUCTOR afterEach: process.env restored.');
});
it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
console.log("TEST START: 'should throw an error if GEMINI_API_KEY is not set...'");
console.log(
`PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
);
// Simulate a non-test environment
process.env.NODE_ENV = 'production';
delete process.env.GEMINI_API_KEY;
delete process.env.VITEST_POOL_ID;
console.log(
`POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`,
);
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('GEMINI_API_KEY', '');
vi.stubEnv('VITEST_POOL_ID', '');
let error: Error | undefined;
// Dynamically import the class to re-evaluate the constructor logic
try {
console.log('Attempting to import and instantiate AIService which is expected to throw...');
const { AIService } = await import('./aiService.server');
new AIService(mockLoggerInstance);
} catch (e) {
console.log('Successfully caught an error during instantiation.');
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
@@ -185,8 +175,8 @@ describe('AI Service (Server)', () => {
it('should use a mock placeholder if API key is missing in a test environment', async () => {
// Arrange: Simulate a test environment without an API key
process.env.NODE_ENV = 'test';
delete process.env.GEMINI_API_KEY;
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('GEMINI_API_KEY', '');
// Act: Dynamically import and instantiate the service
const { AIService } = await import('./aiService.server');
@@ -202,7 +192,7 @@ describe('AI Service (Server)', () => {
});
it('should use the adapter to call generateContent when using real GoogleGenAI client', async () => {
process.env.GEMINI_API_KEY = 'test-key';
vi.stubEnv('GEMINI_API_KEY', 'test-key');
// We need to force the constructor to use the real client logic, not the injected mock.
// So we instantiate AIService without passing aiClient.
@@ -224,7 +214,7 @@ describe('AI Service (Server)', () => {
});
it('should throw error if adapter is called without content', async () => {
process.env.GEMINI_API_KEY = 'test-key';
vi.stubEnv('GEMINI_API_KEY', 'test-key');
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
@@ -237,17 +227,14 @@ describe('AI Service (Server)', () => {
});
describe('Model Fallback Logic', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.unstubAllEnvs();
process.env = { ...originalEnv, GEMINI_API_KEY: 'test-key' };
vi.stubEnv('GEMINI_API_KEY', 'test-key');
vi.resetModules(); // Re-import to use the new env var and re-instantiate the service
mockGenerateContent.mockReset();
});
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
@@ -279,8 +266,8 @@ describe('AI Service (Server)', () => {
});
// Check second call
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash'
model: 'gemini-2.5-flash',
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-pro'
model: 'gemini-2.5-pro',
...request,
});
@@ -919,7 +906,18 @@ describe('AI Service (Server)', () => {
} as UserProfile;
it('should throw DuplicateFlyerError if flyer already exists', async () => {
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({ flyer_id: 99, checksum: 'checksum123', file_name: 'test.pdf', image_url: '/flyer-images/test.pdf', icon_url: '/flyer-images/icons/test.webp', store_id: 1, status: 'processed', item_count: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({
flyer_id: 99,
checksum: 'checksum123',
file_name: 'test.pdf',
image_url: `${baseUrl}/flyer-images/test.pdf`,
icon_url: `${baseUrl}/flyer-images/icons/test.webp`,
store_id: 1,
status: 'processed',
item_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
await expect(
aiServiceInstance.enqueueFlyerProcessing(
@@ -983,7 +981,8 @@ describe('AI Service (Server)', () => {
filename: 'upload.jpg',
originalname: 'orig.jpg',
} as Express.Multer.File; // This was a duplicate, fixed.
const mockProfile = { user: { user_id: 'u1' } } as UserProfile;
const mockProfile = createMockUserProfile({ user: { user_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' } });
beforeEach(() => {
// Default success mocks. Use createMockFlyer for a more complete mock.
@@ -993,8 +992,8 @@ describe('AI Service (Server)', () => {
flyer: {
flyer_id: 100,
file_name: 'orig.jpg',
image_url: '/flyer-images/upload.jpg',
icon_url: '/flyer-images/icons/icon.jpg',
image_url: `${baseUrl}/flyer-images/upload.jpg`,
icon_url: `${baseUrl}/flyer-images/icons/icon.jpg`,
checksum: 'mock-checksum-123',
store_name: 'Mock Store',
valid_from: null,
@@ -1002,7 +1001,7 @@ describe('AI Service (Server)', () => {
store_address: null,
item_count: 0,
status: 'processed',
uploaded_by: 'u1',
uploaded_by: mockProfile.user.user_id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as MockFlyer, // Use the more specific MockFlyer type
@@ -1137,7 +1136,7 @@ describe('AI Service (Server)', () => {
expect(dbModule.adminRepo.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: 'flyer_processed',
userId: 'u1',
userId: mockProfile.user.user_id,
}),
mockLoggerInstance,
);

View File

@@ -879,11 +879,27 @@ async enqueueFlyerProcessing(
const iconsDir = path.join(path.dirname(file.path), 'icons');
const iconFileName = await generateFlyerIcon(file.path, iconsDir, logger);
const iconUrl = `/flyer-images/icons/${iconFileName}`;
// Construct proper URLs including protocol and host to satisfy DB constraints.
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
if (!baseUrl || !baseUrl.startsWith('http')) {
const port = process.env.PORT || 3000;
const fallbackUrl = `http://localhost:${port}`;
if (baseUrl) {
logger.warn(
`FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
);
}
baseUrl = fallbackUrl;
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const iconUrl = `${baseUrl}/flyer-images/icons/${iconFileName}`;
const imageUrl = `${baseUrl}/flyer-images/${file.filename}`;
const flyerData: FlyerInsert = {
file_name: originalFileName,
image_url: `/flyer-images/${file.filename}`,
image_url: imageUrl,
icon_url: iconUrl,
checksum: checksum,
store_name: storeName,

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// src/services/authService.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { UserProfile } from '../types';
import type * as jsonwebtoken from 'jsonwebtoken';
@@ -33,8 +34,8 @@ describe('AuthService', () => {
vi.resetModules();
// Set environment variables before any modules are imported
process.env.JWT_SECRET = 'test-secret';
process.env.FRONTEND_URL = 'http://localhost:3000';
vi.stubEnv('JWT_SECRET', 'test-secret');
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
// Mock all dependencies before dynamically importing the service
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
@@ -77,6 +78,10 @@ describe('AuthService', () => {
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('registerUser', () => {
it('should successfully register a new user', async () => {
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');

View File

@@ -1,5 +1,6 @@
// src/services/db/errors.db.test.ts
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Logger } from 'pino';
import {
DatabaseError,
UniqueConstraintError,
@@ -7,8 +8,15 @@ import {
NotFoundError,
ValidationError,
FileUploadError,
NotNullConstraintError,
CheckConstraintError,
InvalidTextRepresentationError,
NumericValueOutOfRangeError,
handleDbError,
} from './errors.db';
vi.mock('./logger.server');
describe('Custom Database and Application Errors', () => {
describe('DatabaseError', () => {
it('should create a generic database error with a message and status', () => {
@@ -114,4 +122,161 @@ describe('Custom Database and Application Errors', () => {
expect(error.name).toBe('FileUploadError');
});
});
describe('NotNullConstraintError', () => {
it('should create an error with a default message and status 400', () => {
const error = new NotNullConstraintError();
expect(error).toBeInstanceOf(DatabaseError);
expect(error.message).toBe('A required field was left null.');
expect(error.status).toBe(400);
expect(error.name).toBe('NotNullConstraintError');
});
it('should create an error with a custom message', () => {
const message = 'Email cannot be null.';
const error = new NotNullConstraintError(message);
expect(error.message).toBe(message);
});
});
describe('CheckConstraintError', () => {
it('should create an error with a default message and status 400', () => {
const error = new CheckConstraintError();
expect(error).toBeInstanceOf(DatabaseError);
expect(error.message).toBe('A check constraint was violated.');
expect(error.status).toBe(400);
expect(error.name).toBe('CheckConstraintError');
});
it('should create an error with a custom message', () => {
const message = 'Price must be positive.';
const error = new CheckConstraintError(message);
expect(error.message).toBe(message);
});
});
describe('InvalidTextRepresentationError', () => {
it('should create an error with a default message and status 400', () => {
const error = new InvalidTextRepresentationError();
expect(error).toBeInstanceOf(DatabaseError);
expect(error.message).toBe('A value has an invalid format for its data type.');
expect(error.status).toBe(400);
expect(error.name).toBe('InvalidTextRepresentationError');
});
it('should create an error with a custom message', () => {
const message = 'Invalid input syntax for type integer: "abc"';
const error = new InvalidTextRepresentationError(message);
expect(error.message).toBe(message);
});
});
describe('NumericValueOutOfRangeError', () => {
it('should create an error with a default message and status 400', () => {
const error = new NumericValueOutOfRangeError();
expect(error).toBeInstanceOf(DatabaseError);
expect(error.message).toBe('A numeric value is out of the allowed range.');
expect(error.status).toBe(400);
expect(error.name).toBe('NumericValueOutOfRangeError');
});
it('should create an error with a custom message', () => {
const message = 'Value too large for type smallint.';
const error = new NumericValueOutOfRangeError(message);
expect(error.message).toBe(message);
});
});
describe('handleDbError', () => {
const mockLogger = {
error: vi.fn(),
} as unknown as Logger;
beforeEach(() => {
vi.clearAllMocks();
});
it('should re-throw existing DatabaseError instances without logging', () => {
const notFound = new NotFoundError('Test not found');
expect(() => handleDbError(notFound, mockLogger, 'msg', {})).toThrow(notFound);
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should throw UniqueConstraintError for code 23505', () => {
const dbError = new Error('duplicate key');
(dbError as any).code = '23505';
expect(() =>
handleDbError(dbError, mockLogger, 'msg', {}, { uniqueMessage: 'custom unique' }),
).toThrow('custom unique');
});
it('should throw ForeignKeyConstraintError for code 23503', () => {
const dbError = new Error('fk violation');
(dbError as any).code = '23503';
expect(() =>
handleDbError(dbError, mockLogger, 'msg', {}, { fkMessage: 'custom fk' }),
).toThrow('custom fk');
});
it('should throw NotNullConstraintError for code 23502', () => {
const dbError = new Error('not null violation');
(dbError as any).code = '23502';
expect(() =>
handleDbError(dbError, mockLogger, 'msg', {}, { notNullMessage: 'custom not null' }),
).toThrow('custom not null');
});
it('should throw CheckConstraintError for code 23514', () => {
const dbError = new Error('check violation');
(dbError as any).code = '23514';
expect(() =>
handleDbError(dbError, mockLogger, 'msg', {}, { checkMessage: 'custom check' }),
).toThrow('custom check');
});
it('should throw InvalidTextRepresentationError for code 22P02', () => {
const dbError = new Error('invalid text');
(dbError as any).code = '22P02';
expect(() =>
handleDbError(dbError, mockLogger, 'msg', {}, { invalidTextMessage: 'custom invalid text' }),
).toThrow('custom invalid text');
});
it('should throw NumericValueOutOfRangeError for code 22003', () => {
const dbError = new Error('out of range');
(dbError as any).code = '22003';
expect(() =>
handleDbError(
dbError,
mockLogger,
'msg',
{},
{ numericOutOfRangeMessage: 'custom out of range' },
),
).toThrow('custom out of range');
});
it('should throw a generic Error with a default message', () => {
const genericError = new Error('Something else happened');
expect(() =>
handleDbError(genericError, mockLogger, 'msg', {}, { defaultMessage: 'Oops' }),
).toThrow('Oops');
expect(mockLogger.error).toHaveBeenCalledWith({ err: genericError }, 'msg');
});
it('should throw a generic Error with a constructed message using entityName', () => {
const genericError = new Error('Something else happened');
expect(() =>
handleDbError(genericError, mockLogger, 'msg', {}, { entityName: 'User' }),
).toThrow('Failed to perform operation on User.');
});
it('should throw a generic Error with a constructed message using "database" as a fallback', () => {
const genericError = new Error('Something else happened');
// No defaultMessage or entityName provided
expect(() => handleDbError(genericError, mockLogger, 'msg', {}, {})).toThrow(
'Failed to perform operation on database.',
);
});
});
});

View File

@@ -12,7 +12,12 @@ import {
vi.unmock('./flyer.db');
import { FlyerRepository, createFlyerAndItems } from './flyer.db';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import {
UniqueConstraintError,
ForeignKeyConstraintError,
NotFoundError,
CheckConstraintError,
} from './errors.db';
import type {
FlyerInsert,
FlyerItemInsert,
@@ -51,67 +56,72 @@ describe('Flyer DB Service', () => {
describe('findOrCreateStore', () => {
it('should find an existing store and return its ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: 1 }] });
// 1. INSERT...ON CONFLICT does nothing. 2. SELECT finds the store.
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] });
const result = await flyerRepo.findOrCreateStore('Existing Store', mockLogger);
expect(result).toBe(1);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(2);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT store_id FROM public.stores WHERE name = $1'),
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
['Existing Store'],
);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT store_id FROM public.stores WHERE name = $1',
['Existing Store'],
);
});
it('should create a new store if it does not exist', async () => {
it('should create a new store if it does not exist and return its ID', async () => {
// 1. INSERT...ON CONFLICT creates the store. 2. SELECT finds it.
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // First SELECT finds nothing
.mockResolvedValueOnce({ rows: [{ store_id: 2 }] })
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT affects 1 row
.mockResolvedValueOnce({ rows: [{ store_id: 2 }] }); // SELECT finds the new store
const result = await flyerRepo.findOrCreateStore('New Store', mockLogger);
expect(result).toBe(2);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(2);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id'),
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
['New Store'],
);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT store_id FROM public.stores WHERE name = $1',
['New Store'],
);
});
it('should handle race condition where store is created between SELECT and INSERT', async () => {
const uniqueConstraintError = new Error('duplicate key value violates unique constraint');
(uniqueConstraintError as Error & { code: string }).code = '23505';
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // First SELECT finds nothing
.mockRejectedValueOnce(uniqueConstraintError) // INSERT fails due to race condition
.mockResolvedValueOnce({ rows: [{ store_id: 3 }] }); // Second SELECT finds the store
const result = await flyerRepo.findOrCreateStore('Racy Store', mockLogger);
expect(result).toBe(3);
//expect(mockDb.query).toHaveBeenCalledTimes(3);
});
it('should throw an error if the database query fails', async () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// The new implementation uses handleDbError, which will throw a generic Error with the default message.
await expect(flyerRepo.findOrCreateStore('Any Store', mockLogger)).rejects.toThrow(
'Failed to find or create store in database.',
);
// handleDbError also logs the error.
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, storeName: 'Any Store' },
'Database error in findOrCreateStore',
);
});
it('should throw an error if race condition recovery fails', async () => {
const uniqueConstraintError = new Error('duplicate key value violates unique constraint');
(uniqueConstraintError as Error & { code: string }).code = '23505';
it('should throw an error if store is not found after upsert (edge case)', async () => {
// This simulates a very unlikely scenario where the store is deleted between the
// INSERT...ON CONFLICT and the subsequent SELECT.
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // First SELECT
.mockRejectedValueOnce(uniqueConstraintError) // INSERT fails
.mockRejectedValueOnce(new Error('Second select fails')); // Recovery SELECT fails
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT succeeds
.mockResolvedValueOnce({ rows: [] }); // SELECT finds nothing
await expect(flyerRepo.findOrCreateStore('Racy Store', mockLogger)).rejects.toThrow(
await expect(flyerRepo.findOrCreateStore('Weird Store', mockLogger)).rejects.toThrow(
'Failed to find or create store in database.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error), storeName: 'Racy Store' },
{
err: new Error('Failed to find store immediately after upsert operation.'),
storeName: 'Weird Store',
},
'Database error in findOrCreateStore',
);
});
@@ -121,8 +131,8 @@ describe('Flyer DB Service', () => {
it('should execute an INSERT query and return the new flyer', async () => {
const flyerData: FlyerDbInsert = {
file_name: 'test.jpg',
image_url: '/images/test.jpg',
icon_url: '/images/icons/test.jpg',
image_url: 'http://localhost:3001/images/test.jpg',
icon_url: 'http://localhost:3001/images/icons/test.jpg',
checksum: 'checksum123',
store_id: 1,
valid_from: '2024-01-01',
@@ -130,7 +140,8 @@ describe('Flyer DB Service', () => {
store_address: '123 Test St',
status: 'processed',
item_count: 10,
uploaded_by: 'user-1',
// Use a valid UUID format for the foreign key.
uploaded_by: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
};
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
@@ -143,8 +154,8 @@ describe('Flyer DB Service', () => {
expect.stringContaining('INSERT INTO flyers'),
[
'test.jpg',
'/images/test.jpg',
'/images/icons/test.jpg',
'http://localhost:3001/images/test.jpg',
'http://localhost:3001/images/icons/test.jpg',
'checksum123',
1,
'2024-01-01',
@@ -152,7 +163,7 @@ describe('Flyer DB Service', () => {
'123 Test St',
'processed',
10,
'user-1',
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
],
);
});
@@ -188,6 +199,48 @@ describe('Flyer DB Service', () => {
'Database error in insertFlyer',
);
});
it('should throw CheckConstraintError for invalid checksum format', async () => {
const flyerData: FlyerDbInsert = { checksum: 'short' } as FlyerDbInsert;
const dbError = new Error('violates check constraint "flyers_checksum_check"');
(dbError as Error & { code: string }).code = '23514'; // Check constraint violation
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
CheckConstraintError,
);
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).',
);
});
it('should throw CheckConstraintError for invalid status', async () => {
const flyerData: FlyerDbInsert = { status: 'invalid_status' } as any;
const dbError = new Error('violates check constraint "flyers_status_check"');
(dbError as Error & { code: string }).code = '23514';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
CheckConstraintError,
);
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
'Invalid status provided for flyer.',
);
});
it('should throw CheckConstraintError for invalid URL format', async () => {
const flyerData: FlyerDbInsert = { image_url: 'not-a-url' } as FlyerDbInsert;
const dbError = new Error('violates check constraint "url_check"');
(dbError as Error & { code: string }).code = '23514';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
CheckConstraintError,
);
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
'Invalid URL format provided for image or icon.',
);
});
});
describe('insertFlyerItems', () => {
@@ -324,11 +377,16 @@ describe('Flyer DB Service', () => {
// Mock the withTransaction to execute the callback with a mock client
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
// Mock the sequence of calls within the transaction
// Mock the sequence of 4 calls within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: mockItems }); // insertFlyerItems
// 1. findOrCreateStore: INSERT ... ON CONFLICT
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
// 2. findOrCreateStore: SELECT store_id
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
// 3. insertFlyer
.mockResolvedValueOnce({ rows: [mockFlyer] })
// 4. insertFlyerItems
.mockResolvedValueOnce({ rows: mockItems });
return callback(mockClient as unknown as PoolClient);
});
@@ -343,56 +401,54 @@ describe('Flyer DB Service', () => {
// Verify the individual functions were called with the client
const callback = (vi.mocked(withTransaction) as Mock).mock.calls[0][0];
const mockClient = { query: vi.fn() };
// Set up the same mock sequence for verification
mockClient.query
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
.mockResolvedValueOnce({ rows: [mockFlyer] })
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findOrCreateStore 1
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore 2
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: mockItems });
await callback(mockClient as unknown as PoolClient);
// findOrCreateStore assertions
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT store_id FROM public.stores'),
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
['Transaction Store'],
);
expect(mockClient.query).toHaveBeenCalledWith(
'SELECT store_id FROM public.stores WHERE name = $1',
['Transaction Store'],
);
// insertFlyer assertion
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO flyers'),
expect.any(Array),
);
// insertFlyerItems assertion
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO flyer_items'),
expect.any(Array),
);
});
it('should ROLLBACK the transaction if an error occurs', async () => {
it('should log and re-throw an error if the transaction fails', async () => {
const flyerData: FlyerInsert = {
file_name: 'fail.jpg',
store_name: 'Fail Store',
} as FlyerInsert;
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
const dbError = new Error('DB connection lost');
const transactionError = new Error('Underlying transaction failed');
// Mock withTransaction to simulate a failure during the callback
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockRejectedValueOnce(dbError); // insertFlyer fails
// The withTransaction helper will catch this and roll back.
// Since insertFlyer wraps the DB error, we expect the wrapped error message here.
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
'Failed to insert flyer into database.',
);
// re-throw because withTransaction re-throws (simulating the wrapped error propagating up)
throw new Error('Failed to insert flyer into database.');
});
// Mock withTransaction to reject directly
vi.mocked(withTransaction).mockRejectedValue(transactionError);
// The transactional function re-throws the original error from the failed step.
// Since insertFlyer wraps errors, we expect the wrapped error message.
// Expect the createFlyerAndItems function to reject with the same error
await expect(createFlyerAndItems(flyerData, itemsData, mockLogger)).rejects.toThrow(
'Failed to insert flyer into database.',
transactionError,
);
// The error object passed to the logger will be the wrapped Error object, not the original dbError
// Verify that the error was logged before being re-thrown
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
{ err: transactionError },
'Database transaction error in createFlyerAndItems',
);
expect(withTransaction).toHaveBeenCalledTimes(1);

View File

@@ -28,46 +28,32 @@ export class FlyerRepository {
* @returns A promise that resolves to the store's ID.
*/
async findOrCreateStore(storeName: string, logger: Logger): Promise<number> {
// Note: This method should be called within a transaction if the caller
// needs to ensure atomicity with other operations.
try {
// First, try to find the store.
let result = await this.db.query<{ store_id: number }>(
// Atomically insert the store if it doesn't exist. This is safe from race conditions.
await this.db.query(
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
[storeName],
);
// Now, the store is guaranteed to exist, so we can safely select its ID.
const result = await this.db.query<{ store_id: number }>(
'SELECT store_id FROM public.stores WHERE name = $1',
[storeName],
);
if (result.rows.length > 0) {
return result.rows[0].store_id;
} else {
// If not found, create it.
result = await this.db.query<{ store_id: number }>(
'INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id',
[storeName],
);
return result.rows[0].store_id;
// This case should be virtually impossible if the INSERT...ON CONFLICT logic is correct,
// as it would mean the store was deleted between the two queries. We throw an error to be safe.
if (result.rows.length === 0) {
throw new Error('Failed to find store immediately after upsert operation.');
}
return result.rows[0].store_id;
} catch (error) {
// Check for a unique constraint violation on name, which could happen in a race condition
// if two processes try to create the same store at the same time.
if (error instanceof Error && 'code' in error && error.code === '23505') {
try {
logger.warn(
{ storeName },
`Race condition avoided: Store was created by another process. Refetching.`,
);
const result = await this.db.query<{ store_id: number }>(
'SELECT store_id FROM public.stores WHERE name = $1',
[storeName],
);
if (result.rows.length > 0) return result.rows[0].store_id;
} catch (recoveryError) {
// If recovery fails, log a warning and fall through to the generic error handler
logger.warn({ err: recoveryError, storeName }, 'Race condition recovery failed');
}
}
logger.error({ err: error, storeName }, 'Database error in findOrCreateStore');
throw new Error('Failed to find or create store in database.');
// Use the centralized error handler for any unexpected database errors.
handleDbError(error, logger, 'Database error in findOrCreateStore', { storeName }, {
// Any error caught here is unexpected, so we use a generic message.
defaultMessage: 'Failed to find or create store in database.',
});
}
}
@@ -100,6 +86,11 @@ export class FlyerRepository {
flyerData.uploaded_by ?? null, // $11
];
logger.debug(
{ query, values },
'[DB insertFlyer] Executing insert with the following values.',
);
const result = await this.db.query<Flyer>(query, values);
return result.rows[0];
} catch (error) {
@@ -168,6 +159,11 @@ export class FlyerRepository {
RETURNING *;
`;
logger.debug(
{ query, values },
'[DB insertFlyerItems] Executing bulk insert with the following values.',
);
const result = await this.db.query<FlyerItem>(query, values);
return result.rows;
} catch (error) {

View File

@@ -237,6 +237,13 @@ describe('Shopping DB Service', () => {
});
it('should throw an error if both masterItemId and customItemName are missing', async () => {
// This test covers line 185 in shopping.db.ts
await expect(shoppingRepo.addShoppingListItem(1, 'user-1', {}, mockLogger)).rejects.toThrow(
'Either masterItemId or customItemName must be provided.',
);
});
it('should throw an error if no item data is provided', async () => {
await expect(shoppingRepo.addShoppingListItem(1, 'user-1', {}, mockLogger)).rejects.toThrow(
'Either masterItemId or customItemName must be provided.',
);
@@ -251,6 +258,15 @@ describe('Shopping DB Service', () => {
);
});
it('should throw an error if provided updates are not valid fields', async () => {
// This test covers line 362 in shopping.db.ts
const updates = { invalid_field: 'some_value' };
await expect(
shoppingRepo.updateShoppingListItem(1, 'user-1', updates as any, mockLogger),
).rejects.toThrow('No valid fields to update.');
expect(mockPoolInstance.query).not.toHaveBeenCalled(); // No DB query should be made
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);

View File

@@ -678,14 +678,17 @@ describe('User DB Service', () => {
);
});
it('should not throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
it('should log an error but not throw if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// The function is designed to swallow errors, so we expect it to resolve.
await expect(userRepo.deleteRefreshToken('a-token', mockLogger)).resolves.toBeUndefined();
// We can still check that the query was attempted.
expect(mockPoolInstance.query).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
{ err: dbError },
'Database error in deleteRefreshToken',
);
});

View File

@@ -21,6 +21,11 @@ describe('FlyerDataTransformer', () => {
beforeEach(() => {
vi.clearAllMocks();
transformer = new FlyerDataTransformer();
// Stub environment variables to ensure consistency and predictability.
// Prioritize FRONTEND_URL to match the updated service logic.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic
vi.stubEnv('PORT', ''); // Ensure this is not used
// Provide a default mock implementation for generateFlyerIcon
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp');
@@ -70,6 +75,9 @@ describe('FlyerDataTransformer', () => {
mockLogger,
);
// Dynamically construct the expected base URL, mirroring the logic in the transformer.
const expectedBaseUrl = `http://localhost:3000`;
// Assert
// 0. Check logging
expect(mockLogger.info).toHaveBeenCalledWith(
@@ -83,8 +91,8 @@ describe('FlyerDataTransformer', () => {
// 1. Check flyer data
expect(flyerData).toEqual({
file_name: originalFileName,
image_url: `http://localhost:3000/flyer-images/flyer-page-1.jpg`,
icon_url: `http://localhost:3000/flyer-images/icons/icon-flyer-page-1.webp`,
image_url: `${expectedBaseUrl}/flyer-images/flyer-page-1.jpg`,
icon_url: `${expectedBaseUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
checksum,
store_name: 'Test Store',
valid_from: '2024-01-01',
@@ -151,6 +159,9 @@ describe('FlyerDataTransformer', () => {
mockLogger,
);
// Dynamically construct the expected base URL, mirroring the logic in the transformer.
const expectedBaseUrl = `http://localhost:3000`;
// Assert
// 0. Check logging
expect(mockLogger.info).toHaveBeenCalledWith(
@@ -167,8 +178,8 @@ describe('FlyerDataTransformer', () => {
expect(itemsForDb).toHaveLength(0);
expect(flyerData).toEqual({
file_name: originalFileName,
image_url: `http://localhost:3000/flyer-images/another.png`,
icon_url: `http://localhost:3000/flyer-images/icons/icon-another.webp`,
image_url: `${expectedBaseUrl}/flyer-images/another.png`,
icon_url: `${expectedBaseUrl}/flyer-images/icons/icon-another.webp`,
checksum,
store_name: 'Unknown Store (auto)', // Should use fallback
valid_from: null,
@@ -176,7 +187,7 @@ describe('FlyerDataTransformer', () => {
store_address: null,
item_count: 0,
status: 'needs_review',
uploaded_by: undefined, // Should be undefined
uploaded_by: null, // Should be null
});
});

View File

@@ -75,20 +75,38 @@ export class FlyerDataTransformer {
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
}
// Construct proper URLs including protocol and host to satisfy DB constraints
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
// Construct proper URLs including protocol and host to satisfy DB constraints.
// This logic is made more robust to handle cases where env vars might be present but invalid (e.g., whitespace or missing protocol).
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
if (!baseUrl || !baseUrl.startsWith('http')) {
const port = process.env.PORT || 3000;
const fallbackUrl = `http://localhost:${port}`;
if (baseUrl) {
// It was set but invalid
logger.warn(
`FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
);
}
baseUrl = fallbackUrl;
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const flyerData: FlyerInsert = {
file_name: originalFileName,
image_url: new URL(`/flyer-images/${path.basename(firstImage)}`, baseUrl).href,
icon_url: new URL(`/flyer-images/icons/${iconFileName}`, baseUrl).href,
image_url: `${baseUrl}/flyer-images/${path.basename(firstImage)}`,
icon_url: `${baseUrl}/flyer-images/icons/${iconFileName}`,
checksum,
store_name: storeName,
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address, // The number of items is now calculated directly from the transformed data.
item_count: itemsForDb.length,
uploaded_by: userId,
// Defensively handle the userId. An empty string ('') is not a valid UUID,
// but `null` is. This ensures that any falsy value for userId (undefined, null, '')
// is converted to `null` for the database, preventing a 22P02 error.
uploaded_by: userId ? userId : null,
status: needsReview ? 'needs_review' : 'processed',
};

View File

@@ -35,15 +35,13 @@ vi.mock('./logger.server', () => ({
import { logger } from './logger.server';
describe('Geocoding Service', () => {
const originalEnv = process.env;
let geocodingService: GeocodingService;
let mockGoogleService: GoogleGeocodingService;
let mockNominatimService: NominatimGeocodingService;
beforeEach(() => {
vi.clearAllMocks();
// Restore process.env to its original state before each test
process.env = { ...originalEnv };
vi.unstubAllEnvs();
// Create a mock instance of the Google service
mockGoogleService = { geocode: vi.fn() } as unknown as GoogleGeocodingService;
@@ -53,8 +51,7 @@ describe('Geocoding Service', () => {
});
afterEach(() => {
// Restore process.env after each test
process.env = originalEnv;
vi.unstubAllEnvs();
});
describe('geocodeAddress', () => {
@@ -77,7 +74,7 @@ describe('Geocoding Service', () => {
it('should log an error 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';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key');
mocks.mockRedis.get.mockRejectedValue(new Error('Redis down'));
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
@@ -95,7 +92,7 @@ describe('Geocoding Service', () => {
it('should proceed to fetch if cached data is invalid JSON', async () => {
// Arrange: Mock Redis to return a malformed JSON string
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key');
mocks.mockRedis.get.mockResolvedValue('{ "lat": 45.0, "lng": -75.0 '); // Missing closing brace
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
@@ -115,7 +112,7 @@ describe('Geocoding Service', () => {
it('should fetch from Google, return coordinates, and cache the result on cache miss', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key');
mocks.mockRedis.get.mockResolvedValue(null); // Cache miss
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
@@ -136,7 +133,7 @@ describe('Geocoding Service', () => {
it('should fall back to Nominatim if Google API key is missing', async () => {
// Arrange
delete process.env.GOOGLE_MAPS_API_KEY;
vi.stubEnv('GOOGLE_MAPS_API_KEY', '');
mocks.mockRedis.get.mockResolvedValue(null);
vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);
@@ -155,7 +152,7 @@ describe('Geocoding Service', () => {
it('should fall back to Nominatim if Google API returns a non-OK status', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key');
mocks.mockRedis.get.mockResolvedValue(null);
vi.mocked(mockGoogleService.geocode).mockResolvedValue(null); // Google returns no results
vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);
@@ -174,7 +171,7 @@ describe('Geocoding Service', () => {
it('should fall back to Nominatim if Google API fetch call fails', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key');
mocks.mockRedis.get.mockResolvedValue(null);
vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error'));
vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);
@@ -193,7 +190,7 @@ describe('Geocoding Service', () => {
it('should return null and log an error if both Google and Nominatim fail', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key');
mocks.mockRedis.get.mockResolvedValue(null);
vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error'));
vi.mocked(mockNominatimService.geocode).mockResolvedValue(null); // Nominatim also fails
@@ -209,7 +206,7 @@ describe('Geocoding Service', () => {
it('should return coordinates even if Redis SET fails', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key');
mocks.mockRedis.get.mockResolvedValue(null); // Cache miss
vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
// Mock Redis 'set' to fail

View File

@@ -20,25 +20,22 @@ import { logger as mockLogger } from './logger.server';
describe('Google Geocoding Service', () => {
let googleGeocodingService: GoogleGeocodingService;
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
// Mock the global fetch function before each test
vi.stubGlobal('fetch', vi.fn());
// Restore process.env to a clean state for each test
process.env = { ...originalEnv };
vi.unstubAllEnvs();
googleGeocodingService = new GoogleGeocodingService();
});
afterEach(() => {
// Restore original environment variables after each test
process.env = originalEnv;
vi.unstubAllEnvs();
});
it('should return coordinates for a valid address when API key is present', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-api-key');
const mockApiResponse = {
status: 'OK',
results: [
@@ -70,7 +67,7 @@ describe('Google Geocoding Service', () => {
it('should throw an error if GOOGLE_MAPS_API_KEY is not set', async () => {
// Arrange
delete process.env.GOOGLE_MAPS_API_KEY;
vi.stubEnv('GOOGLE_MAPS_API_KEY', '');
// Act & Assert
await expect(googleGeocodingService.geocode('Any Address', mockLogger)).rejects.toThrow(
@@ -81,7 +78,7 @@ describe('Google Geocoding Service', () => {
it('should return null if the API returns a status other than "OK"', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-api-key');
const mockApiResponse = { status: 'ZERO_RESULTS', results: [] };
vi.mocked(fetch).mockResolvedValue({
ok: true,
@@ -101,7 +98,7 @@ describe('Google Geocoding Service', () => {
it('should throw an error if the fetch response is not ok', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-api-key');
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 403,
@@ -119,7 +116,7 @@ describe('Google Geocoding Service', () => {
it('should throw an error if the fetch call itself fails', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-api-key');
const networkError = new Error('Network request failed');
vi.mocked(fetch).mockRejectedValue(networkError);

View File

@@ -1,5 +1,5 @@
// src/services/logger.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock pino before importing the logger
const pinoMock = vi.fn(() => ({
@@ -15,16 +15,21 @@ describe('Server Logger', () => {
// Reset modules to ensure process.env changes are applied to new module instances
vi.resetModules();
vi.clearAllMocks();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should initialize pino with the correct level for production', async () => {
process.env.NODE_ENV = 'production';
vi.stubEnv('NODE_ENV', 'production');
await import('./logger.server');
expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' }));
});
it('should initialize pino with pretty-print transport for development', async () => {
process.env.NODE_ENV = 'development';
vi.stubEnv('NODE_ENV', 'development');
await import('./logger.server');
expect(pinoMock).toHaveBeenCalledWith(
expect.objectContaining({ transport: expect.any(Object) }),

View File

@@ -7,6 +7,10 @@ import { ValidationError, NotFoundError } from './db/errors.db';
import type { Job } from 'bullmq';
import type { TokenCleanupJobData } from '../types/job-data';
// Un-mock the service under test to ensure we are testing the real implementation,
// not the global mock from `tests/setup/tests-setup-unit.ts`.
vi.unmock('./userService');
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
// Create mock implementations for the repository methods we'll be using.
@@ -212,9 +216,12 @@ describe('UserService', () => {
describe('updateUserAvatar', () => {
it('should construct avatar URL and update profile', async () => {
const { logger } = await import('./logger.server');
const testBaseUrl = 'http://localhost:3001';
vi.stubEnv('FRONTEND_URL', testBaseUrl);
const userId = 'user-123';
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
const expectedUrl = '/uploads/avatars/avatar.jpg';
const expectedUrl = `${testBaseUrl}/uploads/avatars/avatar.jpg`;
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);
@@ -225,6 +232,8 @@ describe('UserService', () => {
{ avatar_url: expectedUrl },
logger,
);
vi.unstubAllEnvs();
});
});

View File

@@ -87,7 +87,21 @@ class UserService {
* @returns The updated user profile.
*/
async updateUserAvatar(userId: string, file: Express.Multer.File, logger: Logger): Promise<Profile> {
const avatarUrl = `/uploads/avatars/${file.filename}`;
// Construct proper URLs including protocol and host to satisfy DB constraints.
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
if (!baseUrl || !baseUrl.startsWith('http')) {
const port = process.env.PORT || 3000;
const fallbackUrl = `http://localhost:${port}`;
if (baseUrl) {
logger.warn(
`FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,
);
}
baseUrl = fallbackUrl;
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const avatarUrl = `${baseUrl}/uploads/avatars/${file.filename}`;
return db.userRepo.updateUserProfile(
userId,
{ avatar_url: avatarUrl },

View File

@@ -92,6 +92,8 @@ export const flyerWorker = new Worker<FlyerJobData>(
{
connection,
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '1', 10),
// Increase lock duration to prevent jobs from being re-processed prematurely.
lockDuration: parseInt(process.env.WORKER_LOCK_DURATION || '30000', 10),
},
);

View File

@@ -174,6 +174,20 @@ describe('Authentication E2E Flow', () => {
expect(registerResponse.status).toBe(201);
createdUserIds.push(registerData.userprofile.user.user_id);
// Instead of a fixed delay, poll by attempting to log in. This is more robust
// and confirms the user record is committed and readable by subsequent transactions.
let loginSuccess = false;
for (let i = 0; i < 10; i++) {
// Poll for up to 10 seconds
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
if (loginResponse.ok) {
loginSuccess = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
expect(loginSuccess, 'User should be able to log in after registration before password reset is attempted.').toBe(true);
// Act 1: Request a password reset.
// The test environment returns the token directly in the response for E2E testing.
const forgotResponse = await apiClient.requestPasswordReset(email);

View File

@@ -89,7 +89,7 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
// 5. Poll for job completion
let jobStatus;
const maxRetries = 30; // Poll for up to 90 seconds
const maxRetries = 60; // Poll for up to 180 seconds
for (let i = 0; i < maxRetries; i++) {
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3s
@@ -106,5 +106,5 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
expect(jobStatus.state).toBe('completed');
flyerId = jobStatus.returnValue?.flyerId;
expect(flyerId).toBeTypeOf('number');
}, 120000); // Extended timeout for AI processing
}, 240000); // Extended timeout for AI processing
});

View File

@@ -163,8 +163,8 @@ describe('Admin API Routes Integration Tests', () => {
// Before each modification test, create a fresh flyer item and a correction for it.
beforeEach(async () => {
const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
VALUES ($1, 'admin-test.jpg', 'https://example.com/flyer-images/asdmin-test.jpg', 1, $2) RETURNING flyer_id`,
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'admin-test.jpg', 'https://example.com/flyer-images/asdmin-test.jpg', 'https://example.com/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
// The checksum must be a unique 64-character string to satisfy the DB constraint.
// We generate a dynamic string and pad it to 64 characters.
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],

View File

@@ -1,48 +1,59 @@
// src/tests/integration/db.integration.test.ts
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as db from '../../services/db/index.db';
import * as bcrypt from 'bcrypt';
import { getPool } from '../../services/db/connection.db';
import { logger } from '../../services/logger.server';
import type { UserProfile } from '../../types';
import { cleanupDb } from '../utils/cleanup';
describe('Database Service Integration Tests', () => {
it('should create a new user and be able to find them by email', async ({ onTestFinished }) => {
let testUser: UserProfile;
let testUserEmail: string;
beforeEach(async () => {
// Arrange: Use a unique email for each test run to ensure isolation.
const email = `test.user-${Date.now()}@example.com`;
testUserEmail = `test.user-${Date.now()}@example.com`;
const password = 'password123';
const fullName = 'Test User';
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Ensure the created user is cleaned up after this specific test finishes.
onTestFinished(async () => {
await getPool().query('DELETE FROM public.users WHERE email = $1', [email]);
});
// Act: Call the createUser function
const createdUser = await db.userRepo.createUser(
email,
testUser = await db.userRepo.createUser(
testUserEmail,
passwordHash,
{ full_name: fullName },
logger,
);
});
afterEach(async () => {
// Ensure the created user is cleaned up after each test.
if (testUser?.user.user_id) {
await cleanupDb({ userIds: [testUser.user.user_id] });
}
});
it('should create a new user and have a corresponding profile', async () => {
// Assert: Check that the user was created with the correct details
expect(createdUser).toBeDefined();
expect(createdUser.user.email).toBe(email); // This is correct
expect(createdUser.user.user_id).toBeTypeOf('string');
expect(testUser).toBeDefined();
expect(testUser.user.email).toBe(testUserEmail);
expect(testUser.user.user_id).toBeTypeOf('string');
// Also, verify the profile was created by the trigger
const profile = await db.userRepo.findUserProfileById(testUser.user.user_id, logger);
expect(profile).toBeDefined();
expect(profile?.full_name).toBe('Test User');
});
it('should be able to find the created user by email', async () => {
// Act: Try to find the user we just created
const foundUser = await db.userRepo.findUserByEmail(email, logger);
const foundUser = await db.userRepo.findUserByEmail(testUserEmail, logger);
// Assert: Check that the found user matches the created user
expect(foundUser).toBeDefined();
expect(foundUser?.user_id).toBe(createdUser.user.user_id);
expect(foundUser?.email).toBe(email);
// Also, verify the profile was created by the trigger
const profile = await db.userRepo.findUserProfileById(createdUser.user.user_id, logger);
expect(profile).toBeDefined();
expect(profile?.full_name).toBe(fullName);
expect(foundUser?.user_id).toBe(testUser.user.user_id);
expect(foundUser?.email).toBe(testUserEmail);
});
});

View File

@@ -15,6 +15,7 @@ import { cleanupFiles } from '../utils/cleanupFiles';
import piexif from 'piexifjs';
import exifParser from 'exif-parser';
import sharp from 'sharp';
import { createFlyerAndItems } from '../../services/db/flyer.db';
/**
@@ -23,8 +24,34 @@ import sharp from 'sharp';
const request = supertest(app);
// Import the mocked service to control its behavior in tests.
import { aiService } from '../../services/aiService.server';
const { mockExtractCoreData } = vi.hoisted(() => ({
mockExtractCoreData: vi.fn(),
}));
// Mock the AI service to prevent real API calls during integration tests.
// This is crucial for making the tests reliable and fast. We don't want to
// depend on the external Gemini API.
vi.mock('../../services/aiService.server', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
return {
...actual,
aiService: {
...actual.aiService,
// Replace the real method with our hoisted mock function.
extractCoreDataFromFlyerImage: mockExtractCoreData,
},
};
});
// Mock the database service to allow for simulating DB failures.
// By default, it will use the real implementation.
vi.mock('../../services/db/flyer.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/flyer.db')>();
return {
...actual,
createFlyerAndItems: vi.fn().mockImplementation(actual.createFlyerAndItems),
};
});
describe('Flyer Processing Background Job Integration Test', () => {
const createdUserIds: string[] = [];
@@ -32,23 +59,21 @@ describe('Flyer Processing Background Job Integration Test', () => {
const createdFilePaths: string[] = [];
beforeAll(async () => {
// This setup is now simpler as the worker handles fetching master items.
// Setup default mock response for AI service
const mockItems: ExtractedFlyerItem[] = [
{
item: 'Mocked Integration Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'each',
category_name: 'Mock Category',
},
];
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockResolvedValue({
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
mockExtractCoreData.mockResolvedValue({
store_name: 'Mock Store',
valid_from: null,
valid_to: null,
store_address: null,
items: mockItems,
items: [
{
item: 'Mocked Integration Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'each',
category_name: 'Mock Category',
},
],
});
});
@@ -101,7 +126,9 @@ describe('Flyer Processing Background Job Integration Test', () => {
// Act 2: Poll for the job status until it completes.
let jobStatus;
const maxRetries = 60; // Poll for up to 180 seconds (60 * 3s)
// Poll for up to 210 seconds (70 * 3s). This should be greater than the worker's
// lockDuration (120s) to patiently wait for long-running jobs.
const maxRetries = 70;
for (let i = 0; i < maxRetries; i++) {
console.log(`Polling attempt ${i + 1}...`);
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
@@ -163,11 +190,6 @@ describe('Flyer Processing Background Job Integration Test', () => {
});
createdUserIds.push(authUser.user.user_id); // Track for cleanup
// Use a cleanup function to delete the user even if the test fails.
onTestFinished(async () => {
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [authUser.user.user_id]);
});
// Act & Assert
await runBackgroundProcessingTest(authUser, token);
}, 240000); // Increase timeout to 240 seconds for this long-running test
@@ -223,7 +245,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
// Poll for job completion
let jobStatus;
const maxRetries = 30; // Poll for up to 90 seconds
const maxRetries = 60; // Poll for up to 180 seconds
for (let i = 0; i < maxRetries; i++) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const statusResponse = await request
@@ -309,7 +331,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
// Poll for job completion
let jobStatus;
const maxRetries = 30;
const maxRetries = 60; // Poll for up to 180 seconds
for (let i = 0; i < maxRetries; i++) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const statusResponse = await request
@@ -345,4 +367,162 @@ describe('Flyer Processing Background Job Integration Test', () => {
},
240000,
);
it(
'should handle a failure from the AI service gracefully',
async () => {
// Arrange: Mock the AI service to throw an error for this specific test.
const aiError = new Error('AI model failed to extract data.');
mockExtractCoreData.mockRejectedValueOnce(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
const imageBuffer = await fs.readFile(imagePath);
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`fail-test-${Date.now()}`)]);
const uniqueFileName = `ai-fail-test-${Date.now()}.jpg`;
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
const checksum = await generateFileChecksum(mockImageFile);
// Track created files for cleanup
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
createdFilePaths.push(path.join(uploadDir, uniqueFileName));
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
const { jobId } = uploadResponse.body;
expect(jobId).toBeTypeOf('string');
// Act 2: Poll for the job status until it completes or fails.
let jobStatus;
const maxRetries = 60;
for (let i = 0; i < maxRetries; i++) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
jobStatus = statusResponse.body;
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
break;
}
}
// Assert 1: Check that the job failed.
expect(jobStatus?.state).toBe('failed');
expect(jobStatus?.failedReason).toContain('AI model failed to extract data.');
// Assert 2: Verify the flyer was NOT saved in the database.
const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
expect(savedFlyer).toBeUndefined();
},
240000,
);
it(
'should handle a database failure during flyer creation',
async () => {
// Arrange: Mock the database creation function to throw an error for this specific test.
const dbError = new Error('DB transaction failed');
vi.mocked(createFlyerAndItems).mockRejectedValueOnce(dbError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
const imageBuffer = await fs.readFile(imagePath);
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`db-fail-test-${Date.now()}`)]);
const uniqueFileName = `db-fail-test-${Date.now()}.jpg`;
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
const checksum = await generateFileChecksum(mockImageFile);
// Track created files for cleanup
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
createdFilePaths.push(path.join(uploadDir, uniqueFileName));
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
const { jobId } = uploadResponse.body;
expect(jobId).toBeTypeOf('string');
// Act 2: Poll for the job status until it completes or fails.
let jobStatus;
const maxRetries = 60;
for (let i = 0; i < maxRetries; i++) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
jobStatus = statusResponse.body;
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
break;
}
}
// Assert 1: Check that the job failed.
expect(jobStatus?.state).toBe('failed');
expect(jobStatus?.failedReason).toContain('DB transaction failed');
// Assert 2: Verify the flyer was NOT saved in the database.
const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
expect(savedFlyer).toBeUndefined();
},
240000,
);
it(
'should NOT clean up temporary files when a job fails, to allow for manual inspection',
async () => {
// Arrange: Mock the AI service to throw an error, causing the job to fail.
const aiError = new Error('Simulated AI failure for cleanup test.');
mockExtractCoreData.mockRejectedValueOnce(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
const imageBuffer = await fs.readFile(imagePath);
const uniqueContent = Buffer.concat([
imageBuffer,
Buffer.from(`cleanup-fail-test-${Date.now()}`),
]);
const uniqueFileName = `cleanup-fail-test-${Date.now()}.jpg`;
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
const checksum = await generateFileChecksum(mockImageFile);
// Track the path of the file that will be created in the uploads directory.
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
const tempFilePath = path.join(uploadDir, uniqueFileName);
createdFilePaths.push(tempFilePath);
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
const { jobId } = uploadResponse.body;
expect(jobId).toBeTypeOf('string');
// Act 2: Poll for the job status until it fails.
let jobStatus;
const maxRetries = 60;
for (let i = 0; i < maxRetries; i++) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
jobStatus = statusResponse.body;
if (jobStatus.state === 'failed') {
break;
}
}
// Assert 1: Check that the job actually failed.
expect(jobStatus?.state).toBe('failed');
expect(jobStatus?.failedReason).toContain('Simulated AI failure for cleanup test.');
// Assert 2: Verify the temporary file was NOT deleted.
// We check for its existence. If it doesn't exist, fs.access will throw an error.
await expect(fs.access(tempFilePath), 'Expected temporary file to exist after job failure, but it was deleted.');
},
240000,
);
});

View File

@@ -1,9 +1,10 @@
// src/tests/integration/flyer.integration.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import { getPool } from '../../services/db/connection.db';
import app from '../../../server';
import type { Flyer, FlyerItem } from '../../types';
import { cleanupDb } from '../utils/cleanup';
/**
* @vitest-environment node
@@ -13,6 +14,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
let flyers: Flyer[] = [];
// Use a supertest instance for all requests in this file
const request = supertest(app);
let testStoreId: number;
let createdFlyerId: number;
// Fetch flyers once before all tests in this suite to use in subsequent tests.
@@ -21,12 +23,12 @@ describe('Public Flyer API Routes Integration Tests', () => {
const storeRes = await getPool().query(
`INSERT INTO public.stores (name) VALUES ('Integration Test Store') RETURNING store_id`,
);
const storeId = storeRes.rows[0].store_id;
testStoreId = storeRes.rows[0].store_id;
const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
VALUES ($1, 'integration-test.jpg', 'https://example.com/flyer-images/integration-test.jpg', 1, $2) RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}`.padEnd(64, '0')],
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'integration-test.jpg', 'https://example.com/flyer-images/integration-test.jpg', 'https://example.com/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
);
createdFlyerId = flyerRes.rows[0].flyer_id;
@@ -41,6 +43,14 @@ describe('Public Flyer API Routes Integration Tests', () => {
flyers = response.body;
});
afterAll(async () => {
// Clean up the test data created in beforeAll to prevent polluting the test database.
await cleanupDb({
flyerIds: [createdFlyerId],
storeIds: [testStoreId],
});
});
describe('GET /api/flyers', () => {
it('should return a list of flyers', async () => {
// Act: Call the API endpoint using the client function.

View File

@@ -4,11 +4,13 @@ import supertest from 'supertest';
import app from '../../../server';
import path from 'path';
import fs from 'node:fs/promises';
import { getPool } from '../../services/db/connection.db';
import { createAndLoginUser } from '../utils/testHelpers';
import { generateFileChecksum } from '../../utils/checksum';
import * as db from '../../services/db/index.db';
import { cleanupDb } from '../utils/cleanup';
import { logger } from '../../services/logger.server';
import * as imageProcessor from '../../utils/imageProcessor';
import type {
UserProfile,
UserAchievement,
@@ -16,6 +18,7 @@ import type {
Achievement,
ExtractedFlyerItem,
} from '../../types';
import type { Flyer } from '../../types';
import { cleanupFiles } from '../utils/cleanupFiles';
/**
@@ -24,14 +27,39 @@ import { cleanupFiles } from '../utils/cleanupFiles';
const request = supertest(app);
// Import the mocked service to control its behavior in tests.
import { aiService } from '../../services/aiService.server';
const { mockExtractCoreData } = vi.hoisted(() => ({
mockExtractCoreData: vi.fn(),
}));
// Mock the AI service to prevent real API calls during integration tests.
// This is crucial for making the tests reliable and fast. We don't want to
// depend on the external Gemini API.
vi.mock('../../services/aiService.server', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
return {
...actual,
aiService: {
...actual.aiService,
extractCoreDataFromFlyerImage: mockExtractCoreData,
},
};
});
// Mock the image processor to control icon generation for legacy uploads
vi.mock('../../utils/imageProcessor', async () => {
const actual = await vi.importActual<typeof imageProcessor>('../../utils/imageProcessor');
return {
...actual,
generateFlyerIcon: vi.fn(),
};
});
describe('Gamification Flow Integration Test', () => {
let testUser: UserProfile;
let authToken: string;
const createdFlyerIds: number[] = [];
const createdFilePaths: string[] = [];
const createdStoreIds: number[] = [];
beforeAll(async () => {
// Create a new user specifically for this test suite to ensure a clean slate.
@@ -41,26 +69,21 @@ describe('Gamification Flow Integration Test', () => {
request,
}));
// Mock the AI service's method to prevent actual API calls during integration tests.
// This is crucial for making the integration test reliable. We don't want to
// depend on the external Gemini API, which has quotas and can be slow.
// By mocking this, we test our application's internal flow:
// API -> Queue -> Worker -> DB -> Gamification Logic
const mockExtractedItems: ExtractedFlyerItem[] = [
{
item: 'Integration Test Milk',
price_display: '$4.99',
price_in_cents: 499,
quantity: '2L',
category_name: 'Dairy',
},
];
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockResolvedValue({
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
mockExtractCoreData.mockResolvedValue({
store_name: 'Gamification Test Store',
valid_from: null,
valid_to: null,
store_address: null,
items: mockExtractedItems,
items: [
{
item: 'Integration Test Milk',
price_display: '$4.99',
price_in_cents: 499,
quantity: '2L',
category_name: 'Dairy',
},
],
});
});
@@ -68,6 +91,7 @@ describe('Gamification Flow Integration Test', () => {
await cleanupDb({
userIds: testUser ? [testUser.user.user_id] : [],
flyerIds: createdFlyerIds,
storeIds: createdStoreIds,
});
await cleanupFiles(createdFilePaths);
});
@@ -75,6 +99,10 @@ describe('Gamification Flow Integration Test', () => {
it(
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
async () => {
// --- Arrange: Stub environment variables for URL generation in the background worker ---
const testBaseUrl = 'http://localhost:3001'; // Use a fixed port for predictability
vi.stubEnv('FRONTEND_URL', testBaseUrl);
// --- Arrange: Prepare a unique flyer file for upload ---
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
const imageBuffer = await fs.readFile(imagePath);
@@ -101,7 +129,7 @@ describe('Gamification Flow Integration Test', () => {
// --- Act 2: Poll for job completion ---
let jobStatus;
const maxRetries = 30; // Poll for up to 90 seconds
const maxRetries = 60; // Poll for up to 180 seconds
for (let i = 0; i < maxRetries; i++) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const statusResponse = await request
@@ -161,7 +189,82 @@ describe('Gamification Flow Integration Test', () => {
expect(Number(userOnLeaderboard?.points)).toBeGreaterThanOrEqual(
firstUploadAchievement!.points_value,
);
// --- Cleanup ---
vi.unstubAllEnvs();
},
120000, // Increase timeout to 120 seconds for this long-running test
240000, // Increase timeout to 240s to match other long-running processing tests
);
describe('Legacy Flyer Upload', () => {
it('should process a legacy upload and save fully qualified URLs to the database', async () => {
// --- Arrange ---
// 1. Stub environment variables to have a predictable base URL for the test.
const testBaseUrl = 'https://cdn.example.com';
vi.stubEnv('FRONTEND_URL', testBaseUrl);
// 2. Mock the icon generator to return a predictable filename.
vi.mocked(imageProcessor.generateFlyerIcon).mockResolvedValue('legacy-icon.webp');
// 3. Prepare a unique file for upload to avoid checksum conflicts.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
const imageBuffer = await fs.readFile(imagePath);
const uniqueFileName = `legacy-upload-test-${Date.now()}.jpg`;
const mockImageFile = new File([imageBuffer], uniqueFileName, { type: 'image/jpeg' });
const checksum = await generateFileChecksum(mockImageFile);
// Track created files for cleanup.
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
createdFilePaths.push(path.join(uploadDir, uniqueFileName));
createdFilePaths.push(path.join(uploadDir, 'icons', 'legacy-icon.webp'));
// 4. Prepare the legacy payload (body of the request).
const storeName = `Legacy Store - ${Date.now()}`;
const legacyPayload = {
checksum: checksum,
extractedData: {
store_name: storeName,
items: [{ item: 'Legacy Milk', price_in_cents: 250 }],
},
};
// --- Act ---
// 5. Make the API request.
// Note: This assumes a legacy endpoint exists at `/api/ai/upload-legacy`.
// This endpoint would be responsible for calling `aiService.processLegacyFlyerUpload`.
const response = await request
.post('/api/ai/upload-legacy')
.set('Authorization', `Bearer ${authToken}`)
.field('data', JSON.stringify(legacyPayload))
.attach('flyerFile', imageBuffer, uniqueFileName);
// --- Assert ---
// 6. Check for a successful response.
expect(response.status).toBe(200);
const newFlyer: Flyer = response.body;
expect(newFlyer).toBeDefined();
expect(newFlyer.flyer_id).toBeTypeOf('number');
createdFlyerIds.push(newFlyer.flyer_id); // Add for cleanup.
// 7. Query the database directly to verify the saved values.
const pool = getPool();
const dbResult = await pool.query<Flyer>(
'SELECT image_url, icon_url, store_id FROM public.flyers WHERE flyer_id = $1',
[newFlyer.flyer_id],
);
expect(dbResult.rowCount).toBe(1);
const savedFlyer = dbResult.rows[0];
// The store_id is guaranteed to exist for a saved flyer, but the generic `Flyer` type
// might have it as optional. We use a non-null assertion `!` to satisfy TypeScript.
createdStoreIds.push(savedFlyer.store_id!); // Add for cleanup.
// 8. Assert that the URLs are fully qualified.
expect(savedFlyer.image_url).to.equal(`${testBaseUrl}/flyer-images/${uniqueFileName}`);
expect(savedFlyer.icon_url).to.equal(`${testBaseUrl}/flyer-images/icons/legacy-icon.webp`);
// --- Cleanup ---
vi.unstubAllEnvs();
});
});
});

View File

@@ -34,22 +34,22 @@ describe('Price History API Integration Test (/api/price-history)', () => {
// 3. Create two flyers with different dates
const flyerRes1 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-1.jpg', 'https://example.com/flyer-images/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-1.jpg', 'https://example.com/flyer-images/price-test-1.jpg', 'https://example.com/flyer-images/icons/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}1`.padEnd(64, '0')],
);
flyerId1 = flyerRes1.rows[0].flyer_id;
const flyerRes2 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-2.jpg', 'https://example.com/flyer-images/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-2.jpg', 'https://example.com/flyer-images/price-test-2.jpg', 'https://example.com/flyer-images/icons/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}2`.padEnd(64, '0')],
);
flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed.
const flyerRes3 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-3.jpg', 'https://example.com/flyer-images/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-3.jpg', 'https://example.com/flyer-images/price-test-3.jpg', 'https://example.com/flyer-images/icons/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}3`.padEnd(64, '0')],
);
flyerId3 = flyerRes3.rows[0].flyer_id;

View File

@@ -77,8 +77,8 @@ describe('Public API Routes Integration Tests', () => {
);
testStoreId = storeRes.rows[0].store_id;
const flyerRes = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
VALUES ($1, 'public-routes-test.jpg', 'https://example.com/flyer-images/public-routes-test.jpg', 1, $2) RETURNING *`,
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'public-routes-test.jpg', 'https://example.com/flyer-images/public-routes-test.jpg', 'https://example.com/flyer-images/icons/public-routes-test.jpg', 1, $2) RETURNING *`,
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
);
testFlyer = flyerRes.rows[0];

View File

@@ -329,6 +329,59 @@ vi.mock('react-hot-toast', () => ({
// --- Database Service Mocks ---
// Mock for db/index.db which exports repository instances used by many routes
vi.mock('../../services/db/index.db', () => ({
userRepo: {
findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(),
updateUserPreferences: vi.fn(),
},
personalizationRepo: {
getWatchedItems: vi.fn(),
removeWatchedItem: vi.fn(),
addWatchedItem: vi.fn(),
getUserDietaryRestrictions: vi.fn(),
setUserDietaryRestrictions: vi.fn(),
getUserAppliances: vi.fn(),
setUserAppliances: vi.fn(),
},
shoppingRepo: {
getShoppingLists: vi.fn(),
createShoppingList: vi.fn(),
deleteShoppingList: vi.fn(),
addShoppingListItem: vi.fn(),
updateShoppingListItem: vi.fn(),
removeShoppingListItem: vi.fn(),
getShoppingListById: vi.fn(),
},
recipeRepo: {
deleteRecipe: vi.fn(),
updateRecipe: vi.fn(),
},
addressRepo: {
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
},
notificationRepo: {
getNotificationsForUser: vi.fn(),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
},
}));
// Mock userService used by routes
vi.mock('../../services/userService', () => ({
userService: {
updateUserAvatar: vi.fn(),
updateUserPassword: vi.fn(),
deleteUserAccount: vi.fn(),
getUserAddress: vi.fn(),
upsertUserAddress: vi.fn(),
processTokenCleanupJob: vi.fn(),
deleteUserAsAdmin: vi.fn(),
},
}));
vi.mock('../../services/db/user.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/user.db')>();
return {

View File

@@ -88,7 +88,10 @@ export const resetMockIds = () => {
* @returns A complete and type-safe User object.
*/
export const createMockUser = (overrides: Partial<User> = {}): User => {
const userId = overrides.user_id ?? `user-${getNextId()}`;
// Generate a deterministic, valid UUID-like string for mock user IDs.
// This prevents database errors in integration tests where a UUID is expected.
const userId =
overrides.user_id ?? `00000000-0000-0000-0000-${String(getNextId()).padStart(12, '0')}`;
const defaultUser: User = {
user_id: userId,
@@ -175,6 +178,8 @@ export const createMockFlyer = (
store_id: overrides.store_id ?? overrides.store?.store_id,
});
const baseUrl = 'http://localhost:3001'; // A reasonable default for tests
// Determine the final file_name to generate dependent properties from.
const fileName = overrides.file_name ?? `flyer-${flyerId}.jpg`;
@@ -192,8 +197,8 @@ export const createMockFlyer = (
const defaultFlyer: Flyer = {
flyer_id: flyerId,
file_name: fileName,
image_url: `/flyer-images/${fileName}`,
icon_url: `/flyer-images/icons/icon-${fileName.replace(/\.[^/.]+$/, '.webp')}`,
image_url: `${baseUrl}/flyer-images/${fileName}`,
icon_url: `${baseUrl}/flyer-images/icons/icon-${fileName.replace(/\.[^/.]+$/, '.webp')}`,
checksum: generateMockChecksum(fileName),
store_id: store.store_id,
valid_from: new Date().toISOString().split('T')[0],

View File