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
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:
@@ -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');
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('Budget DB Service', () => {
|
||||
totalCount: 0,
|
||||
idleCount: 0,
|
||||
waitingCount: 0,
|
||||
} as any;
|
||||
} as unknown as Pool;
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('Notification DB Service', () => {
|
||||
totalCount: 0,
|
||||
idleCount: 0,
|
||||
waitingCount: 0,
|
||||
} as any;
|
||||
} as unknown as Pool;
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Personalization DB Service', () => {
|
||||
totalCount: 0,
|
||||
idleCount: 0,
|
||||
waitingCount: 0,
|
||||
} as any;
|
||||
} as unknown as Pool;
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('Shopping DB Service', () => {
|
||||
totalCount: 0,
|
||||
idleCount: 0,
|
||||
waitingCount: 0,
|
||||
} as any;
|
||||
} as unknown as Pool;
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
2
src/services/express.d.ts
vendored
2
src/services/express.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
// src/types/express.d.ts
|
||||
// src/services/express.d.ts
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user