// src/services/aiApiClient.test.ts import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; // Ensure the module under test is NOT mocked. vi.unmock('./aiApiClient'); import { createMockFlyerItem, createMockStore, createMockMasterGroceryItem, } from '../tests/utils/mockFactories'; import * as aiApiClient from './aiApiClient'; // 1. Mock logger to keep output clean vi.mock('./logger.client', () => ({ logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn(), }, })); // 2. Mock ./apiClient to simply pass calls through to the global fetch. vi.mock('./apiClient', async (importOriginal) => { // This is the core logic we want to preserve: it calls the global fetch // which is then intercepted by MSW. const apiFetch = ( url: string, options: RequestInit = {}, apiOptions: import('./apiClient').ApiOptions = {}, ) => { const fullUrl = url.startsWith('/') ? `http://localhost/api${url}` : url; options.headers = new Headers(options.headers); // Ensure headers is a Headers object if (apiOptions.tokenOverride) { options.headers.set('Authorization', `Bearer ${apiOptions.tokenOverride}`); } // ================================= WORKAROUND FOR JSDOM FILE NAME BUG ================================= // JSDOM's fetch implementation (undici) loses filenames in FormData. // SOLUTION: Before fetch is called, we find the file, extract its real name, // and add it to a custom header. The MSW handler will read this header. if (options.body instanceof FormData) { console.log(`[apiFetch MOCK] FormData detected. Searching for file to preserve its name.`); for (const value of (options.body as FormData).values()) { if (value instanceof File) { console.log( `[apiFetch MOCK] Found file: '${value.name}'. Setting 'X-Test-Filename' header.`, ); options.headers.set('X-Test-Filename', value.name); // We only expect one file per request in these tests, so we can break. break; } } } // ======================================= END WORKAROUND =============================================== const request = new Request(fullUrl, options); console.log(`[apiFetch MOCK] Executing fetch for URL: ${request.url}.`); return fetch(request); }; return { // The original mock only had apiFetch. We need to add the helpers. apiFetch, // These helpers are what aiApiClient.ts actually calls. // Their mock implementation should just call our mocked apiFetch. authedGet: (endpoint: string, options: import('./apiClient').ApiOptions = {}) => { return apiFetch(endpoint, { method: 'GET' }, options); }, authedPost: (endpoint: string, body: T, options: import('./apiClient').ApiOptions = {}) => { return apiFetch( endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, options, ); }, authedPostForm: (endpoint: string, formData: FormData, options: import('./apiClient').ApiOptions = {}) => { return apiFetch(endpoint, { method: 'POST', body: formData }, options); }, // Add a mock for ApiOptions to satisfy the compiler ApiOptions: vi.fn(), }; }); // 3. Setup MSW to capture requests const requestSpy = vi.fn(); const server = setupServer( // Handler for all POST requests to the AI endpoints http.post('http://localhost/api/ai/:endpoint', async ({ request, params }) => { let body: Record | FormData = {}; let bodyForSpy: Record = {}; const contentType = request.headers.get('Content-Type'); console.log(`\n--- [MSW HANDLER] Intercepted POST to '${String(params.endpoint)}' ---`); if (contentType?.includes('application/json')) { const parsedBody = await request.json(); if (typeof parsedBody === 'object' && parsedBody !== null && !Array.isArray(parsedBody)) { body = parsedBody as Record; bodyForSpy = body; // For JSON, the body is already a plain object. } } else if (contentType?.includes('multipart/form-data')) { body = await request.formData(); // WORKAROUND PART 2: Read the filename from our custom header. const preservedFilename = request.headers.get('X-Test-Filename'); console.log(`[MSW HANDLER] Reading 'X-Test-Filename' header. Value: '${preservedFilename}'`); for (const [key, value] of (body as FormData).entries()) { const isFile = typeof value === 'object' && value !== null && 'name' in value && 'size' in value && 'type' in value; if (isFile) { const file = value as File; const finalName = preservedFilename || file.name; console.log( `[MSW HANDLER DEBUG] Found file-like object for key '${key}'. Original name: '${file.name}'. Using preserved name: '${finalName}'`, ); if (!bodyForSpy[key]) { bodyForSpy[key] = { name: finalName, size: file.size, type: file.type }; } } else { bodyForSpy[key] = value; } } console.log('[MSW HANDLER] Finished processing FormData. Final object for spy:', bodyForSpy); } requestSpy({ endpoint: params.endpoint, method: request.method, body: bodyForSpy, // Pass the stable, plain object to the spy. headers: request.headers, }); return HttpResponse.json({ success: true }); }), // Handler for GET requests, specifically for job status http.get('http://localhost/api/ai/jobs/:jobId/status', ({ params }) => { requestSpy({ endpoint: 'jobs', method: 'GET', jobId: params.jobId, }); return HttpResponse.json({ state: 'completed' }); }), ); describe('AI API Client (Network Mocking with MSW)', () => { beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { server.resetHandlers(); requestSpy.mockClear(); vi.clearAllMocks(); }); afterAll(() => server.close()); describe('uploadAndProcessFlyer', () => { it('should construct FormData with file and checksum and send a POST request', async () => { const mockFile = new File(['this is a test pdf'], 'flyer.pdf', { type: 'application/pdf' }); const checksum = 'checksum-abc-123'; console.log(`\n--- [TEST START] uploadAndProcessFlyer ---`); console.log('[TEST ARRANGE] Created mock file:', { name: mockFile.name, size: mockFile.size, type: mockFile.type, }); await aiApiClient.uploadAndProcessFlyer(mockFile, checksum); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2)); expect(req.endpoint).toBe('upload-and-process'); expect(req.method).toBe('POST'); // DEBUG: Log the body received by the spy. It should now be a plain object. console.log('[TEST DEBUG] uploadAndProcessFlyer - Body received by spy:', req.body); // FIX: Assert against the properties of the plain object from the spy. const flyerFile = req.body.flyerFile as { name: string }; const checksumValue = req.body.checksum; // Add assertions to ensure the objects exist before checking their properties. expect(flyerFile).toBeDefined(); expect(checksumValue).toBeDefined(); expect(flyerFile.name).toBe('flyer.pdf'); expect(checksumValue).toBe(checksum); }); }); describe('uploadAndProcessFlyer error handling', () => { it('should throw a structured error with JSON body on non-ok response', async () => { const mockFile = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const checksum = 'checksum-abc-123'; const errorBody = { message: 'Checksum already exists', flyerId: 99 }; server.use( http.post('http://localhost/api/ai/upload-and-process', () => { return HttpResponse.json(errorBody, { status: 409 }); }), ); // The function now throws a structured object, not an Error instance. await expect(aiApiClient.uploadAndProcessFlyer(mockFile, checksum)).rejects.toEqual({ status: 409, body: errorBody, }); }); it('should throw a structured error with text body on non-ok, non-JSON response', async () => { const mockFile = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const checksum = 'checksum-abc-123'; const errorText = 'Internal Server Error'; server.use( http.post('http://localhost/api/ai/upload-and-process', () => { return HttpResponse.text(errorText, { status: 500 }); }), ); // The function now throws a structured object, not an Error instance. // The catch block in the implementation wraps the text in a message property. await expect(aiApiClient.uploadAndProcessFlyer(mockFile, checksum)).rejects.toEqual({ status: 500, body: { message: errorText }, }); }); }); describe('getJobStatus', () => { it('should send a GET request to the correct job status URL', async () => { const jobId = 'job-id-456'; await aiApiClient.getJobStatus(jobId); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('jobs'); expect(req.method).toBe('GET'); expect(req.jobId).toBe(jobId); }); }); describe('getJobStatus error handling', () => { const jobId = 'job-id-789'; it('should throw a JobFailedError if job state is "failed"', async () => { const failedStatus: aiApiClient.JobStatus = { id: jobId, state: 'failed', progress: { message: 'AI model exploded', errorCode: 'AI_ERROR' }, returnValue: null, failedReason: 'Raw error from BullMQ', }; server.use( http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => { return HttpResponse.json(failedStatus); }), ); await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow( new aiApiClient.JobFailedError('AI model exploded', 'AI_ERROR'), ); }); it('should use failedReason for JobFailedError if progress message is missing', async () => { const failedStatus: aiApiClient.JobStatus = { id: jobId, state: 'failed', progress: null, // No progress object returnValue: null, failedReason: 'Raw error from BullMQ', }; server.use( http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => { return HttpResponse.json(failedStatus); }), ); await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow( new aiApiClient.JobFailedError('Raw error from BullMQ', 'UNKNOWN_ERROR'), ); }); it('should throw a generic error if the API response is not ok', async () => { const errorBody = { message: 'Job not found' }; server.use( http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => { return HttpResponse.json(errorBody, { status: 404 }); }), ); await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Job not found'); }); it('should throw a specific error if a 200 OK response is not valid JSON', async () => { server.use( http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => { // A 200 OK response that is not JSON is a server-side contract violation. return HttpResponse.text('This should have been JSON', { status: 200 }); }), ); await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow( 'Failed to parse job status from a successful API response.', ); }); it('should throw a generic error with status text if the non-ok API response is not valid JSON', async () => { server.use( http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => { return HttpResponse.text('Gateway Timeout', { status: 504, statusText: 'Gateway Timeout' }); }), ); await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('API Error: 504 Gateway Timeout'); }); }); describe('isImageAFlyer', () => { it('should construct FormData and send a POST request', async () => { const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' }); console.log(`\n--- [TEST START] isImageAFlyer ---`); await aiApiClient.isImageAFlyer(mockFile, 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2)); expect(req.endpoint).toBe('check-flyer'); expect(req.method).toBe('POST'); // FIX: Assert against the plain object from the spy. const imageFile = req.body.image as { name: string }; expect(imageFile).toBeDefined(); expect(imageFile.name).toBe('flyer.jpg'); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('extractAddressFromImage', () => { it('should construct FormData and send a POST request', async () => { const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' }); console.log(`\n--- [TEST START] extractAddressFromImage ---`); await aiApiClient.extractAddressFromImage(mockFile, 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2)); expect(req.endpoint).toBe('extract-address'); // FIX: Assert against the plain object from the spy. const imageFile = req.body.image as { name: string }; expect(imageFile).toBeDefined(); expect(imageFile.name).toBe('flyer.jpg'); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('extractLogoFromImage', () => { it('should construct FormData and send a POST request', async () => { const mockFile = new File(['dummy image content'], 'logo.jpg', { type: 'image/jpeg' }); console.log(`\n--- [TEST START] extractLogoFromImage ---`); await aiApiClient.extractLogoFromImage([mockFile], 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2)); expect(req.endpoint).toBe('extract-logo'); // FIX: Assert against the plain object from the spy. const imageFile = req.body.images as { name: string }; expect(imageFile).toBeDefined(); expect(imageFile.name).toBe('logo.jpg'); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('getQuickInsights', () => { it('should send items as JSON in the body', async () => { const items = [createMockFlyerItem({ item: 'apple' })]; await aiApiClient.getQuickInsights(items, undefined, 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('quick-insights'); expect(req.body).toEqual({ items }); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('getDeepDiveAnalysis', () => { it('should send items as JSON in the body', async () => { const items = [createMockFlyerItem({ item: 'apple' })]; await aiApiClient.getDeepDiveAnalysis(items, undefined, 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('deep-dive'); expect(req.body).toEqual({ items }); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('searchWeb', () => { it('should send query as JSON in the body', async () => { const query = 'search me'; await aiApiClient.searchWeb(query, undefined, 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('search-web'); expect(req.body).toEqual({ query }); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('generateImageFromText', () => { it('should send prompt as JSON in the body', async () => { const prompt = 'A tasty burger'; await aiApiClient.generateImageFromText(prompt, undefined, 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('generate-image'); expect(req.body).toEqual({ prompt }); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('generateSpeechFromText', () => { it('should send text as JSON in the body', async () => { const text = 'Hello world'; await aiApiClient.generateSpeechFromText(text, undefined, 'test-token'); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('generate-speech'); expect(req.body).toEqual({ text }); expect(req.headers.get('Authorization')).toBe('Bearer test-token'); }); }); describe('planTripWithMaps', () => { it('should send items, store, and location as JSON in the body', async () => { // Create a full FlyerItem object, as the function signature requires it, not a partial. const items = [ createMockFlyerItem({ flyer_item_id: 1, flyer_id: 1, item: 'bread', price_display: '$1.99', price_in_cents: 199, quantity: '1 loaf', category_name: 'Bakery', // Factory allows overrides view_count: 0, click_count: 0, }), ]; const store = createMockStore({ store_id: 1, name: 'Test Store' }); // FIX: The mock GeolocationCoordinates object must correctly serialize to JSON, // mimicking the behavior of the real browser API when passed to JSON.stringify. // The previous toJSON method returned an empty object, causing the failure. const userLocation: GeolocationCoordinates = { latitude: 45, longitude: -75, accuracy: 0, altitude: null, altitudeAccuracy: null, heading: null, speed: null, toJSON: function () { // This function ensures that when JSON.stringify is called on this object, // it produces a plain object with all the expected properties. return { latitude: this.latitude, longitude: this.longitude, accuracy: this.accuracy, altitude: this.altitude, altitudeAccuracy: this.altitudeAccuracy, heading: this.heading, speed: this.speed, }; }, }; await aiApiClient.planTripWithMaps(items, store, userLocation); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; console.log('[TEST DEBUG] planTripWithMaps - Body received by spy:', req.body); expect(req.endpoint).toBe('plan-trip'); // FIX: The assertion must compare the received body against the *serialized* form of the userLocation. expect(req.body).toEqual({ items, store, userLocation: userLocation.toJSON() }); }); }); describe('rescanImageArea', () => { it('should construct FormData with image, cropArea, and extractionType', async () => { const mockFile = new File(['dummy image content'], 'flyer-page.jpg', { type: 'image/jpeg' }); const cropArea = { x: 10, y: 20, width: 100, height: 50 }; const extractionType = 'item_details' as const; console.log(`\n--- [TEST START] rescanImageArea ---`); await aiApiClient.rescanImageArea(mockFile, cropArea, extractionType); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2)); expect(req.endpoint).toBe('rescan-area'); // FIX: Assert against the plain object from the spy. const imageFile = req.body.image as { name: string }; const cropAreaValue = req.body.cropArea; const extractionTypeValue = req.body.extractionType; expect(imageFile).toBeDefined(); expect(imageFile.name).toBe('flyer-page.jpg'); expect(cropAreaValue).toBe(JSON.stringify(cropArea)); expect(extractionTypeValue).toBe(extractionType); }); }); describe('startVoiceSession', () => { it('should throw an error as it is not implemented', () => { 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.', ); }); }); describe('compareWatchedItemPrices', () => { it('should send items as JSON in the body', async () => { const items = [createMockMasterGroceryItem({ name: 'apple' })]; await aiApiClient.compareWatchedItemPrices(items); expect(requestSpy).toHaveBeenCalledTimes(1); const req = requestSpy.mock.calls[0][0]; expect(req.endpoint).toBe('compare-prices'); expect(req.body).toEqual({ items }); }); }); });