All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m54s
573 lines
22 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|