Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ebe2f0806 | ||
| 7867abc5bc | |||
|
|
cc4c8e2839 | ||
| 33ee2eeac9 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.53",
|
||||
"version": "0.9.55",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.53",
|
||||
"version": "0.9.55",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.9.53",
|
||||
"version": "0.9.55",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { FlyerJobData } from '../types/job-data';
|
||||
// Mock dependencies
|
||||
vi.mock('sharp', () => {
|
||||
const mockSharpInstance = {
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// src/services/flyerPersistenceService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FlyerPersistenceService } from './flyerPersistenceService.server';
|
||||
import { withTransaction } from './db/connection.db';
|
||||
import { createFlyerAndItems } from './db/flyer.db';
|
||||
import { AdminRepository } from './db/admin.db';
|
||||
import { GamificationRepository } from './db/gamification.db';
|
||||
import type { FlyerInsert, FlyerItemInsert, Flyer } from '../types';
|
||||
import type { Logger } from 'pino';
|
||||
import type { PoolClient } from 'pg';
|
||||
@@ -20,6 +22,10 @@ vi.mock('./db/admin.db', () => ({
|
||||
AdminRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./db/gamification.db', () => ({
|
||||
GamificationRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('FlyerPersistenceService', () => {
|
||||
let service: FlyerPersistenceService;
|
||||
let mockLogger: Logger;
|
||||
@@ -54,6 +60,9 @@ describe('FlyerPersistenceService', () => {
|
||||
checksum: 'abc',
|
||||
status: 'processed',
|
||||
item_count: 0,
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
store_address: '123 Test St',
|
||||
} as FlyerInsert;
|
||||
|
||||
const mockItemsForDb: FlyerItemInsert[] = [];
|
||||
@@ -77,9 +86,14 @@ describe('FlyerPersistenceService', () => {
|
||||
|
||||
const mockLogActivity = vi.fn();
|
||||
// Mock the AdminRepository constructor to return an object with logActivity
|
||||
vi.mocked(AdminRepository).mockImplementation(() => ({
|
||||
logActivity: mockLogActivity,
|
||||
} as any));
|
||||
vi.mocked(AdminRepository).mockImplementation(function () {
|
||||
return { logActivity: mockLogActivity } as any;
|
||||
});
|
||||
|
||||
const mockAwardAchievement = vi.fn();
|
||||
vi.mocked(GamificationRepository).mockImplementation(function () {
|
||||
return { awardAchievement: mockAwardAchievement } as any;
|
||||
});
|
||||
|
||||
const result = await service.saveFlyer(mockFlyerData, mockItemsForDb, userId, mockLogger);
|
||||
|
||||
@@ -106,6 +120,10 @@ describe('FlyerPersistenceService', () => {
|
||||
mockLogger
|
||||
);
|
||||
|
||||
// Verify GamificationRepository usage
|
||||
expect(GamificationRepository).toHaveBeenCalledWith(mockClient);
|
||||
expect(mockAwardAchievement).toHaveBeenCalledWith(userId, 'First-Upload', mockLogger);
|
||||
|
||||
expect(result).toEqual(mockCreatedFlyer);
|
||||
});
|
||||
|
||||
@@ -118,9 +136,9 @@ describe('FlyerPersistenceService', () => {
|
||||
});
|
||||
|
||||
const mockLogActivity = vi.fn();
|
||||
vi.mocked(AdminRepository).mockImplementation(() => ({
|
||||
logActivity: mockLogActivity,
|
||||
} as any));
|
||||
vi.mocked(AdminRepository).mockImplementation(function () {
|
||||
return { logActivity: mockLogActivity } as any;
|
||||
});
|
||||
|
||||
const result = await service.saveFlyer(mockFlyerData, mockItemsForDb, userId, mockLogger);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from 'pino';
|
||||
import { withTransaction } from './db/connection.db';
|
||||
import { createFlyerAndItems } from './db/flyer.db';
|
||||
import { AdminRepository } from './db/admin.db';
|
||||
import { GamificationRepository } from './db/gamification.db';
|
||||
import type { FlyerInsert, FlyerItemInsert, Flyer } from '../types';
|
||||
|
||||
export class FlyerPersistenceService {
|
||||
@@ -35,6 +36,10 @@ export class FlyerPersistenceService {
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
// Award 'First-Upload' achievement
|
||||
const gamificationRepo = new GamificationRepository(client);
|
||||
await gamificationRepo.awardAchievement(userId, 'First-Upload', logger);
|
||||
}
|
||||
return flyer;
|
||||
});
|
||||
|
||||
@@ -321,12 +321,12 @@ describe('FlyerProcessingService', () => {
|
||||
message: 'AI model exploded',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'AI model exploded' },
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
],
|
||||
}); // This was a duplicate, fixed.
|
||||
});
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||
@@ -347,7 +347,7 @@ describe('FlyerProcessingService', () => {
|
||||
message: 'An AI quota has been exceeded. Please try again later.',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'AI model quota exceeded' },
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
@@ -417,7 +417,7 @@ describe('FlyerProcessingService', () => {
|
||||
rawData: {},
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer." },
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
@@ -477,7 +477,7 @@ describe('FlyerProcessingService', () => {
|
||||
message: 'A database operation failed. Please try again later.',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
|
||||
{ name: 'Transforming AI Data', status: 'completed', critical: true },
|
||||
{ name: 'Saving to Database', status: 'failed', critical: true, detail: 'A database operation failed. Please try again later.' },
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
beforeEach(async () => {
|
||||
const flyerRes = await getPool().query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
||||
VALUES ($1, 'admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/asdmin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
|
||||
VALUES ($1, 'admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/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')],
|
||||
@@ -292,27 +292,15 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 404 if the user to be deleted is not found', async () => {
|
||||
// Arrange: Mock the userRepo.deleteUserById to throw a NotFoundError
|
||||
const notFoundUserId = 'non-existent-user-id';
|
||||
// Arrange: Use a valid UUID that does not exist
|
||||
const notFoundUserId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const response = await request
|
||||
.delete(`/api/admin/users/${notFoundUserId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
// Assert: Check for a 400 status code because the UUID is invalid and caught by validation.
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
// Arrange: Mock the userRepo.deleteUserById to throw a generic error
|
||||
const genericUserId = 'generic-error-user-id';
|
||||
|
||||
const response = await request
|
||||
.delete(`/api/admin/users/${genericUserId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
// Assert: Check for a 400 status code because the UUID is invalid and caught by validation.
|
||||
expect(response.status).toBe(400);
|
||||
// Assert: Check for a 404 status code
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,13 +27,11 @@ const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||
mockExtractCoreData: vi.fn(),
|
||||
}));
|
||||
|
||||
// REMOVED: vi.mock('../../services/aiService.server', ...)
|
||||
// The previous mock was not effectively intercepting the singleton instance used by the worker.
|
||||
|
||||
// Mock the main DB service to allow for simulating transaction failures.
|
||||
// By default, it will use the real implementation.
|
||||
vi.mock('../../services/db/index.db', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../services/db/index.db')>();
|
||||
// Mock the connection DB service to intercept withTransaction.
|
||||
// This is crucial because FlyerPersistenceService imports directly from connection.db,
|
||||
// so mocking index.db is insufficient.
|
||||
vi.mock('../../services/db/connection.db', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../services/db/connection.db')>();
|
||||
return {
|
||||
...actual,
|
||||
withTransaction: vi.fn().mockImplementation(actual.withTransaction),
|
||||
@@ -47,11 +45,13 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const createdFilePaths: string[] = [];
|
||||
let workersModule: typeof import('../../services/workers.server');
|
||||
|
||||
const originalFrontendUrl = process.env.FRONTEND_URL;
|
||||
|
||||
beforeAll(async () => {
|
||||
// FIX: Stub FRONTEND_URL to ensure valid absolute URLs (http://...) are generated
|
||||
// for the database, satisfying the 'url_check' constraint.
|
||||
// IMPORTANT: This must run BEFORE the app is imported so workers inherit the env var.
|
||||
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||
process.env.FRONTEND_URL = 'https://example.com';
|
||||
console.log('[TEST SETUP] FRONTEND_URL stubbed to:', process.env.FRONTEND_URL);
|
||||
|
||||
// FIX: Spy on the actual singleton instance. This ensures that when the worker
|
||||
@@ -92,13 +92,17 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// 2. Restore DB Service Mock to real implementation
|
||||
// This ensures that unless a test specifically mocks a failure, the DB logic works as expected.
|
||||
const { withTransaction } = await import('../../services/db/index.db');
|
||||
const actualDb = await vi.importActual<typeof import('../../services/db/index.db')>('../../services/db/index.db');
|
||||
const { withTransaction } = await import('../../services/db/connection.db');
|
||||
// We need to get the actual implementation again to restore it
|
||||
const actualDb = await vi.importActual<typeof import('../../services/db/connection.db')>('../../services/db/connection.db');
|
||||
vi.mocked(withTransaction).mockReset();
|
||||
vi.mocked(withTransaction).mockImplementation(actualDb.withTransaction);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Restore original value
|
||||
process.env.FRONTEND_URL = originalFrontendUrl;
|
||||
|
||||
vi.unstubAllEnvs(); // Clean up env stubs
|
||||
vi.restoreAllMocks(); // Restore the AI spy
|
||||
|
||||
@@ -475,7 +479,7 @@ it(
|
||||
// Arrange: Mock the database transaction function to throw an error.
|
||||
// This is a more realistic simulation of a DB failure than mocking the inner createFlyerAndItems function.
|
||||
const dbError = new Error('DB transaction failed');
|
||||
const { withTransaction } = await import('../../services/db/index.db');
|
||||
const { withTransaction } = await import('../../services/db/connection.db');
|
||||
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||
|
||||
// Arrange: Prepare a unique flyer file for upload.
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock('../../utils/imageProcessor', async () => {
|
||||
const actual = await vi.importActual<typeof imageProcessor>('../../utils/imageProcessor');
|
||||
return {
|
||||
...actual,
|
||||
generateFlyerIcon: vi.fn(),
|
||||
generateFlyerIcon: vi.fn().mockResolvedValue('mock-icon.webp'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -203,10 +203,17 @@ describe('Gamification Flow Integration Test', () => {
|
||||
const achievementsResponse = await request
|
||||
.get('/api/achievements/me')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
const userAchievements: (UserAchievement & Achievement)[] = achievementsResponse.body;
|
||||
|
||||
// --- Assert 2: Verify the "First-Upload" achievement was awarded ---
|
||||
// The 'user_registered' achievement is awarded on creation, so we expect at least two.
|
||||
// Wait for the asynchronous achievement event to process
|
||||
await vi.waitUntil(async () => {
|
||||
const achievements = await db.gamificationRepo.getUserAchievements(testUser.user.user_id, logger);
|
||||
return achievements.length >= 2;
|
||||
}, { timeout: 5000, interval: 200 });
|
||||
|
||||
// Final assertion and retrieval
|
||||
const userAchievements = await db.gamificationRepo.getUserAchievements(testUser.user.user_id, logger);
|
||||
expect(userAchievements.length).toBeGreaterThanOrEqual(2);
|
||||
const firstUploadAchievement = userAchievements.find((ach) => ach.name === 'First-Upload');
|
||||
expect(firstUploadAchievement).toBeDefined();
|
||||
|
||||
@@ -74,10 +74,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Placeholder for future tests
|
||||
// Skipping this test as the POST /api/recipes endpoint for creation does not appear to be implemented.
|
||||
// The test currently fails with a 404 Not Found.
|
||||
it.skip('should allow an authenticated user to create a new recipe', async () => {
|
||||
it('should allow an authenticated user to create a new recipe', async () => {
|
||||
const newRecipeData = {
|
||||
name: 'My New Awesome Recipe',
|
||||
instructions: '1. Be awesome. 2. Make recipe.',
|
||||
@@ -85,7 +82,7 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
};
|
||||
|
||||
const response = await request
|
||||
.post('/api/recipes') // This endpoint does not exist, causing a 404.
|
||||
.post('/api/users/recipes')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(newRecipeData);
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const finalConfig = mergeConfig(
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
BASE_URL: 'https://example.com', // Use a standard domain to pass strict URL validation
|
||||
FRONTEND_URL: 'https://example.com',
|
||||
PORT: '3000',
|
||||
},
|
||||
// This setup script starts the backend server before tests run.
|
||||
|
||||
Reference in New Issue
Block a user