diff --git a/src/features/charts/PriceHistoryChart.test.tsx b/src/features/charts/PriceHistoryChart.test.tsx
index b29fa9cf..2e8c3953 100644
--- a/src/features/charts/PriceHistoryChart.test.tsx
+++ b/src/features/charts/PriceHistoryChart.test.tsx
@@ -26,7 +26,8 @@ vi.mock('recharts', () => ({
YAxis: () =>
,
Tooltip: () => ,
Legend: () => ,
- Line: ({ name }: { name: string }) => ,
+ // Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey
+ Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => ,
}));
const mockWatchedItems: MasterGroceryItem[] = [
@@ -48,13 +49,13 @@ describe('PriceHistoryChart', () => {
it('should render a placeholder when there are no watched items', () => {
render();
- expect(screen.getByText('Price history will appear here once you add items to your watchlist.')).toBeInTheDocument();
+ expect(screen.getByText('Add items to your watchlist to see their price trends over time.')).toBeInTheDocument();
});
it('should display a loading state while fetching data', () => {
mockedApiClient.fetchHistoricalPriceData.mockReturnValue(new Promise(() => {}));
render();
- expect(screen.getByText('Loading price history...')).toBeInTheDocument();
+ expect(screen.getByText('Loading Price History...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
@@ -62,7 +63,8 @@ describe('PriceHistoryChart', () => {
render();
await waitFor(() => {
- expect(screen.getByText('Could not load price history.')).toBeInTheDocument();
+ // Use regex to match the error message text which might be split across elements
+ expect(screen.getByText(/API is down/)).toBeInTheDocument();
});
});
@@ -71,7 +73,7 @@ describe('PriceHistoryChart', () => {
render();
await waitFor(() => {
- expect(screen.getByText('No historical price data available for your watched items yet.')).toBeInTheDocument();
+ expect(screen.getByText('Not enough historical data for your watched items. Process more flyers to build a trend.')).toBeInTheDocument();
});
});
diff --git a/src/services/aiApiClient.test.ts b/src/services/aiApiClient.test.ts
index 4b8fed37..92838868 100644
--- a/src/services/aiApiClient.test.ts
+++ b/src/services/aiApiClient.test.ts
@@ -1,17 +1,37 @@
// src/services/aiApiClient.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
-import * as apiClient from './apiClient';
-import * as aiApiClient from './aiApiClient';
-import { MasterGroceryItem, FlyerItem } from '../types';
-// Mock the underlying apiClient to isolate our tests to the aiApiClient logic.
-vi.mock('./apiClient');
-const mockedApiClient = apiClient as Mocked;
+// 1. Hoist the mock function so it is created before modules are evaluated
+// and can be referenced inside the vi.mock factory.
+const { mockApiFetchWithAuth } = vi.hoisted(() => ({
+ mockApiFetchWithAuth: vi.fn(),
+}));
+
+// 2. Mock the dependency './apiClient' to use our hoisted spy.
+vi.mock('./apiClient', () => ({
+ apiFetchWithAuth: mockApiFetchWithAuth,
+}));
+
+// Mock logger as it is used by aiApiClient
+vi.mock('./logger', () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+ },
+}));
describe('AI API Client', () => {
- beforeEach(() => {
- // Clear all mock history before each test
+ // We will load the module under test dynamically for each test
+ let aiApiClient: typeof import('./aiApiClient');
+
+ beforeEach(async () => {
vi.clearAllMocks();
+ vi.resetModules(); // Force a fresh module load
+
+ // 3. Import the module under test AFTER mocks are set up.
+ // This ensures aiApiClient binds to the mocked apiFetchWithAuth.
+ aiApiClient = await import('./aiApiClient');
});
describe('isImageAFlyer', () => {
@@ -19,8 +39,8 @@ describe('AI API Client', () => {
const file = new File([''], 'flyer.jpg', { type: 'image/jpeg' });
await aiApiClient.isImageAFlyer(file, 'test-token');
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- const [url, options, token] = mockedApiClient.apiFetchWithAuth.mock.calls[0];
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ const [url, options, token] = mockApiFetchWithAuth.mock.calls[0];
expect(url).toBe('/ai/check-flyer');
expect(options.method).toBe('POST');
@@ -35,8 +55,8 @@ describe('AI API Client', () => {
const file = new File([''], 'flyer.jpg', { type: 'image/jpeg' });
await aiApiClient.extractAddressFromImage(file, 'test-token');
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- const [url, options, token] = mockedApiClient.apiFetchWithAuth.mock.calls[0];
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ const [url, options, token] = mockApiFetchWithAuth.mock.calls[0];
expect(url).toBe('/ai/extract-address');
expect(options.method).toBe('POST');
@@ -49,12 +69,12 @@ describe('AI API Client', () => {
describe('extractCoreDataFromImage', () => {
it('should construct FormData and call apiFetchWithAuth correctly', async () => {
const files = [new File([''], 'flyer1.jpg', { type: 'image/jpeg' })];
- const masterItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Milk', created_at: '' }];
+ const masterItems: any[] = [{ master_grocery_item_id: 1, name: 'Milk', created_at: '' }];
await aiApiClient.extractCoreDataFromImage(files, masterItems);
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- const [url, options] = mockedApiClient.apiFetchWithAuth.mock.calls[0];
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ const [url, options] = mockApiFetchWithAuth.mock.calls[0];
expect(url).toBe('/ai/process-flyer');
expect(options.method).toBe('POST');
@@ -72,8 +92,8 @@ describe('AI API Client', () => {
const files = [new File([''], 'logo.jpg', { type: 'image/jpeg' })];
await aiApiClient.extractLogoFromImage(files, 'test-token');
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- const [url, options, token] = mockedApiClient.apiFetchWithAuth.mock.calls[0];
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ const [url, options, token] = mockApiFetchWithAuth.mock.calls[0];
expect(url).toBe('/ai/extract-logo');
expect(options.method).toBe('POST');
@@ -85,11 +105,11 @@ describe('AI API Client', () => {
describe('getDeepDiveAnalysis', () => {
it('should call apiFetchWithAuth with the items in the body', async () => {
- const items: FlyerItem[] = [];
+ const items: any[] = [];
await aiApiClient.getDeepDiveAnalysis(items, 'test-token');
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith(
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ expect(mockApiFetchWithAuth).toHaveBeenCalledWith(
'/ai/deep-dive',
{
method: 'POST',
@@ -103,11 +123,11 @@ describe('AI API Client', () => {
describe('searchWeb', () => {
it('should call apiFetchWithAuth with the items in the body', async () => {
- const items: FlyerItem[] = [];
+ const items: any[] = [];
await aiApiClient.searchWeb(items, 'test-token');
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith(
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ expect(mockApiFetchWithAuth).toHaveBeenCalledWith(
'/ai/search-web',
{
method: 'POST',
@@ -124,8 +144,8 @@ describe('AI API Client', () => {
const prompt = 'A delicious meal';
await aiApiClient.generateImageFromText(prompt, 'test-token');
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith(
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ expect(mockApiFetchWithAuth).toHaveBeenCalledWith(
'/ai/generate-image',
{
method: 'POST',
@@ -142,8 +162,8 @@ describe('AI API Client', () => {
const text = 'Hello world';
await aiApiClient.generateSpeechFromText(text, 'test-token');
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
- expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith(
+ expect(mockApiFetchWithAuth).toHaveBeenCalledTimes(1);
+ expect(mockApiFetchWithAuth).toHaveBeenCalledWith(
'/ai/generate-speech',
{
method: 'POST',
@@ -157,7 +177,8 @@ describe('AI API Client', () => {
describe('startVoiceSession', () => {
it('should throw an error as it is not implemented', () => {
- expect(() => aiApiClient.startVoiceSession({ onmessage: vi.fn() })).toThrow(
+ // Ensure the real implementation is called, which should throw
+ expect(() => aiApiClient.startVoiceSession({ onmessage: vi.fn() } as any)).toThrow(
'Voice session feature is not fully implemented and requires a backend WebSocket proxy.'
);
});
diff --git a/src/services/aiService.server.test.ts b/src/services/aiService.server.test.ts
index 804a015e..9595a152 100644
--- a/src/services/aiService.server.test.ts
+++ b/src/services/aiService.server.test.ts
@@ -1,20 +1,29 @@
// src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { MasterGroceryItem } from '../types';
-import type { readFile as ReadFileFn } from 'fs/promises';
-// Define the generateContent mock outside so it can be accessed in tests.
-const mockGenerateContent = vi.fn();
+// 1. Hoist the mocks so they can be referenced inside the mock factories
+const { mockGenerateContent, mockReadFile, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => {
+ const generateContent = vi.fn();
+ const readFile = vi.fn();
+ const toBuffer = vi.fn();
+ const extract = vi.fn(() => ({ toBuffer }));
+ const sharp = vi.fn(() => ({ extract }));
+
+ return {
+ mockGenerateContent: generateContent,
+ mockReadFile: readFile,
+ mockToBuffer: toBuffer,
+ mockExtract: extract,
+ mockSharp: sharp
+ };
+});
-// Mock @google/genai using a class-based approach for clarity and stability.
+// 2. Mock @google/genai using a class that references the hoisted mock
vi.mock('@google/genai', () => {
- // Create a standard class to act as the mock.
class MockGoogleGenAI {
- constructor(public config: any) {
- console.log('[AI MOCK] Constructor called');
- }
+ constructor(public config: any) {}
- // Match the structure expected: genAI.models.generateContent
get models() {
return {
generateContent: mockGenerateContent
@@ -30,29 +39,25 @@ vi.mock('@google/genai', () => {
}
return {
- // Ensure the mock returns a new instance of our mock class.
- GoogleGenAI: vi.fn((config) => new MockGoogleGenAI(config)),
+ GoogleGenerativeAI: MockGoogleGenAI,
};
});
-// Mock fs/promises
-const mockReadFile = vi.fn();
+
+// 3. Mock fs/promises
vi.mock('fs/promises', () => ({
default: {
- readFile: (...args: Parameters) => mockReadFile(...args),
+ readFile: mockReadFile,
},
- readFile: (...args: Parameters) => mockReadFile(...args),
+ readFile: mockReadFile,
}));
-// Mock the sharp library
-const mockToBuffer = vi.fn();
-const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
-const mockSharp = vi.fn(() => ({ extract: mockExtract }));
+// 4. Mock sharp
vi.mock('sharp', () => ({
__esModule: true,
default: mockSharp,
}));
-// Mock the logger
+// 5. Mock the logger
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
@@ -64,12 +69,14 @@ vi.mock('./logger.server', () => ({
describe('AI Service (Server)', () => {
beforeEach(() => {
vi.clearAllMocks();
- // Reset modules to re-evaluate the aiService with the mock for each test.
+ // Reset modules to ensure the service re-initializes with the mocks
vi.resetModules();
- // Ensure a default success response is set before each test.
+ // Default success response matching the shape expected by the implementation
+ // The implementation accesses `response.text` (property) and `response.candidates`
mockGenerateContent.mockResolvedValue({
- response: { text: () => '[]', candidates: [] }
+ text: '[]',
+ candidates: []
});
});
@@ -81,8 +88,8 @@ describe('AI Service (Server)', () => {
{ "raw_item_description": "AVOCADO", "price_paid_cents": 299 }
]`;
- // The server code accesses `response.text` as a property.
- mockGenerateContent.mockResolvedValue({ response: { text: () => mockAiResponseText } } as any);
+ // Update mock to return an object with a 'text' property
+ mockGenerateContent.mockResolvedValue({ text: mockAiResponseText });
mockReadFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg');
@@ -96,7 +103,7 @@ describe('AI Service (Server)', () => {
it('should throw an error if the AI response is not valid JSON', async () => {
const { extractItemsFromReceiptImage } = await import('./aiService.server');
- mockGenerateContent.mockResolvedValue({ response: { text: () => 'This is not JSON.' } } as any);
+ mockGenerateContent.mockResolvedValue({ text: 'This is not JSON.' });
mockReadFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg')).rejects.toThrow(
@@ -119,7 +126,7 @@ describe('AI Service (Server)', () => {
{ item: 'Oranges', price_display: null, price_in_cents: null, quantity: undefined, category_name: null, master_item_id: null },
],
};
- mockGenerateContent.mockResolvedValue({ response: { text: () => JSON.stringify(mockAiResponse) } } as any);
+ mockGenerateContent.mockResolvedValue({ text: JSON.stringify(mockAiResponse) });
mockReadFile.mockResolvedValue(Buffer.from('mock-image-data'));
const result = await extractCoreDataFromFlyerImage([{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], mockMasterItems);
@@ -135,7 +142,7 @@ describe('AI Service (Server)', () => {
it('should throw an error if the AI response is not a valid JSON object', async () => {
const { extractCoreDataFromFlyerImage } = await import('./aiService.server');
- mockGenerateContent.mockResolvedValue({ response: { text: () => 'not a json object' } } as any);
+ mockGenerateContent.mockResolvedValue({ text: 'not a json object' });
mockReadFile.mockResolvedValue(Buffer.from('mock-image-data'));
await expect(extractCoreDataFromFlyerImage([], mockMasterItems)).rejects.toThrow(
@@ -147,19 +154,16 @@ describe('AI Service (Server)', () => {
describe('planTripWithMaps', () => {
it('should call generateContent and return the text and sources', async () => {
const { planTripWithMaps } = await import('./aiService.server');
- // Server code accesses `response.candidates` and `response.text`.
mockGenerateContent.mockResolvedValue({
- response: {
- text: () => 'The nearest store is...',
- candidates: [{
- groundingMetadata: {
- groundingChunks: [
- { web: { uri: 'http://maps.google.com/1', title: 'Map to Store A' } },
- { web: { uri: 'http://maps.google.com/2', title: 'Map to Store B' } },
- ],
- },
- }],
- }
+ text: 'The nearest store is...',
+ candidates: [{
+ groundingMetadata: {
+ groundingChunks: [
+ { web: { uri: 'http://maps.google.com/1', title: 'Map to Store A' } },
+ { web: { uri: 'http://maps.google.com/2', title: 'Map to Store B' } },
+ ],
+ },
+ }],
});
const mockLocation = { latitude: 48.4284, longitude: -123.3656 } as GeolocationCoordinates;
@@ -168,7 +172,7 @@ describe('AI Service (Server)', () => {
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
// Verify that the prompt includes the location data
const calledWith = mockGenerateContent.mock.calls[0][0] as any;
- expect(calledWith.contents[0].parts[0].text).toContain('My current location is latitude 48.4284, longitude -123.3656');
+ expect(calledWith.contents).toContain('latitude 48.4284');
// Verify the returned structure
expect(result.text).toBe('The nearest store is...');
@@ -191,7 +195,7 @@ describe('AI Service (Server)', () => {
mockToBuffer.mockResolvedValue(mockCroppedBuffer);
// Mock AI response
- mockGenerateContent.mockResolvedValue({ response: { text: () => 'Super Store' } } as any);
+ mockGenerateContent.mockResolvedValue({ text: 'Super Store' });
const result = await extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType);
diff --git a/src/services/notificationService.test.ts b/src/services/notificationService.test.ts
index c4e07d3b..db44282a 100644
--- a/src/services/notificationService.test.ts
+++ b/src/services/notificationService.test.ts
@@ -1,18 +1,23 @@
// src/services/notificationService.test.ts
-import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { notifySuccess, notifyError } from './notificationService';
-import toast from 'react-hot-toast';
-// Mock the react-hot-toast library. We mock the default export which is the toast object.
-vi.mock('react-hot-toast', () => ({
- default: {
- success: vi.fn(),
- error: vi.fn(),
- },
+// Use vi.hoisted to create the mocks before any imports or mock calls.
+// This ensures that we have a stable reference to the spy functions that
+// will be used in both the mock definition and the test assertions.
+const mocks = vi.hoisted(() => ({
+ success: vi.fn(),
+ error: vi.fn(),
}));
-// Get a typed reference to the mocked functions for type-safe assertions.
-const mockedToast = toast as Mocked;
+// Mock react-hot-toast using the hoisted spies.
+// The factory returns the module structure, where 'default' contains the toast methods.
+vi.mock('react-hot-toast', () => ({
+ default: {
+ success: mocks.success,
+ error: mocks.error,
+ },
+}));
describe('Notification Service', () => {
beforeEach(() => {
@@ -25,9 +30,8 @@ describe('Notification Service', () => {
const message = 'Operation was successful!';
notifySuccess(message);
- expect(mockedToast.success).toHaveBeenCalledTimes(1);
- // Check that the message is correct and that the options object is passed.
- expect(mockedToast.success).toHaveBeenCalledWith(
+ expect(mocks.success).toHaveBeenCalledTimes(1);
+ expect(mocks.success).toHaveBeenCalledWith(
message,
expect.objectContaining({
style: expect.any(Object), // Check that common styles are included
@@ -45,8 +49,8 @@ describe('Notification Service', () => {
const message = 'Something went wrong!';
notifyError(message);
- expect(mockedToast.error).toHaveBeenCalledTimes(1);
- expect(mockedToast.error).toHaveBeenCalledWith(
+ expect(mocks.error).toHaveBeenCalledTimes(1);
+ expect(mocks.error).toHaveBeenCalledWith(
message,
expect.objectContaining({
style: expect.any(Object),