Compare commits

..

4 Commits

Author SHA1 Message Date
Gitea Actions
91e0f0c46f ci: Bump version to 0.9.16 [skip ci] 2026-01-04 05:05:33 +05:00
e6986d512b test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m38s
2026-01-03 16:04:04 -08:00
Gitea Actions
8f9c21675c ci: Bump version to 0.9.15 [skip ci] 2026-01-04 03:58:29 +05:00
7fb22cdd20 more test improvements
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m12s
2026-01-03 14:57:40 -08:00
10 changed files with 113 additions and 32 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@@ -15,8 +15,8 @@ describe('useFlyerItems Hook', () => {
const mockFlyer = createMockFlyer({ const mockFlyer = createMockFlyer({
flyer_id: 123, flyer_id: 123,
file_name: 'test-flyer.jpg', file_name: 'test-flyer.jpg',
image_url: '/test.jpg', image_url: 'http://example.com/test.jpg',
icon_url: '/icon.jpg', icon_url: 'http://example.com/icon.jpg',
checksum: 'abc', checksum: 'abc',
valid_from: '2024-01-01', valid_from: '2024-01-01',
valid_to: '2024-01-07', valid_to: '2024-01-07',

View File

@@ -72,7 +72,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
createMockFlyer({ createMockFlyer({
flyer_id: 1, flyer_id: 1,
file_name: 'flyer1.jpg', file_name: 'flyer1.jpg',
image_url: 'url1', image_url: 'http://example.com/flyer1.jpg',
item_count: 5, item_count: 5,
created_at: '2024-01-01', created_at: '2024-01-01',
}), }),

View File

@@ -225,6 +225,7 @@ describe('AI Routes (/api/ai)', () => {
// Act // Act
await supertest(authenticatedApp) await supertest(authenticatedApp)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
.field('checksum', validChecksum) .field('checksum', validChecksum)
.attach('flyerFile', imagePath); .attach('flyerFile', imagePath);
@@ -260,6 +261,7 @@ describe('AI Routes (/api/ai)', () => {
// Act // Act
await supertest(authenticatedApp) await supertest(authenticatedApp)
.post('/api/ai/upload-and-process') .post('/api/ai/upload-and-process')
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
.field('checksum', validChecksum) .field('checksum', validChecksum)
.attach('flyerFile', imagePath); .attach('flyerFile', imagePath);

View File

@@ -91,11 +91,55 @@ export class AIService {
private fs: IFileSystem; private fs: IFileSystem;
private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>; private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
private logger: Logger; private logger: Logger;
// The fallback list is ordered by preference (speed/cost vs. power).
// We try the fastest models first, then the more powerful 'pro' model as a high-quality fallback, // OPTIMIZED: Flyer Image Processing (Vision + Long Output)
// and finally the 'lite' model as a last resort. // PRIORITIES:
private readonly models = [ 'gemini-3-flash-preview','gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite','gemini-2.0-flash-001','gemini-2.0-flash','gemini-2.0-flash-exp','gemini-2.0-flash-lite-001','gemini-2.0-flash-lite', 'gemma-3-27b-it', 'gemma-3-12b-it']; // 1. Output Limit: Must be 65k+ (Gemini 2.5/3.0) to avoid cutting off data.
private readonly models_lite = ["gemma-3-4b-it", "gemma-3-2b-it", "gemma-3-1b-it"]; // 2. Intelligence: 'Pro' models handle messy layouts better.
// 3. Quota Management: 'Preview' and 'Exp' models are added as fallbacks to tap into separate rate limits.
private readonly models = [
// --- TIER A: The Happy Path (Fast & Stable) ---
'gemini-2.5-flash', // Primary workhorse. 65k output.
'gemini-2.5-flash-lite', // Cost-saver. 65k output.
// --- TIER B: The Heavy Lifters (Complex Layouts) ---
'gemini-2.5-pro', // High IQ for messy flyers. 65k output.
// --- TIER C: Separate Quota Buckets (Previews) ---
'gemini-3-flash-preview', // Newer/Faster. Separate 'Preview' quota. 65k output.
'gemini-3-pro-preview', // High IQ. Separate 'Preview' quota. 65k output.
// --- TIER D: Experimental Buckets (High Capacity) ---
'gemini-exp-1206', // Excellent reasoning. Separate 'Experimental' quota. 65k output.
// --- TIER E: Last Resorts (Lower Capacity/Local) ---
'gemma-3-27b-it', // Open model fallback.
'gemini-2.0-flash-exp' // Exp fallback. WARNING: 8k output limit. Good for small flyers only.
];
// OPTIMIZED: Simple Text Tasks (Recipes, Shopping Lists, Summaries)
// PRIORITIES:
// 1. Cost/Speed: These tasks are simple.
// 2. Output Limit: The 8k limit of Gemini 2.0 is perfectly fine here.
private readonly models_lite = [
// --- Best Value (Smart + Cheap) ---
"gemini-2.5-flash-lite", // Current generation efficiency king.
// --- The "Recycled" Gemini 2.0 Models (Perfect for Text) ---
"gemini-2.0-flash-lite-001", // Extremely cheap, very capable for text.
"gemini-2.0-flash-001", // Smarter than Lite, good for complex recipes.
// --- Open Models (Good for simple categorization) ---
"gemma-3-12b-it", // Solid reasoning for an open model.
"gemma-3-4b-it", // Very fast.
// --- Quota Fallbacks (Experimental/Preview) ---
"gemini-2.0-flash-exp", // Use this separate quota bucket if others are exhausted.
// --- Edge/Nano Models (Simple string manipulation only) ---
"gemma-3n-e4b-it", // Corrected name from JSON
"gemma-3n-e2b-it" // Corrected name from JSON
];
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) { constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
this.logger = logger; this.logger = logger;
@@ -865,6 +909,8 @@ async enqueueFlyerProcessing(
const itemsArray = Array.isArray(rawItems) ? rawItems : typeof rawItems === 'string' ? JSON.parse(rawItems) : []; const itemsArray = Array.isArray(rawItems) ? rawItems : typeof rawItems === 'string' ? JSON.parse(rawItems) : [];
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({ const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
...item, ...item,
// Ensure price_display is never null to satisfy database constraints.
price_display: item.price_display ?? '',
master_item_id: item.master_item_id === null ? undefined : item.master_item_id, master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
quantity: item.quantity ?? 1, quantity: item.quantity ?? 1,
view_count: 0, view_count: 0,

View File

@@ -596,7 +596,7 @@ describe('Shopping DB Service', () => {
const mockReceipt = { const mockReceipt = {
receipt_id: 1, receipt_id: 1,
user_id: 'user-1', user_id: 'user-1',
receipt_image_url: 'url', receipt_image_url: 'http://example.com/receipt.jpg',
status: 'pending', status: 'pending',
}; };
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] }); mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });

View File

@@ -83,16 +83,14 @@ describe('FlyerProcessingService', () => {
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({ vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
flyerData: { flyerData: {
file_name: 'test.jpg', file_name: 'test.jpg',
image_url: 'test.jpg', image_url: 'http://example.com/test.jpg',
icon_url: 'icon.webp', icon_url: 'http://example.com/icon.webp',
checksum: 'checksum-123',
store_name: 'Mock Store', store_name: 'Mock Store',
// Add required fields for FlyerInsert type // Add required fields for FlyerInsert type
status: 'processed', status: 'processed',
item_count: 0, item_count: 0,
valid_from: '2024-01-01', valid_from: '2024-01-01',
valid_to: '2024-01-07', valid_to: '2024-01-07',
store_address: '123 Mock St',
} as FlyerInsert, // Cast is okay here as it's a mock value } as FlyerInsert, // Cast is okay here as it's a mock value
itemsForDb: [], itemsForDb: [],
}); });
@@ -151,7 +149,7 @@ describe('FlyerProcessingService', () => {
flyer: createMockFlyer({ flyer: createMockFlyer({
flyer_id: 1, flyer_id: 1,
file_name: 'test.jpg', file_name: 'test.jpg',
image_url: 'test.jpg', image_url: 'http://example.com/test.jpg',
item_count: 1, item_count: 1,
}), }),
items: [], items: [],

View File

@@ -103,6 +103,31 @@ export class FlyerProcessingService {
stages[2].status = 'completed'; stages[2].status = 'completed';
await job.updateProgress({ stages }); await job.updateProgress({ stages });
// Sanitize URLs before database insertion to prevent constraint violations,
// especially in test environments where a base URL might not be configured.
const sanitizeUrl = (url: string): string => {
if (url.startsWith('http')) {
return url;
}
// If it's a relative path, build an absolute URL.
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(
`URL Sanitization: FRONTEND_URL/BASE_URL is invalid ('${baseUrl}'). Falling back to ${fallbackUrl}.`,
);
}
baseUrl = fallbackUrl;
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
return `${baseUrl}${url.startsWith('/') ? url : `/${url}`}`;
};
flyerData.image_url = sanitizeUrl(flyerData.image_url);
flyerData.icon_url = sanitizeUrl(flyerData.icon_url);
// Stage 4: Save to Database // Stage 4: Save to Database
stages[3].status = 'in-progress'; stages[3].status = 'in-progress';
await job.updateProgress({ stages }); await job.updateProgress({ stages });

View File

@@ -1,5 +1,5 @@
// src/tests/e2e/auth.e2e.test.ts // src/tests/e2e/auth.e2e.test.ts
import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as apiClient from '../../services/apiClient'; import * as apiClient from '../../services/apiClient';
import { cleanupDb } from '../utils/cleanup'; import { cleanupDb } from '../utils/cleanup';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers'; import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
@@ -13,15 +13,19 @@ describe('Authentication E2E Flow', () => {
let testUser: UserProfile; let testUser: UserProfile;
const createdUserIds: string[] = []; const createdUserIds: string[] = [];
beforeAll(async () => { beforeAll(async () => {
// Create a user that can be used for login-related tests in this suite. // Create a user that can be used for login-related tests in this suite.
const { user } = await createAndLoginUser({ try {
email: `e2e-login-user-${Date.now()}@example.com`, const { user } = await createAndLoginUser({
fullName: 'E2E Login User', email: `e2e-login-user-${Date.now()}@example.com`,
// E2E tests use apiClient which doesn't need the `request` object. fullName: 'E2E Login User',
}); });
testUser = user; testUser = user;
createdUserIds.push(user.user.user_id); createdUserIds.push(user.user.user_id);
} catch (error) {
console.error('[FATAL] Setup failed. DB might be down.', error);
throw error;
}
}); });
afterAll(async () => { afterAll(async () => {
@@ -70,7 +74,7 @@ describe('Authentication E2E Flow', () => {
const firstResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User'); const firstResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
const firstData = await firstResponse.json(); const firstData = await firstResponse.json();
expect(firstResponse.status).toBe(201); expect(firstResponse.status).toBe(201);
createdUserIds.push(firstData.userprofile.user.user_id); // Add for cleanup createdUserIds.push(firstData.userprofile.user.user_id);
// Act 2: Attempt to register the same user again // Act 2: Attempt to register the same user again
const secondResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User'); const secondResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
@@ -186,17 +190,23 @@ describe('Authentication E2E Flow', () => {
} }
await new Promise((resolve) => setTimeout(resolve, 1000)); 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); expect(loginSuccess, 'User should be able to log in after registration. DB might be lagging.').toBe(true);
// Act 1: Request a password reset. // 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); const forgotResponse = await apiClient.requestPasswordReset(email);
const forgotData = await forgotResponse.json(); const forgotData = await forgotResponse.json();
const resetToken = forgotData.token; const resetToken = forgotData.token;
// --- DEBUG SECTION FOR FAILURE ---
if (!resetToken) {
console.error(' [DEBUG FAILURE] Token missing in response:', JSON.stringify(forgotData, null, 2));
console.error(' [DEBUG FAILURE] This usually means the backend hit a DB error or is not in NODE_ENV=test mode.');
}
// ---------------------------------
// Assert 1: Check that we received a token. // Assert 1: Check that we received a token.
expect(forgotResponse.status).toBe(200); expect(forgotResponse.status).toBe(200);
expect(resetToken).toBeDefined(); expect(resetToken, 'Backend returned 200 but no token. Check backend logs for "Connection terminated" errors.').toBeDefined();
expect(resetToken).toBeTypeOf('string'); expect(resetToken).toBeTypeOf('string');
// Act 2: Use the token to set a new password. // Act 2: Use the token to set a new password.
@@ -208,7 +218,7 @@ describe('Authentication E2E Flow', () => {
expect(resetResponse.status).toBe(200); expect(resetResponse.status).toBe(200);
expect(resetData.message).toBe('Password has been reset successfully.'); expect(resetData.message).toBe('Password has been reset successfully.');
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change. // Act 3: Log in with the NEW password
const loginResponse = await apiClient.loginUser(email, newPassword, false); const loginResponse = await apiClient.loginUser(email, newPassword, false);
const loginData = await loginResponse.json(); const loginData = await loginResponse.json();