Refactor tests and services for improved type safety and error handling
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m24s

- Updated FlyerCorrectionTool tests to remove unused error notification.
- Enhanced ProfileManager tests and component to include points in user profile.
- Fixed error handling in ProfileManager to correctly log error messages.
- Adjusted AI routes tests to ensure proper mocking and added missing properties in mock responses.
- Refined AI routes to improve error message extraction and payload handling.
- Cleaned up gamification routes tests by removing unnecessary parameters.
- Simplified public routes by removing unused parameters in async handlers.
- Improved system routes tests to handle exec command callbacks more robustly.
- Updated user routes tests to remove unnecessary middleware parameters.
- Enhanced AI API client tests to use File objects for simulating uploads.
- Modified AI service tests to improve type safety and mock implementations.
- Refined database service tests to ensure proper type assertions and mock setups.
- Updated express type definitions for better clarity and organization.
- Cleaned up notification service tests to mock local re-exports instead of library directly.
This commit is contained in:
2025-12-04 12:46:12 -08:00
parent 2026cb1584
commit 5c214fb6f4
26 changed files with 191 additions and 128 deletions

View File

@@ -4,7 +4,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { FlyerCorrectionTool } from './FlyerCorrectionTool';
import * as aiApiClient from '../services/aiApiClient';
import { notifyError, notifySuccess } from '../services/notificationService';
import { notifySuccess } from '../services/notificationService';
// Mock dependencies
vi.mock('../services/aiApiClient');

View File

@@ -31,6 +31,7 @@ const authenticatedProfile = {
full_name: 'Test User',
avatar_url: 'http://example.com/avatar.png',
role: 'user' as const,
points: 100,
preferences: {
darkMode: false,
unitSystem: 'imperial' as const,
@@ -345,7 +346,7 @@ describe('ProfileManager Authentication Flows', () => {
{...defaultProps}
user={{ user_id: '123', email: 'authenticated@example.com' }}
authStatus="AUTHENTICATED"
profile={{ user_id: '123', full_name: 'Test User', role: 'user' }}
profile={{ user_id: '123', full_name: 'Test User', role: 'user', points: 100 }}
/>
);

View File

@@ -160,7 +160,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
setAddress(prev => ({ ...prev, latitude: lat, longitude: lng }));
toast.success('Address re-geocoded successfully!');
} catch (error) {
toast.error('Failed to re-geocode address: ${error.message}.');
toast.error(`Failed to re-geocode address: ${(error as Error).message}.`);
} finally {
setIsGeocoding(false);
}
@@ -192,7 +192,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
setAddress(prev => ({ ...prev, latitude: lat, longitude: lng }));
toast.success('Address geocoded successfully!');
} catch (error) {
toast.error('Failed to geocode address: ${error.message}.');
toast.error(`Failed to geocode address: ${(error as Error).message}.`);
} finally {
setIsGeocoding(false);
}

View File

@@ -5,15 +5,15 @@ import express, { type Request, type Response, type NextFunction } from 'express
import path from 'node:path';
import aiRouter from './ai';
import * as aiService from '../services/aiService.server';
import * as db from '../services/db';
import { UserProfile } from '../types';
import * as db from '../services/db';
// Mock the AI service to avoid making real AI calls
vi.mock('../services/aiService.server');
const mockedAiService = aiService as Mocked<typeof aiService>;
// Mock the entire db service, as the /flyers/process route uses it.
vi.mock('../services/db');
vi.mock('../services/db'); // Keep this mock, as db is used by the route
const mockedDb = db as Mocked<typeof db>;
// Mock the logger to keep test output clean
@@ -30,7 +30,7 @@ vi.mock('../services/logger.server', () => ({
vi.mock('./passport', () => ({
// Mock the default export for passport.authenticate
default: {
authenticate: vi.fn((_strategy: string, _options: object) => (_req: Request, _res: Response, next: NextFunction) => {
authenticate: vi.fn(() => (_req: Request, _res: Response, next: NextFunction) => {
next(); // Immediately pass through for testing purposes
}),
},
@@ -53,7 +53,7 @@ describe('AI Routes (/api/ai)', () => {
// Default mock for passport.authenticate to simulate an unauthenticated request.
// This will be overridden in tests that require an authenticated user.
mockedAuthenticate.mockImplementation(
() => (req: Request, res: Response, next: NextFunction) => {
() => (req: Request, res: Response) => {
res.status(401).json({ message: 'Unauthorized' });
});
});
@@ -138,7 +138,13 @@ describe('AI Routes (/api/ai)', () => {
};
mockedDb.findFlyerByChecksum.mockResolvedValue(undefined);
mockedDb.createFlyerAndItems.mockResolvedValue({ flyer_id: 2, created_at: new Date().toISOString(), file_name: partialPayload.originalFileName, image_url: '/flyer-images/flyer2.jpg' } as any);
mockedDb.createFlyerAndItems.mockResolvedValue({
flyer_id: 2,
created_at: new Date().toISOString(),
file_name: partialPayload.originalFileName,
image_url: '/flyer-images/flyer2.jpg',
item_count: 0, // Add missing required property
});
const response = await supertest(app)
.post('/api/ai/flyers/process')
@@ -163,7 +169,13 @@ describe('AI Routes (/api/ai)', () => {
};
mockedDb.findFlyerByChecksum.mockResolvedValue(undefined);
mockedDb.createFlyerAndItems.mockResolvedValue({ flyer_id: 3, created_at: new Date().toISOString(), file_name: payloadNoStore.originalFileName, image_url: '/flyer-images/flyer3.jpg' } as any);
mockedDb.createFlyerAndItems.mockResolvedValue({
flyer_id: 3,
created_at: new Date().toISOString(),
file_name: payloadNoStore.originalFileName,
image_url: '/flyer-images/flyer3.jpg',
item_count: 0, // Add missing required property
});
const response = await supertest(app)
.post('/api/ai/flyers/process')

View File

@@ -9,18 +9,23 @@ import * as db from '../services/db';
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
import { generateFlyerIcon } from '../utils/imageProcessor';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { AsyncRequestHandler } from '../types/express';
import { UserProfile, ExtractedCoreData } from '../types';
import { flyerQueue } from '../services/queueService.server';
const router = Router();
interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
checksum?: string;
originalFileName?: string;
extractedData?: Partial<ExtractedCoreData>;
data?: FlyerProcessPayload; // For nested data structures
}
// Helper to safely extract an error message from unknown `catch` values.
const errMsg = (e: unknown) => {
if (!e) return String(e);
if (typeof e === 'string') return e;
if (typeof e === 'object' && 'message' in e) return String((e as any).message);
return String(e);
if (e instanceof Error) return e.message;
if (typeof e === 'object' && e !== null && 'message' in e) return String((e as { message: unknown }).message);
return String(e || 'An unknown error occurred.');
};
// --- Multer Configuration for File Uploads ---
@@ -47,8 +52,6 @@ const diskStorage = multer.diskStorage({
}
}
});
// 2. Memory storage for endpoints that only need to analyze the file in memory without saving it.
const memoryStorage = multer.memoryStorage();
const uploadToDisk = multer({ storage: diskStorage });
@@ -165,26 +168,26 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
logger.debug('[API /ai/flyers/process] file present:', !!req.file);
// Try several ways to obtain the payload so we are tolerant to client variations.
let parsed: any = {};
let extractedData: any = {};
let parsed: FlyerProcessPayload = {};
let extractedData: Partial<ExtractedCoreData> = {};
try {
// If the client sent a top-level `data` field (stringified JSON), parse it.
if (req.body && (req.body.data || req.body.extractedData)) {
const raw = (req.body.data ?? req.body.extractedData) as any;
const raw = (req.body.data ?? req.body.extractedData);
logger.debug('[API /ai/flyers/process] raw extractedData type:', typeof raw, 'length:', raw && raw.length ? raw.length : 0);
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign', { error: errMsg(err) });
parsed = typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw;
parsed = (typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw) as FlyerProcessPayload;
}
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
extractedData = parsed.extractedData ?? parsed;
extractedData = parsed.extractedData ?? (parsed as Partial<ExtractedCoreData>);
} else {
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
try {
parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
} catch (err) {
} catch (err) { // eslint-disable-line
logger.warn('[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object', { error: errMsg(err) });
parsed = req.body || {};
}
@@ -195,14 +198,14 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
extractedData = inner.extractedData ?? inner;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to parse parsed.data; falling back', { error: errMsg(err) });
extractedData = parsed.data as any;
extractedData = parsed.data as Partial<ExtractedCoreData>;
}
} else if (parsed.extractedData) {
extractedData = parsed.extractedData;
} else {
// Assume the body itself is the extracted data if it looks like it (has items or store_name keys)
if (parsed.items || parsed.store_name || parsed.valid_from) {
extractedData = parsed;
if ('items' in parsed || 'store_name' in parsed || 'valid_from' in parsed) {
extractedData = parsed as Partial<ExtractedCoreData>;
} else {
extractedData = {};
}
@@ -215,7 +218,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
}
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? undefined;
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
const originalFileName = parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
const user = req.user as UserProfile | undefined;
@@ -256,7 +259,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
const flyerData = {
file_name: originalFileName,
image_url: req.file.filename, // Store only the filename
icon_url: iconUrl, // Add the new icon URL
icon_url: iconUrl,
checksum: checksum,
// Use normalized store name (fallback applied above).
store_name: storeName,
@@ -390,7 +393,7 @@ router.post(
'/rescan-area',
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('image'),
async (req, res, next) => {
async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
@@ -413,7 +416,7 @@ router.post(
res.status(200).json(result);
} catch (error) {
logger.error('Error in /api/ai/rescan-area endpoint:', { error });
next(error);
res.status(500).json({ message: (error as Error).message || 'An unexpected error occurred during rescan.' });
}
}
);

View File

@@ -32,7 +32,6 @@ vi.mock('./passport', () => ({
isAdmin: vi.fn(),
}));
import passport from './passport';
import { isAdmin } from './passport'; // Keep this for isAdmin
const mockedIsAdmin = vi.mocked(isAdmin);
@@ -58,10 +57,10 @@ describe('Gamification Routes (/api/achievements)', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock for unauthenticated user for protected routes
mockAuthMiddleware = (req: Request, res: Response, next: NextFunction) => {
mockAuthMiddleware = (req: Request, res: Response) => {
res.status(401).json({ message: 'Unauthorized' });
};
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => {
mockedIsAdmin.mockImplementation((req: Request, res: Response) => {
res.status(403).json({ message: 'Forbidden' });
});
});

View File

@@ -1,10 +1,9 @@
// src/routes/gamification.ts
import express, { Request, Response, NextFunction } from 'express';
import express from 'express';
import passport, { isAdmin } from './passport';
import { getAllAchievements, getUserAchievements, awardAchievement, getLeaderboard } from '../services/db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { AsyncRequestHandler } from '../types/express';
const router = express.Router();

View File

@@ -2,11 +2,17 @@
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { Request, Response, NextFunction } from 'express';
// Define a type for the JWT verify callback function for type safety.
type VerifyCallback = (payload: { user_id: string }, done: (error: Error | null, user?: object | false) => void) => Promise<void>;
// FIX: Use vi.hoisted to declare variables that need to be accessed inside vi.mock
const { verifyCallbackWrapper } = vi.hoisted(() => {
return {
// We use a wrapper object to hold the callback reference
verifyCallbackWrapper: { callback: null as any }
// Initialize with a more specific type instead of `any`.
verifyCallbackWrapper: {
callback: null as VerifyCallback | null
}
};
});
@@ -64,7 +70,10 @@ describe('Passport Configuration', () => {
const done = vi.fn();
// Act: Invoke the captured callback from the wrapper
await verifyCallbackWrapper.callback(jwtPayload, done);
// Use a non-null assertion `!` because we know the mock setup populates it.
if (verifyCallbackWrapper.callback) {
await verifyCallbackWrapper.callback(jwtPayload, done);
}
// Assert
expect(mockedDb.findUserProfileById).toHaveBeenCalledWith('user-123');
@@ -78,7 +87,9 @@ describe('Passport Configuration', () => {
const done = vi.fn();
// Act
await verifyCallbackWrapper.callback(jwtPayload, done);
if (verifyCallbackWrapper.callback) {
await verifyCallbackWrapper.callback(jwtPayload, done);
}
// Assert
expect(done).toHaveBeenCalledWith(null, false);
@@ -92,7 +103,9 @@ describe('Passport Configuration', () => {
const done = vi.fn();
// Act
await verifyCallbackWrapper.callback(jwtPayload, done);
if (verifyCallbackWrapper.callback) {
await verifyCallbackWrapper.callback(jwtPayload, done);
}
// Assert
expect(done).toHaveBeenCalledWith(dbError, false);
@@ -114,7 +127,13 @@ describe('Passport Configuration', () => {
it('should call next() if user has "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
user: { role: 'admin' },
// Create a complete, type-safe mock UserProfile object.
user: {
user_id: 'admin-id',
role: 'admin',
points: 100,
user: { user_id: 'admin-id', email: 'admin@test.com' }
},
};
// Act
@@ -128,7 +147,12 @@ describe('Passport Configuration', () => {
it('should return 403 Forbidden if user does not have "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
user: { role: 'user' },
user: {
user_id: 'user-id',
role: 'user',
points: 50,
user: { user_id: 'user-id', email: 'user@test.com' }
},
};
// Act
@@ -207,13 +231,27 @@ describe('Passport Configuration', () => {
});
it('should call next() if user has "admin" role', () => {
const mockReq: Partial<Request> = { user: { role: 'admin' } };
const mockReq: Partial<Request> = {
user: {
user_id: 'admin-id',
role: 'admin',
points: 100,
user: { user_id: 'admin-id', email: 'admin@test.com' }
}
};
isAdmin(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should return 403 Forbidden if user does not have "admin" role', () => {
const mockReq: Partial<Request> = { user: { role: 'user' } };
const mockReq: Partial<Request> = {
user: {
user_id: 'user-id',
role: 'user',
points: 50,
user: { user_id: 'user-id', email: 'user@test.com' }
}
};
isAdmin(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(403);
});

View File

@@ -1,10 +1,8 @@
// src/routes/public.ts
import { Router, Request, Response, NextFunction } from 'express';
import { Router, Request, Response } from 'express';
import * as db from '../services/db';
import { logger } from '../services/logger.server';
import fs from 'fs/promises';
import passport from 'passport';
import { AsyncRequestHandler } from '../types/express';
const router = Router();
@@ -131,7 +129,7 @@ router.get('/recipes/by-sale-percentage', async (req, res, next) => {
}
});
router.get('/recipes/by-sale-ingredients', async (req, res, next) => {
router.get('/recipes/by-sale-ingredients', async (req, res) => {
const minIngredientsStr = req.query.minIngredients as string || '3';
const minIngredients = parseInt(minIngredientsStr, 10);
@@ -142,7 +140,7 @@ router.get('/recipes/by-sale-ingredients', async (req, res, next) => {
res.json(recipes);
});
router.get('/recipes/by-ingredient-and-tag', async (req, res, next) => {
router.get('/recipes/by-ingredient-and-tag', async (req, res) => {
const { ingredient, tag } = req.query;
if (!ingredient || !tag) {
return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' });
@@ -151,7 +149,7 @@ router.get('/recipes/by-ingredient-and-tag', async (req, res, next) => {
res.json(recipes);
});
router.get('/stats/most-frequent-sales', async (req, res, next) => {
router.get('/stats/most-frequent-sales', async (req, res) => {
const daysStr = req.query.days as string || '30';
const limitStr = req.query.limit as string || '10';
@@ -169,18 +167,18 @@ router.get('/stats/most-frequent-sales', async (req, res, next) => {
res.json(items);
});
router.get('/recipes/:recipeId/comments', async (req, res, next) => {
router.get('/recipes/:recipeId/comments', async (req, res) => {
const recipeId = parseInt(req.params.recipeId, 10);
const comments = await db.getRecipeComments(recipeId);
res.json(comments);
});
router.get('/dietary-restrictions', async (req, res, next) => {
router.get('/dietary-restrictions', async (req, res) => {
const restrictions = await db.getDietaryRestrictions();
res.json(restrictions);
});
router.get('/appliances', async (req, res, next) => {
router.get('/appliances', async (req, res) => {
const appliances = await db.getAppliances();
res.json(appliances);
});

View File

@@ -3,14 +3,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import type { ExecException, ChildProcess } from 'child_process';
import { ExecOptions } from 'child_process';
import systemRouter from './system';
// Define a type for the exec callback to avoid using `any`.
type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void;
// FIX: Add default export to child_process mock
vi.mock('child_process', () => {
const mockExec = vi.fn((command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void) => {
if (callback) callback(null, '', '');
return {} as ChildProcess;
});
// The mock now handles both overloads of exec without using `any`.
const mockExec = vi.fn();
return {
exec: mockExec,
default: { exec: mockExec },
@@ -51,11 +53,14 @@ describe('System Routes (/api/system)', () => {
`;
// The `exec` callback receives (error, stdout, stderr). For success, error is null.
// We must match the overloaded signature of `exec`. The second argument can be options or the callback.
vi.mocked(exec).mockImplementation(((command: string, options: any, callback: any) => {
const cb = typeof options === 'function' ? options : callback;
cb?.(null, pm2OnlineOutput, '');
// By using `...args: any[]`, we create a generic mock that can handle all overloads.
vi.mocked(exec).mockImplementation((...args: any[]) => {
// The callback is always the last function argument.
const callback = args.find(arg => typeof arg === 'function') as ExecCallback;
// For this test, we simulate success by calling the callback with no error.
callback(null, pm2OnlineOutput, '');
return {} as ChildProcess; // Return a dummy child process object
}) as any);
});
// Act
const response = await supertest(app).get('/api/system/pm2-status');
@@ -72,11 +77,11 @@ describe('System Routes (/api/system)', () => {
const pm2StoppedOutput = `
│ status │ stopped │
`;
vi.mocked(exec).mockImplementation(((command: string, options: any, callback: any) => {
const cb = typeof options === 'function' ? options : callback;
cb?.(null, pm2StoppedOutput, '');
vi.mocked(exec).mockImplementation((...args: any[]) => {
const callback = args.find(arg => typeof arg === 'function') as ExecCallback;
callback(null, pm2StoppedOutput, '');
return {} as ChildProcess;
}) as any);
});
// Act
const response = await supertest(app).get('/api/system/pm2-status');
@@ -89,11 +94,11 @@ describe('System Routes (/api/system)', () => {
it('should return success: false when pm2 process does not exist', async () => {
// Arrange: Simulate the error and stdout when a process is not found.
const processNotFoundOutput = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
vi.mocked(exec).mockImplementation(((command: string, options: any, callback: any) => {
const cb = typeof options === 'function' ? options : callback;
cb?.(new Error('Command failed') as ExecException, processNotFoundOutput, '');
vi.mocked(exec).mockImplementation((...args: any[]) => {
const callback = args.find(arg => typeof arg === 'function') as ExecCallback;
callback(new Error('Command failed') as ExecException, processNotFoundOutput, '');
return {} as ChildProcess;
}) as any);
});
// Act
const response = await supertest(app).get('/api/system/pm2-status');
@@ -105,11 +110,11 @@ describe('System Routes (/api/system)', () => {
it('should return 500 on a generic exec error', async () => {
// Arrange: Simulate a generic failure of the `exec` command.
vi.mocked(exec).mockImplementation(((command: string, options: any, callback: any) => {
const cb = typeof options === 'function' ? options : callback;
cb?.(new Error('Generic exec error') as ExecException, '', 'Some stderr output');
vi.mocked(exec).mockImplementation((...args: any[]) => {
const callback = args.find(arg => typeof arg === 'function') as ExecCallback;
callback(new Error('Generic exec error') as ExecException, '', 'Some stderr output');
return {} as ChildProcess;
}) as any);
});
// Act
const response = await supertest(app).get('/api/system/pm2-status');
@@ -123,11 +128,11 @@ describe('System Routes (/api/system)', () => {
// Arrange: Simulate a scenario where the command writes to stderr but doesn't
// produce a formal error object for the callback's first argument.
const stderrMessage = 'A non-fatal warning or configuration issue.';
vi.mocked(exec).mockImplementation(((command: string, options: any, callback: any) => {
const cb = typeof options === 'function' ? options : callback;
cb?.(null, '', stderrMessage);
vi.mocked(exec).mockImplementation((...args: any[]) => {
const callback = args.find(arg => typeof arg === 'function') as ExecCallback;
callback(null, '', stderrMessage);
return {} as ChildProcess;
}) as any);
});
// Act
const response = await supertest(app).get('/api/system/pm2-status');

View File

@@ -53,7 +53,7 @@ describe('User Routes (/api/users)', () => {
vi.clearAllMocks();
// Default authentication state: Unauthorized
mockAuthMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
mockAuthMiddleware = (req: express.Request, res: express.Response) => {
res.status(401).json({ message: 'Unauthorized' });
};
});

View File

@@ -1,5 +1,5 @@
// src/routes/user.ts
import express, { Request, Response, NextFunction } from 'express';
import express, { Request, Response } from 'express';
import passport from './passport';
import multer from 'multer';
import path from 'path';
@@ -9,7 +9,6 @@ import zxcvbn from 'zxcvbn';
import * as db from '../services/db';
import { logger } from '../services/logger.server';
import { User, UserProfile, Address } from '../types';
import { AsyncRequestHandler } from '../types/express';
const router = express.Router();

View File

@@ -1,12 +1,13 @@
// src/services/aiApiClient.test.ts
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse, passthrough } from 'msw';
import { http, HttpResponse } from 'msw';
// Ensure the module under test is NOT mocked.
vi.unmock('./aiApiClient');
import * as aiApiClient from './aiApiClient';
import { FlyerItem } from '../types';
// 1. Mock logger to keep output clean
vi.mock('./logger', () => ({
@@ -32,26 +33,27 @@ vi.mock('./apiClient', () => ({
const requestSpy = vi.fn();
const server = setupServer(
http.post('http://localhost/api/ai/:endpoint', async ({ request, params }) => {
let body: any = {};
http.post('http://localhost/api/ai/:endpoint', async ({ request, params }): Promise<HttpResponse<Record<string, unknown>>> => {
let body: Record<string, unknown> = {};
const contentType = request.headers.get('content-type');
if (contentType?.includes('application/json')) {
try {
body = await request.json();
} catch (e) { /* ignore parse error */ }
body = await request.json() as Record<string, unknown>;
} catch (err) { /* ignore parse error */ }
} else if (contentType?.includes('multipart/form-data')) {
try {
// This is the key part. We read the formData from the request.
const formData = await request.formData();
// And then convert it to a plain object for easier assertions.
// This correctly preserves File objects.
body = {};
const formDataBody: Record<string, unknown> = {};
for (const [key, value] of formData.entries()) {
body[key] = value;
formDataBody[key] = value;
}
body._isFormData = true;
} catch (e) { /* ignore parse error */ }
formDataBody._isFormData = true;
body = formDataBody;
} catch (err) { /* ignore parse error */ }
}
requestSpy({
@@ -77,19 +79,15 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('isImageAFlyer', () => {
it('should construct FormData and send a POST request', async () => {
// FIX: Create a Blob and append it to FormData with a filename.
// This is the most reliable way to simulate a file upload in this test environment.
const blob = new Blob(['dummy'], { type: 'image/jpeg' });
const formData = new FormData();
formData.append('image', blob, 'flyer.jpg');
await aiApiClient.isImageAFlyer(formData as any, 'test-token');
const mockFile = new File(['dummy'], 'flyer.jpg', { type: 'image/jpeg' });
await aiApiClient.isImageAFlyer(mockFile, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
expect(req.endpoint).toBe('check-flyer');
expect(req.method).toBe('POST');
expect(req.body._isFormData).toBe(true);
expect(req.body).toHaveProperty('_isFormData', true);
// Check for file-like properties instead of strict instance check
expect(req.body.image).toHaveProperty('name', 'flyer.jpg');
expect(req.body.image).toHaveProperty('size');
@@ -98,26 +96,22 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('extractAddressFromImage', () => {
it('should construct FormData and send a POST request', async () => {
const blob = new Blob(['dummy'], { type: 'image/jpeg' });
const formData = new FormData();
formData.append('image', blob, 'flyer.jpg');
await aiApiClient.extractAddressFromImage(formData as any, 'test-token');
const mockFile = new File(['dummy'], 'flyer.jpg', { type: 'image/jpeg' });
await aiApiClient.extractAddressFromImage(mockFile, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
expect(req.endpoint).toBe('extract-address');
expect(req.body._isFormData).toBe(true);
expect(req.body).toHaveProperty('_isFormData', true);
expect(req.body.image).toHaveProperty('name', 'flyer.jpg');
});
});
describe('extractLogoFromImage', () => {
it('should construct FormData and send a POST request', async () => {
const blob = new Blob(['logo'], { type: 'image/jpeg' });
const formData = new FormData();
formData.append('images', blob, 'logo.jpg');
await aiApiClient.extractLogoFromImage(formData as any, 'test-token');
const mockFile = new File(['logo'], 'logo.jpg', { type: 'image/jpeg' });
await aiApiClient.extractLogoFromImage([mockFile], 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
@@ -130,7 +124,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
// ... (Rest of tests remain unchanged)
describe('getDeepDiveAnalysis', () => {
it('should send items as JSON in the body', async () => {
const items: any[] = [{ item: 'apple' }];
const items = [{ item: 'apple' }];
await aiApiClient.getDeepDiveAnalysis(items, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
@@ -143,7 +137,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('searchWeb', () => {
it('should send items as JSON in the body', async () => {
const items: any[] = [{ item: 'search me' }];
const items = [{ item: 'search me' }];
await aiApiClient.searchWeb(items, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
@@ -182,7 +176,12 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('startVoiceSession', () => {
it('should throw an error as it is not implemented', () => {
expect(() => aiApiClient.startVoiceSession({ onmessage: vi.fn() } as any)).toThrow(
const mockCallbacks = {
onmessage: vi.fn(),
onopen: vi.fn(),
onclose: vi.fn(),
};
expect(() => aiApiClient.startVoiceSession(mockCallbacks)).toThrow(
'Voice session feature is not fully implemented and requires a backend WebSocket proxy.'
);
});

View File

@@ -74,7 +74,7 @@ export const extractLogoFromImage = async (imageFiles: File[], tokenOverride?: s
}, tokenOverride);
};
export const getQuickInsights = async (items: FlyerItem[], tokenOverride?: string): Promise<Response> => {
export const getQuickInsights = async (items: Partial<FlyerItem>[], tokenOverride?: string): Promise<Response> => {
return apiFetchWithAuth('/ai/quick-insights', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -82,7 +82,7 @@ export const getQuickInsights = async (items: FlyerItem[], tokenOverride?: strin
}, tokenOverride);
};
export const getDeepDiveAnalysis = async (items: FlyerItem[], tokenOverride?: string): Promise<Response> => {
export const getDeepDiveAnalysis = async (items: Partial<FlyerItem>[], tokenOverride?: string): Promise<Response> => {
return apiFetchWithAuth('/ai/deep-dive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -90,7 +90,7 @@ export const getDeepDiveAnalysis = async (items: FlyerItem[], tokenOverride?: st
}, tokenOverride);
};
export const searchWeb = async (items: FlyerItem[], tokenOverride?: string): Promise<Response> => {
export const searchWeb = async (items: Partial<FlyerItem>[], tokenOverride?: string): Promise<Response> => {
return apiFetchWithAuth('/ai/search-web', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -22,7 +22,9 @@ const { mockGenerateContent, mockReadFile, mockToBuffer, mockExtract, mockSharp
// 2. Mock @google/genai using a class that references the hoisted mock
vi.mock('@google/genai', () => {
class MockGoogleGenAI {
constructor(public config: any) {}
// Use a specific type for the config to avoid `any`.
// The real config is more complex, but this satisfies the test's needs.
constructor(public config: { apiKey: string }) {}
get models() {
return {
@@ -204,7 +206,17 @@ describe('AI Service (Server)', () => {
// 2. Verify the AI was called with the cropped image data and correct prompt
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
const aiCallArgs = mockGenerateContent.mock.calls[0][0] as any;
// Define a specific type for the AI call arguments to avoid `as any`.
interface AiCallArgs {
contents: {
parts: {
text?: string;
inlineData?: unknown;
}[];
}[];
}
const aiCallArgs = mockGenerateContent.mock.calls[0][0] as AiCallArgs;
expect(aiCallArgs.contents[0].parts[0].text).toContain('What is the store name in this image?');
expect(result.text).toBe('Super Store');
});

View File

@@ -1,5 +1,5 @@
// src/services/apiClient.ts
import { Profile, ShoppingListItem, FlyerItem, SearchQuery, Budget, Address } from '../types';
import { Profile, ShoppingListItem, SearchQuery, Budget, Address } from '../types';
import { logger } from './logger';
// This constant should point to your backend API.

View File

@@ -39,7 +39,7 @@ describe('Budget DB Service', () => {
totalCount: 0,
idleCount: 0,
waitingCount: 0,
} as any;
} as unknown as Pool;
});
vi.clearAllMocks();
});

View File

@@ -46,14 +46,14 @@ describe('Flyer DB Service', () => {
totalCount: 0,
idleCount: 0,
waitingCount: 0,
} as any;
} as unknown as Pool;
});
vi.clearAllMocks();
});
describe('getFlyers', () => {
it('should execute the correct query and return flyers', async () => {
const mockFlyers: Flyer[] = [{ flyer_id: 1, file_name: 'test.jpg', image_url: 'url', created_at: new Date().toISOString() }];
const mockFlyers: Flyer[] = [{ flyer_id: 1, file_name: 'test.jpg', image_url: 'url', created_at: new Date().toISOString(), item_count: 10 }];
mockQuery.mockResolvedValue({ rows: mockFlyers });
const result = await getFlyers();
@@ -105,7 +105,7 @@ describe('Flyer DB Service', () => {
describe('createFlyerAndItems', () => {
it('should execute a transaction to create a flyer and its items', async () => {
const flyerData = { file_name: 'flyer.jpg', image_url: '/img.jpg', checksum: 'cs', store_name: 'Test Store', valid_from: null, valid_to: null, store_address: null, uploaded_by: null };
const flyerData = { file_name: 'flyer.jpg', image_url: '/img.jpg', checksum: 'cs', store_name: 'Test Store', valid_from: null, valid_to: null, store_address: null, uploaded_by: null, item_count: 0 };
const items: Omit<FlyerItem, 'flyer_item_id' | 'flyer_id' | 'created_at'>[] = [{
item: 'Test Item',
price_display: '$1',

View File

@@ -1,6 +1,6 @@
// src/services/db/flyer.ts
import { getPool } from './connection';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors';
import { UniqueConstraintError } from './errors';
import { logger } from '../logger.server';
import { geocodeAddress } from '../geocodingService.server';
import { Flyer, Brand, MasterGroceryItem, FlyerItem, Address } from '../../types';

View File

@@ -39,7 +39,7 @@ describe('Notification DB Service', () => {
totalCount: 0,
idleCount: 0,
waitingCount: 0,
} as any;
} as unknown as Pool;
});
vi.clearAllMocks();
});

View File

@@ -49,7 +49,7 @@ describe('Personalization DB Service', () => {
totalCount: 0,
idleCount: 0,
waitingCount: 0,
} as any;
} as unknown as Pool;
});
vi.clearAllMocks();
});

View File

@@ -1,6 +1,5 @@
// src/services/db/recipe.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPool } from './connection';
import {
getRecipesBySalePercentage,
getRecipesByMinSaleIngredients,
@@ -40,7 +39,7 @@ describe('Recipe DB Service', () => {
totalCount: 0,
idleCount: 0,
waitingCount: 0,
} as any;
} as unknown as Pool;
});
vi.clearAllMocks();
});

View File

@@ -45,7 +45,7 @@ describe('Shopping DB Service', () => {
totalCount: 0,
idleCount: 0,
waitingCount: 0,
} as any;
} as unknown as Pool;
});
vi.clearAllMocks();
});

View File

@@ -2,7 +2,7 @@
import { getPool } from './connection';
import { logger } from '../logger';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors';
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, User, UserProfile } from '../../types';
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, UserProfile } from '../../types';
import { getShoppingLists } from './shopping';
import { getWatchedItems } from './personalization';

View File

@@ -1,4 +1,4 @@
// src/types/express.d.ts
// src/services/express.d.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';
/**

View File

@@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { notifySuccess, notifyError } from './notificationService';
// FIX: Mock the local re-export, not the library directly.
// This is more stable and ensures the service under test gets the mock.