Files
flyer-crawler.projectium.com/src/tests/integration/gamification.integration.test.ts
Torben Sorensen 7867abc5bc
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 24m23s
fix the dang integration tests
2026-01-07 01:42:43 -08:00

305 lines
14 KiB
TypeScript

// src/tests/integration/gamification.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import path from 'path';
import fs from 'node:fs/promises';
import { getPool } from '../../services/db/connection.db';
import { createAndLoginUser, getTestBaseUrl } 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 { poll } from '../utils/poll';
import type {
UserProfile,
UserAchievement,
LeaderboardUser,
Achievement,
ExtractedFlyerItem,
} from '../../types';
import type { Flyer } from '../../types';
import { cleanupFiles } from '../utils/cleanupFiles';
import { aiService } from '../../services/aiService.server';
/**
* @vitest-environment node
*/
const { mockExtractCoreData } = vi.hoisted(() => ({
mockExtractCoreData: vi.fn(),
}));
// 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().mockResolvedValue('mock-icon.webp'),
};
});
describe('Gamification Flow Integration Test', () => {
let request: ReturnType<typeof supertest>;
let testUser: UserProfile;
let authToken: string;
const createdFlyerIds: number[] = [];
const createdFilePaths: string[] = [];
const createdStoreIds: number[] = [];
let workersModule: typeof import('../../services/workers.server');
beforeAll(async () => {
// Stub environment variables for URL generation in the background worker.
// This needs to be in beforeAll to ensure it's set before any code that might use it is imported.
vi.stubEnv('FRONTEND_URL', 'https://example.com');
// Spy on the actual singleton instance. This ensures that when the worker
// imports 'aiService', it gets the instance we are controlling here.
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockImplementation(mockExtractCoreData);
// Import workers to start them IN-PROCESS.
// This ensures they run in the same memory space as our mocks.
workersModule = await import('../../services/workers.server');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create a new user specifically for this test suite to ensure a clean slate.
({ user: testUser, token: authToken } = await createAndLoginUser({
email: `gamification-user-${Date.now()}@example.com`,
fullName: 'Gamification Tester',
request,
}));
// 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: [
{
item: 'Integration Test Milk',
price_display: '$4.99',
price_in_cents: 499,
quantity: '2L',
category_name: 'Dairy',
},
],
});
});
afterAll(async () => {
vi.unstubAllEnvs();
vi.restoreAllMocks(); // Restore the AI spy
await cleanupDb({
userIds: testUser ? [testUser.user.user_id] : [],
flyerIds: createdFlyerIds,
storeIds: createdStoreIds,
});
await cleanupFiles(createdFilePaths);
// Clean up workers and Redis connection to prevent tests from hanging.
if (workersModule) {
await workersModule.closeWorkers();
}
// Close the shared redis connection used by the workers/queues
const { connection } = await import('../../services/redis.server');
await connection.quit();
});
it(
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
async () => {
// --- 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(Date.now().toString())]);
const uniqueFileName = `gamification-test-flyer-${Date.now()}.jpg`;
const mockImageFile = new File([new Uint8Array(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));
const iconFileName = `icon-${path.parse(uniqueFileName).name}.webp`;
createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName));
// --- Act 1: Upload the flyer to trigger the background job ---
const testBaseUrl = 'https://example.com';
console.error('--------------------------------------------------------------------------------');
console.error('[TEST DEBUG] STARTING UPLOAD STEP');
console.error(`[TEST DEBUG] Env FRONTEND_URL: "${process.env.FRONTEND_URL}"`);
console.error(`[TEST DEBUG] Sending baseUrl field: "${testBaseUrl}"`);
console.error('--------------------------------------------------------------------------------');
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.field('checksum', checksum)
.field('baseUrl', testBaseUrl)
.attach('flyerFile', uniqueContent, uniqueFileName);
console.error('--------------------------------------------------------------------------------');
console.error(`[TEST DEBUG] Upload Response Status: ${uploadResponse.status}`);
console.error(`[TEST DEBUG] Upload Response Body: ${JSON.stringify(uploadResponse.body, null, 2)}`);
console.error('--------------------------------------------------------------------------------');
const { jobId } = uploadResponse.body;
expect(jobId).toBeTypeOf('string');
console.error(`[TEST DEBUG] Job ID received: ${jobId}`);
// --- Act 2: Poll for job completion using the new utility ---
const jobStatus = await poll(
async () => {
const statusResponse = await request
.get(`/api/ai/jobs/${jobId}/status`)
.set('Authorization', `Bearer ${authToken}`);
console.error(`[TEST DEBUG] Polling status for ${jobId}: ${statusResponse.body?.state}`);
return statusResponse.body;
},
(status) => status.state === 'completed' || status.state === 'failed',
{ timeout: 180000, interval: 3000, description: 'gamification flyer processing' },
);
if (!jobStatus) {
console.error('[DEBUG] Gamification test job timed out: No job status received.');
throw new Error('Gamification test job timed out: No job status received.');
}
console.error('--------------------------------------------------------------------------------');
console.error('[TEST DEBUG] Final Job Status Object:', JSON.stringify(jobStatus, null, 2));
if (jobStatus.state === 'failed') {
console.error(`[TEST DEBUG] Job Failed Reason: ${jobStatus.failedReason}`);
// If there is a progress object with error details, log it
if (jobStatus.progress) {
console.error(`[TEST DEBUG] Job Progress/Error Details:`, JSON.stringify(jobStatus.progress, null, 2));
}
}
console.error('--------------------------------------------------------------------------------');
// --- Assert 1: Verify the job completed successfully ---
if (jobStatus?.state === 'failed') {
console.error('[DEBUG] Gamification test job failed:', jobStatus.failedReason);
}
expect(jobStatus?.state).toBe('completed');
const flyerId = jobStatus?.returnValue?.flyerId;
expect(flyerId).toBeTypeOf('number');
createdFlyerIds.push(flyerId); // Track for cleanup
// --- Assert 1.5: Verify the flyer was saved with the correct original filename ---
const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
expect(savedFlyer).toBeDefined();
expect(savedFlyer?.file_name).toBe(uniqueFileName);
// Also add the final processed image path to the cleanup list.
// This is important because JPEGs are re-processed to strip EXIF data, creating a new file.
const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url));
createdFilePaths.push(savedImagePath);
// --- Act 3: Fetch the user's achievements ---
const achievementsResponse = await request
.get('/api/achievements/me')
.set('Authorization', `Bearer ${authToken}`);
// --- 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();
expect(firstUploadAchievement?.points_value).toBeGreaterThan(0);
// --- Act 4: Fetch the leaderboard ---
const leaderboardResponse = await request.get('/api/achievements/leaderboard');
const leaderboard: LeaderboardUser[] = leaderboardResponse.body;
// --- Assert 3: Verify the user is on the leaderboard with points ---
const userOnLeaderboard = leaderboard.find((u) => u.user_id === testUser.user.user_id);
expect(userOnLeaderboard).toBeDefined();
// The user should have points from 'user_registered' and 'First-Upload'.
// We check that the points are greater than or equal to the points from the upload achievement.
expect(Number(userOnLeaderboard?.points)).toBeGreaterThanOrEqual(
firstUploadAchievement!.points_value,
);
},
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 ---
// 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([new Uint8Array(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, price_display: '$2.50' }],
},
};
// --- 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(newFlyer.image_url);
expect(savedFlyer.icon_url).to.equal(newFlyer.icon_url);
const expectedBaseUrl = 'https://example.com';
expect(newFlyer.image_url).toContain(`${expectedBaseUrl}/flyer-images/`);
});
});
});