Files
flyer-crawler.projectium.com/src/services/aiApiClient.test.ts
Torben Sorensen cadacb63f5
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m54s
fix unit tests
2025-12-30 03:19:47 -08:00

573 lines
22 KiB
TypeScript

// 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: <T>(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<string, unknown> | FormData = {};
let bodyForSpy: Record<string, unknown> = {};
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<string, unknown>;
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('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 });
});
});
});