// src/services/apiClient.test.ts import { describe, it, expect, vi, afterAll, afterEach, beforeEach, beforeAll } from 'vitest'; import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; // Unmock the module under test to ensure we are testing the real implementation. vi.unmock('./apiClient'); import * as apiClient from './apiClient'; import { createMockAddressPayload, createMockBudget, createMockLoginPayload, createMockProfileUpdatePayload, createMockRecipeCommentPayload, createMockRegisterUserPayload, createMockSearchQueryPayload, createMockShoppingListItemPayload, createMockWatchedItemPayload, } from '../tests/utils/mockFactories'; // Mock the logger to keep test output clean and verifiable. vi.mock('./logger', () => ({ logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn(), }, })); // Mock localStorage for token storage, as it's used by apiFetch. const localStorageMock = (() => { let store: Record = {}; return { getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { store[key] = value.toString(); }, removeItem: (key: string) => { delete store[key]; }, clear: () => { store = {}; }, }; })(); Object.defineProperty(window, 'localStorage', { value: localStorageMock }); // Setup MSW mock server. const server = setupServer(); describe('API Client', () => { // These variables will be used to capture details from the fetch call. let capturedUrl: URL | null = null; let capturedHeaders: Headers | null = null; let capturedBody: string | FormData | Record | null = null; // Start the MSW server before all tests. beforeAll(() => server.listen()); beforeEach(() => { vi.clearAllMocks(); // Critical: Clear call history before every test // Define a correctly typed mock function for fetch. const mockFetch = (url: RequestInfo | URL, options?: RequestInit): Promise => { capturedUrl = new URL(url as string, 'http://localhost'); capturedHeaders = options?.headers as Headers; if (typeof options?.body === 'string') { try { capturedBody = JSON.parse(options.body); } catch { capturedBody = options.body; } } else if (options?.body instanceof FormData) { capturedBody = options.body; } else { capturedBody = null; } return Promise.resolve( new Response(JSON.stringify({ data: 'mock-success' }), { status: 200, headers: new Headers() as Headers, }), ); }; // Use spyOn instead of direct assignment to allow restoration for MSW tests. vi.spyOn(global, 'fetch').mockImplementation(mockFetch); }); afterEach(() => { server.resetHandlers(); localStorageMock.clear(); // Restore all mocks (including global.fetch) to their original implementation. vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('apiFetch', () => { it('should add Authorization header if token exists in localStorage', async () => { localStorage.setItem('authToken', 'test-token-123'); // The global fetch mock will now capture the headers. await apiClient.apiFetch('/users/profile'); expect(capturedHeaders).not.toBeNull(); expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token-123'); }); it('should not add Authorization header if no token exists', async () => { await apiClient.apiFetch('/public-data'); expect(capturedHeaders).not.toBeNull(); expect(capturedHeaders!.has('Authorization')).toBe(false); }); it('should handle token refresh on 401 response', async () => { localStorage.setItem('authToken', 'expired-token'); // Set an initial token // Mock the fetch sequence on the existing spy: // 1. Initial API call fails with 401 // 2. `refreshToken` call succeeds with a new token // 3. Retried API call succeeds with the expected user data vi.mocked(global.fetch) .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ message: 'Unauthorized' }), } as Response) .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ token: 'new-refreshed-token' }), } as Response) .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ user_id: 'user-123' }), } as Response); // The apiClient's internal refreshToken function will call the refresh endpoint. // We don't need a separate MSW handler for it if we are mocking global.fetch directly. // This test is now independent of MSW. const response = await apiClient.apiFetch('/users/profile'); const data = await response.json(); expect(response.ok).toBe(true); expect(data).toEqual({ user_id: 'user-123' }); // Verify the new token was stored in localStorage. expect(localStorage.getItem('authToken')).toBe('new-refreshed-token'); }); it('should reject if token refresh fails', async () => { localStorage.setItem('authToken', 'expired-token'); // Mock the fetch sequence: // 1. Initial API call fails with 401 // 2. `refreshToken` call also fails (e.g., with a 403) vi.mocked(global.fetch) .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ message: 'Unauthorized' }), } as Response) .mockResolvedValueOnce({ ok: false, status: 403, json: () => Promise.resolve({ message: 'Refresh failed' }), } as Response); // The apiFetch call should ultimately reject. await expect(apiClient.apiFetch('/users/profile')).rejects.toThrow('Refresh failed'); }); it('should log an error message if a non-401 request fails', async () => { const { logger } = await import('./logger.client'); // Mock a 500 Internal Server Error response vi.mocked(global.fetch).mockResolvedValueOnce({ ok: false, status: 500, headers: new Headers(), clone: () => ({ text: () => Promise.resolve('Internal Server Error') }), } as Response); // We expect the promise to still resolve with the bad response, but log an error. await apiClient.apiFetch('/some/failing/endpoint'); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ status: 500, body: 'Internal Server Error', url: expect.stringContaining('/some/failing/endpoint'), }), 'apiFetch: Request failed', ); }); it('should handle x-request-id header on failure (Sentry optional)', async () => { const requestId = 'req-123'; vi.mocked(global.fetch).mockResolvedValueOnce({ ok: false, status: 500, headers: new Headers({ 'x-request-id': requestId }), clone: () => ({ text: () => Promise.resolve('Error') }), } as Response); // This should not throw even if Sentry is not installed await apiClient.apiFetch('/error'); // The request should complete without error expect(true).toBe(true); }); it('should handle 401 on initial call, refresh token, and then poll until completed', async () => { localStorage.setItem('authToken', 'expired-token'); // Mock the global fetch to return a sequence of responses: // 1. 401 Unauthorized (initial API call) // 2. 200 OK (token refresh call) // 3. 200 OK (retry of the initial API call) vi.mocked(global.fetch) .mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ message: 'Unauthorized' }), } as Response) .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ token: 'new-refreshed-token' }), } as Response) .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ state: 'completed', returnValue: { flyerId: 777 } }), } as Response); const finalResponse = await apiClient.getJobStatus('polling-job'); const finalData = await finalResponse.json(); expect(finalData.state).toBe('completed'); expect(localStorage.getItem('authToken')).toBe('new-refreshed-token'); }); it('should return the response immediately if the refresh token endpoint itself returns 401', async () => { // Mock fetch to return 401 for the refresh token endpoint vi.mocked(global.fetch).mockResolvedValue({ ok: false, status: 401, json: () => Promise.resolve({ message: 'Unauthorized' }), } as Response); const response = await apiClient.apiFetch('/auth/refresh-token'); expect(response.status).toBe(401); }); }); describe('apiFetch (with FormData)', () => { it('should handle FormData correctly by not setting Content-Type', async () => { localStorage.setItem('authToken', 'form-data-token'); const formData = new FormData(); formData.append('file', new File(['content'], 'test.jpg')); await apiClient.apiFetch('/ai/upload-and-process', { method: 'POST', body: formData, }); expect(capturedHeaders).not.toBeNull(); expect(capturedHeaders!.get('Authorization')).toBe('Bearer form-data-token'); // FIX: The browser sets the Content-Type for FormData, including a boundary. // We should assert that our code does NOT set it to 'application/json', and that the browser-set value is correct. // In our test mock, the header won't be set at all, which is the correct behavior for our code. // The browser/fetch implementation handles the multipart header. expect(capturedHeaders!.has('Content-Type')).toBe(false); }); }); describe('Store and Brand API Functions', () => { it('uploadLogoAndUpdateStore should send FormData with the logo', async () => { const mockFile = new File(['logo-content'], 'store-logo.png', { type: 'image/png' }); await apiClient.uploadLogoAndUpdateStore(1, mockFile); expect(capturedUrl?.pathname).toBe('/api/stores/1/logo'); expect(capturedBody).toBeInstanceOf(FormData); const uploadedFile = (capturedBody as FormData).get('logoImage') as File; expect(uploadedFile.name).toBe('store-logo.png'); }); it('uploadBrandLogo should send FormData with the logo to the admin endpoint', async () => { const mockFile = new File(['brand-logo-content'], 'brand-logo.svg', { type: 'image/svg+xml', }); await apiClient.uploadBrandLogo(2, mockFile); expect(capturedUrl?.pathname).toBe('/api/admin/brands/2/logo'); expect(capturedBody).toBeInstanceOf(FormData); const uploadedFile = (capturedBody as FormData).get('logoImage') as File; expect(uploadedFile.name).toBe('brand-logo.svg'); }); }); describe('Specific API Functions', () => { beforeEach(() => { localStorage.setItem('authToken', 'specific-api-token'); }); it('getAuthenticatedUserProfile should call the correct endpoint', async () => { await apiClient.getAuthenticatedUserProfile(); expect(capturedUrl?.pathname).toBe('/api/users/profile'); }); it('fetchWatchedItems should call the correct endpoint', async () => { await apiClient.fetchWatchedItems(); expect(capturedUrl?.pathname).toBe('/api/users/watched-items'); }); it('addWatchedItem should send a POST request with the correct body', async () => { const watchedItemData = createMockWatchedItemPayload({ itemName: 'Apples', category: 'Produce', }); await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category); expect(capturedUrl?.pathname).toBe('/api/users/watched-items'); expect(capturedBody).toEqual(watchedItemData); }); it('removeWatchedItem should send a DELETE request to the correct URL', async () => { await apiClient.removeWatchedItem(99); expect(capturedUrl?.pathname).toBe('/api/users/watched-items/99'); }); }); describe('Budget API Functions', () => { it('getBudgets should call the correct endpoint', async () => { server.use(http.get('http://localhost/api/budgets', () => HttpResponse.json([]))); await apiClient.getBudgets(); expect(capturedUrl?.pathname).toBe('/api/budgets'); }); it('createBudget should send a POST request with budget data', async () => { const budgetData = createMockBudget({ name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01', }); await apiClient.createBudget(budgetData); expect(capturedUrl?.pathname).toBe('/api/budgets'); expect(capturedBody).toEqual(budgetData); }); it('updateBudget should send a PUT request with the correct data and ID', async () => { const budgetUpdates = { amount_cents: 60000 }; await apiClient.updateBudget(123, budgetUpdates); expect(capturedUrl?.pathname).toBe('/api/budgets/123'); expect(capturedBody).toEqual(budgetUpdates); }); it('deleteBudget should send a DELETE request to the correct URL', async () => { await apiClient.deleteBudget(456); expect(capturedUrl?.pathname).toBe('/api/budgets/456'); }); it('getSpendingAnalysis should send a GET request with correct query params', async () => { await apiClient.getSpendingAnalysis('2024-01-01', '2024-01-31'); expect(capturedUrl!.searchParams.get('startDate')).toBe('2024-01-01'); expect(capturedUrl!.searchParams.get('endDate')).toBe('2024-01-31'); }); }); describe('Gamification API Functions', () => { it('getUserAchievements should call the authenticated endpoint', async () => { localStorage.setItem('authToken', 'gamify-token'); await apiClient.getUserAchievements(); expect(capturedUrl?.pathname).toBe('/api/achievements/me'); expect(capturedHeaders).not.toBeNull(); expect(capturedHeaders!.get('Authorization')).toBe('Bearer gamify-token'); }); it('fetchLeaderboard should send a GET request with a limit query param', async () => { await apiClient.fetchLeaderboard(5); expect(capturedUrl).not.toBeNull(); // This assertion ensures capturedUrl is not null for the next line expect(capturedUrl!.pathname).toBe('/api/achievements/leaderboard'); expect(capturedUrl!.searchParams.get('limit')).toBe('5'); }); it('getAchievements should call the public endpoint', async () => { // This is a public endpoint, so no token is needed. await apiClient.getAchievements(); expect(capturedUrl?.pathname).toBe('/api/achievements'); }); it('uploadAvatar should send FormData with the avatar file', async () => { localStorage.setItem('authToken', 'avatar-token'); const mockFile = new File(['avatar-content'], 'my-avatar.png', { type: 'image/png' }); await apiClient.uploadAvatar(mockFile); expect(capturedHeaders!.get('Authorization')).toBe('Bearer avatar-token'); expect(capturedBody).not.toBeNull(); // Using non-null assertion (!) because we asserted not.toBeNull() above. const uploadedFile = (capturedBody as FormData).get('avatar') as File; expect(uploadedFile.name).toBe('my-avatar.png'); }); }); describe('Notification API Functions', () => { it('getNotifications should call the correct endpoint with query params', async () => { await apiClient.getNotifications(10, 20); expect(capturedUrl).not.toBeNull(); expect(capturedUrl!.pathname).toBe('/api/users/notifications'); expect(capturedUrl!.searchParams.get('limit')).toBe('10'); expect(capturedUrl!.searchParams.get('offset')).toBe('20'); }); it('markAllNotificationsAsRead should send a POST request', async () => { await apiClient.markAllNotificationsAsRead(); expect(capturedUrl?.pathname).toBe('/api/users/notifications/mark-all-read'); }); it('markNotificationAsRead should send a POST request to the correct URL', async () => { const notificationId = 123; await apiClient.markNotificationAsRead(notificationId); expect(capturedUrl?.pathname).toBe(`/api/users/notifications/${notificationId}/mark-read`); }); }); describe('Shopping List API Functions', () => { // The beforeEach was testing fetchShoppingLists, so we move that into its own test. it('fetchShoppingLists should call the correct endpoint', async () => { await apiClient.fetchShoppingLists(); expect(capturedUrl?.pathname).toBe('/api/users/shopping-lists'); }); it('fetchShoppingListById should call the correct endpoint', async () => { const listId = 5; await apiClient.fetchShoppingListById(listId); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}`); }); it('createShoppingList should send a POST request with the list name', async () => { await apiClient.createShoppingList('Weekly Groceries'); expect(capturedUrl?.pathname).toBe('/api/users/shopping-lists'); expect(capturedBody).toEqual({ name: 'Weekly Groceries' }); }); it('deleteShoppingList should send a DELETE request to the correct URL', async () => { const listId = 42; server.use( http.delete(`http://localhost/api/users/shopping-lists/${listId}`, () => { return new HttpResponse(null, { status: 204 }); }), ); await apiClient.deleteShoppingList(listId); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}`); }); it('addShoppingListItem should send a POST request with item data', async () => { const listId = 42; const itemData = createMockShoppingListItemPayload({ customItemName: 'Paper Towels' }); await apiClient.addShoppingListItem(listId, itemData); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/items`); expect(capturedBody).toEqual(itemData); }); it('updateShoppingListItem should send a PUT request with update data', async () => { const itemId = 101; const updates = { is_purchased: true }; await apiClient.updateShoppingListItem(itemId, updates); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/items/${itemId}`); expect(capturedBody).toEqual(updates); }); it('removeShoppingListItem should send a DELETE request to the correct URL', async () => { const itemId = 101; await apiClient.removeShoppingListItem(itemId); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/items/${itemId}`); }); it('completeShoppingList should send a POST request with total spent', async () => { const listId = 77; const totalSpentCents = 12345; await apiClient.completeShoppingList(listId, totalSpentCents); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/complete`); expect(capturedBody).toEqual({ totalSpentCents }); }); }); describe('Recipe API Functions', () => { beforeEach(() => { // Most recipe endpoints require authentication. localStorage.setItem('authToken', 'recipe-token'); }); it('getCompatibleRecipes should call the correct endpoint', async () => { await apiClient.getCompatibleRecipes(); expect(capturedUrl?.pathname).toBe('/api/users/me/compatible-recipes'); }); it('forkRecipe should send a POST request to the correct URL', async () => { const recipeId = 99; server.use( http.post(`http://localhost/api/recipes/${recipeId}/fork`, () => { return HttpResponse.json({ success: true }); }), ); await apiClient.forkRecipe(recipeId); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/fork`); }); it('getUserFavoriteRecipes should call the correct endpoint', async () => { await apiClient.getUserFavoriteRecipes(); expect(capturedUrl?.pathname).toBe('/api/users/me/favorite-recipes'); }); it('addFavoriteRecipe should send a POST request with the recipeId', async () => { const recipeId = 123; await apiClient.addFavoriteRecipe(recipeId); expect(capturedUrl?.pathname).toBe('/api/users/me/favorite-recipes'); expect(capturedBody).toEqual({ recipeId }); }); it('removeFavoriteRecipe should send a DELETE request to the correct URL', async () => { const recipeId = 123; await apiClient.removeFavoriteRecipe(recipeId); expect(capturedUrl?.pathname).toBe(`/api/users/me/favorite-recipes/${recipeId}`); }); it('getRecipeComments should call the public endpoint', async () => { const recipeId = 456; await apiClient.getRecipeComments(recipeId); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`); }); it('getRecipeById should call the correct public endpoint', async () => { const recipeId = 789; await apiClient.getRecipeById(recipeId); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}`); }); it('addRecipeComment should send a POST request with content and optional parentId', async () => { const recipeId = 456; const commentData = createMockRecipeCommentPayload({ content: 'This is a reply', parentCommentId: 789, }); await apiClient.addRecipeComment(recipeId, commentData.content, commentData.parentCommentId); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`); expect(capturedBody).toEqual(commentData); }); it('deleteRecipe should send a DELETE request to the correct URL', async () => { const recipeId = 101; await apiClient.deleteRecipe(recipeId); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}`); }); it('suggestRecipe should send a POST request with ingredients', async () => { const ingredients = ['chicken', 'rice']; await apiClient.suggestRecipe(ingredients); expect(capturedUrl?.pathname).toBe('/api/recipes/suggest'); expect(capturedBody).toEqual({ ingredients }); }); }); describe('User Profile and Settings API Functions', () => { it('updateUserProfile should send a PUT request with profile data', async () => { localStorage.setItem('authToken', 'user-settings-token'); const profileData = createMockProfileUpdatePayload({ full_name: 'John Doe' }); await apiClient.updateUserProfile(profileData, { tokenOverride: 'override-token' }); expect(capturedUrl?.pathname).toBe('/api/users/profile'); expect(capturedBody).toEqual(profileData); expect(capturedHeaders!.get('Authorization')).toBe('Bearer override-token'); }); it('updateUserPreferences should send a PUT request with preferences data', async () => { const preferences = { darkMode: true }; await apiClient.updateUserPreferences(preferences); expect(capturedUrl?.pathname).toBe('/api/users/profile/preferences'); expect(capturedBody).toEqual(preferences); }); it('updateUserPassword should send a PUT request with the new password', async () => { const passwordData = { newPassword: 'new-secure-password' }; await apiClient.updateUserPassword(passwordData.newPassword, { tokenOverride: 'pw-override-token', }); expect(capturedUrl?.pathname).toBe('/api/users/profile/password'); expect(capturedBody).toEqual(passwordData); expect(capturedHeaders!.get('Authorization')).toBe('Bearer pw-override-token'); }); it('updateUserPassword should send a PUT request with the new password', async () => { const newPassword = 'new-secure-password'; await apiClient.updateUserPassword(newPassword); expect(capturedUrl?.pathname).toBe('/api/users/profile/password'); expect(capturedBody).toEqual({ newPassword }); }); it('exportUserData should call the correct endpoint', async () => { await apiClient.exportUserData(); expect(capturedUrl?.pathname).toBe('/api/users/data-export'); }); it('getUserFeed should call the correct endpoint with query params', async () => { await apiClient.getUserFeed(10, 5); expect(capturedUrl?.pathname).toBe('/api/users/feed'); expect(capturedUrl!.searchParams.get('limit')).toBe('10'); expect(capturedUrl!.searchParams.get('offset')).toBe('5'); }); it('followUser should send a POST request to the correct URL', async () => { const userIdToFollow = 'user-to-follow'; await apiClient.followUser(userIdToFollow); expect(capturedUrl?.pathname).toBe(`/api/users/${userIdToFollow}/follow`); }); it('unfollowUser should send a DELETE request to the correct URL', async () => { const userIdToUnfollow = 'user-to-unfollow'; await apiClient.unfollowUser(userIdToUnfollow); expect(capturedUrl?.pathname).toBe(`/api/users/${userIdToUnfollow}/follow`); }); it('registerUser should send a POST request with user data', async () => { const userData = createMockRegisterUserPayload({ email: 'test@example.com', password: 'password123', full_name: 'Test User', }); await apiClient.registerUser(userData.email, userData.password, userData.full_name); expect(capturedUrl?.pathname).toBe('/api/auth/register'); expect(capturedBody).toEqual(userData); }); it('deleteUserAccount should send a DELETE request with the confirmation password', async () => { const passwordData = { password: 'current-password-for-confirmation' }; await apiClient.deleteUserAccount(passwordData.password); expect(capturedUrl?.pathname).toBe('/api/users/account'); expect(capturedBody).toEqual(passwordData); }); it('setUserDietaryRestrictions should send a PUT request with restriction IDs', async () => { const restrictionData = { restrictionIds: [1, 5] }; await apiClient.setUserDietaryRestrictions(restrictionData.restrictionIds); expect(capturedUrl?.pathname).toBe('/api/users/me/dietary-restrictions'); expect(capturedBody).toEqual(restrictionData); }); it('setUserAppliances should send a PUT request with appliance IDs', async () => { const applianceData = { applianceIds: [2, 8] }; await apiClient.setUserAppliances(applianceData.applianceIds, { tokenOverride: 'appliance-override', }); expect(capturedUrl?.pathname).toBe('/api/users/appliances'); expect(capturedBody).toEqual(applianceData); expect(capturedHeaders!.get('Authorization')).toBe('Bearer appliance-override'); }); it('updateUserAddress should send a PUT request with address data', async () => { const addressData = createMockAddressPayload({ address_line_1: '123 Main St', city: 'Anytown', }); await apiClient.updateUserAddress(addressData); expect(capturedUrl?.pathname).toBe('/api/users/profile/address'); expect(capturedBody).toEqual(addressData); }); it('geocodeAddress should send a POST request with address data', async () => { const address = '1600 Amphitheatre Parkway, Mountain View, CA'; await apiClient.geocodeAddress(address); expect(capturedUrl?.pathname).toBe('/api/system/geocode'); expect(capturedBody).toEqual({ address }); }); it('getUserAddress should call the correct endpoint', async () => { const addressId = 99; await apiClient.getUserAddress(addressId); expect(capturedUrl?.pathname).toBe(`/api/users/addresses/${addressId}`); }); it('getPantryLocations should call the correct endpoint', async () => { await apiClient.getPantryLocations(); expect(capturedUrl?.pathname).toBe('/api/pantry/locations'); }); it('createPantryLocation should send a POST request with the location name', async () => { await apiClient.createPantryLocation('Fridge'); expect(capturedUrl?.pathname).toBe('/api/pantry/locations'); expect(capturedBody).toEqual({ name: 'Fridge' }); }); it('getShoppingTripHistory should call the correct endpoint', async () => { localStorage.setItem('authToken', 'user-settings-token'); await apiClient.getShoppingTripHistory(); expect(capturedUrl?.pathname).toBe('/api/users/shopping-history'); expect(capturedHeaders!.get('Authorization')).toBe('Bearer user-settings-token'); }); it('requestPasswordReset should send a POST request with email', async () => { const email = 'forgot@example.com'; await apiClient.requestPasswordReset(email); expect(capturedUrl?.pathname).toBe('/api/auth/forgot-password'); expect(capturedBody).toEqual({ email }); }); it('resetPassword should send a POST request with token and new password', async () => { const data = { token: 'reset-token', newPassword: 'new-password' }; await apiClient.resetPassword(data.token, data.newPassword); expect(capturedUrl?.pathname).toBe('/api/auth/reset-password'); expect(capturedBody).toEqual(data); }); }); describe('Public API Functions', () => { it('pingBackend should call the correct health check endpoint', async () => { await apiClient.pingBackend(); expect(capturedUrl?.pathname).toBe('/api/health/ping'); }); it('checkDbSchema should call the correct health check endpoint', async () => { server.use( http.get('http://localhost/api/health/db-schema', () => { return HttpResponse.json({ success: true }); }), ); await apiClient.checkDbSchema(); expect(capturedUrl?.pathname).toBe('/api/health/db-schema'); }); it('checkStorage should call the correct health check endpoint', async () => { server.use( http.get('http://localhost/api/health/storage', () => { return HttpResponse.json({ success: true }); }), ); await apiClient.checkStorage(); expect(capturedUrl?.pathname).toBe('/api/health/storage'); }); it('checkDbPoolHealth should call the correct health check endpoint', async () => { server.use( http.get('http://localhost/api/health/db-pool', () => { return HttpResponse.json({ success: true }); }), ); await apiClient.checkDbPoolHealth(); expect(capturedUrl?.pathname).toBe('/api/health/db-pool'); }); it('checkRedisHealth should call the correct health check endpoint', async () => { server.use( http.get('http://localhost/api/health/redis', () => { return HttpResponse.json({ success: true }); }), ); await apiClient.checkRedisHealth(); expect(capturedUrl?.pathname).toBe('/api/health/redis'); }); it('getQueueHealth should call the correct health check endpoint', async () => { server.use( http.get('http://localhost/api/health/queues', () => { return HttpResponse.json({}); }), ); await apiClient.getQueueHealth(); expect(capturedUrl?.pathname).toBe('/api/health/queues'); }); it('checkPm2Status should call the correct system endpoint', async () => { server.use( http.get('http://localhost/api/system/pm2-status', () => { return HttpResponse.json({ success: true }); }), ); await apiClient.checkPm2Status(); expect(capturedUrl?.pathname).toBe('/api/system/pm2-status'); }); it('fetchFlyers should call the correct public endpoint', async () => { server.use( http.get('http://localhost/api/flyers', () => { return HttpResponse.json([]); }), ); await apiClient.fetchFlyers(); expect(capturedUrl?.pathname).toBe('/api/flyers'); }); it('fetchMasterItems should call the correct public endpoint', async () => { server.use( http.get('http://localhost/api/personalization/master-items', () => { return HttpResponse.json([]); }), ); await apiClient.fetchMasterItems(); expect(capturedUrl?.pathname).toBe('/api/personalization/master-items'); }); it('fetchCategories should call the correct public endpoint', async () => { server.use( http.get('http://localhost/api/categories', () => { return HttpResponse.json([]); }), ); await apiClient.fetchCategories(); expect(capturedUrl?.pathname).toBe('/api/categories'); }); it('fetchFlyerItems should call the correct public endpoint for a specific flyer', async () => { const flyerId = 123; server.use( http.get(`http://localhost/api/flyers/${flyerId}/items`, () => { return HttpResponse.json([]); }), ); await apiClient.fetchFlyerItems(flyerId); expect(capturedUrl?.pathname).toBe(`/api/flyers/${flyerId}/items`); }); it('fetchFlyerById should call the correct public endpoint for a specific flyer', async () => { const flyerId = 456; await apiClient.fetchFlyerById(flyerId); expect(capturedUrl?.pathname).toBe(`/api/flyers/${flyerId}`); }); it('fetchFlyerItemsForFlyers should send a POST request with flyer IDs', async () => { const flyerIds = [1, 2, 3]; await apiClient.fetchFlyerItemsForFlyers(flyerIds); expect(capturedUrl?.pathname).toBe('/api/flyers/items/batch-fetch'); expect(capturedBody).toEqual({ flyerIds }); }); it('fetchFlyerItemsForFlyers should return an empty array response if flyerIds is empty', async () => { const response = await apiClient.fetchFlyerItemsForFlyers([]); const data = await response.json(); expect(data).toEqual([]); }); it('countFlyerItemsForFlyers should return a zero count response if flyerIds is empty', async () => { const response = await apiClient.countFlyerItemsForFlyers([]); const data = await response.json(); expect(data).toEqual({ count: 0 }); }); it('countFlyerItemsForFlyers should send a POST request with flyer IDs', async () => { const flyerIds = [1, 2, 3]; await apiClient.countFlyerItemsForFlyers(flyerIds); expect(capturedUrl?.pathname).toBe('/api/flyers/items/batch-count'); expect(capturedBody).toEqual({ flyerIds }); }); it('fetchHistoricalPriceData should send a POST request with master item IDs', async () => { const masterItemIds = [10, 20]; await apiClient.fetchHistoricalPriceData(masterItemIds); expect(capturedUrl?.pathname).toBe('/api/price-history'); expect(capturedBody).toEqual({ masterItemIds }); }); it('fetchHistoricalPriceData should return an empty array response if masterItemIds is empty', async () => { const response = await apiClient.fetchHistoricalPriceData([]); const data = await response.json(); expect(data).toEqual([]); }); }); describe('Admin API Functions', () => { it('approveCorrection should send a POST request to the correct URL', async () => { const correctionId = 45; await apiClient.approveCorrection(correctionId); expect(capturedUrl?.pathname).toBe(`/api/admin/corrections/${correctionId}/approve`); }); it('updateRecipeStatus should send a PUT request with the correct body', async () => { const recipeId = 78; const statusUpdate = { status: 'public' as const }; await apiClient.updateRecipeStatus(recipeId, 'public'); expect(capturedUrl?.pathname).toBe(`/api/admin/recipes/${recipeId}/status`); expect(capturedBody).toEqual(statusUpdate); }); it('cleanupFlyerFiles should send a POST request to the correct URL', async () => { const flyerId = 99; await apiClient.cleanupFlyerFiles(flyerId); expect(capturedUrl?.pathname).toBe(`/api/admin/flyers/${flyerId}/cleanup`); }); it('triggerFailingJob should send a POST request to the correct URL', async () => { await apiClient.triggerFailingJob(); expect(capturedUrl?.pathname).toBe('/api/admin/trigger/failing-job'); }); it('clearGeocodeCache should send a POST request to the correct URL', async () => { await apiClient.clearGeocodeCache(); expect(capturedUrl?.pathname).toBe('/api/admin/system/clear-geocode-cache'); }); it('getApplicationStats should call the correct endpoint', async () => { await apiClient.getApplicationStats(); expect(capturedUrl?.pathname).toBe('/api/admin/stats'); }); it('getSuggestedCorrections should call the correct endpoint', async () => { await apiClient.getSuggestedCorrections(); expect(capturedUrl?.pathname).toBe('/api/admin/corrections'); }); it('getFlyersForReview should call the correct endpoint', async () => { await apiClient.getFlyersForReview(); expect(capturedUrl?.pathname).toBe('/api/admin/review/flyers'); }); it('rejectCorrection should send a POST request to the correct URL', async () => { const correctionId = 46; await apiClient.rejectCorrection(correctionId); expect(capturedUrl?.pathname).toBe(`/api/admin/corrections/${correctionId}/reject`); }); it('updateSuggestedCorrection should send a PUT request with the new value', async () => { const correctionId = 47; const newValue = 'new value'; await apiClient.updateSuggestedCorrection(correctionId, newValue); expect(capturedUrl?.pathname).toBe(`/api/admin/corrections/${correctionId}`); expect(capturedBody).toEqual({ suggested_value: newValue }); }); it('getUnmatchedFlyerItems should call the correct endpoint', async () => { await apiClient.getUnmatchedFlyerItems(); expect(capturedUrl?.pathname).toBe('/api/admin/unmatched-items'); }); it('updateRecipeCommentStatus should send a PUT request with the status', async () => { const commentId = 88; await apiClient.updateRecipeCommentStatus(commentId, 'hidden'); expect(capturedUrl?.pathname).toBe(`/api/admin/comments/${commentId}/status`); expect(capturedBody).toEqual({ status: 'hidden' }); }); it('fetchAllBrands should call the correct endpoint', async () => { await apiClient.fetchAllBrands(); expect(capturedUrl?.pathname).toBe('/api/admin/brands'); }); it('getDailyStats should call the correct endpoint', async () => { await apiClient.getDailyStats(); expect(capturedUrl?.pathname).toBe('/api/admin/stats/daily'); }); it('updateUserRole should send a PUT request with the new role', async () => { const userId = 'user-to-promote'; await apiClient.updateUserRole(userId, 'admin'); expect(capturedUrl?.pathname).toBe(`/api/admin/users/${userId}/role`); expect(capturedBody).toEqual({ role: 'admin' }); }); }); describe('Analytics API Functions', () => { it('trackFlyerItemInteraction should send a POST request with interaction type', async () => { await apiClient.trackFlyerItemInteraction(123, 'click'); expect(capturedUrl?.pathname).toBe('/api/flyer-items/123/track'); expect(capturedBody).toEqual({ type: 'click' }); }); it('logSearchQuery should send a POST request with query data', async () => { const queryData = createMockSearchQueryPayload({ query_text: 'apples', result_count: 10, was_successful: true, }); await apiClient.logSearchQuery(queryData as any); expect(capturedUrl?.pathname).toBe('/api/search/log'); expect(capturedBody).toEqual(queryData); }); it('trackFlyerItemInteraction should log a warning on failure', async () => { const apiError = new Error('Network failed'); // Mock global.fetch to throw an error directly to ensure the catch block is hit. vi.spyOn(global, 'fetch').mockImplementationOnce(() => { throw apiError; }); const { logger } = await import('./logger.client'); // We can now await this properly because we added 'return' in apiClient.ts await apiClient.trackFlyerItemInteraction(123, 'click'); expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', { error: apiError, }); }); it('logSearchQuery should log a warning on failure', async () => { const apiError = new Error('Network failed'); // Mock global.fetch to throw an error directly to ensure the catch block is hit. vi.spyOn(global, 'fetch').mockImplementationOnce(() => { throw apiError; }); const { logger } = await import('./logger.client'); const queryData = createMockSearchQueryPayload({ query_text: 'test', result_count: 0, was_successful: false, }); await apiClient.logSearchQuery(queryData as any); expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError }); }); }); describe('Authentication API Functions', () => { it('loginUser should send a POST request with credentials', async () => { const loginData = createMockLoginPayload({ email: 'test@example.com', password: 'password123', rememberMe: true, }); await apiClient.loginUser(loginData.email, loginData.password, loginData.rememberMe); expect(capturedUrl?.pathname).toBe('/api/auth/login'); expect(capturedBody).toEqual(loginData); }); }); describe('Admin Activity Log', () => { it('fetchActivityLog should call the correct endpoint with query params', async () => { await apiClient.fetchActivityLog(50, 10); expect(capturedUrl?.pathname).toBe('/api/admin/activity-log'); expect(capturedUrl!.searchParams.get('limit')).toBe('50'); expect(capturedUrl!.searchParams.get('offset')).toBe('10'); }); }); describe('AI API Functions', () => { it('uploadAndProcessFlyer should send FormData with file and checksum', async () => { const mockFile = new File(['flyer-content'], 'flyer.pdf', { type: 'application/pdf' }); const checksum = 'checksum-abc-123'; await apiClient.uploadAndProcessFlyer(mockFile, checksum); expect(capturedUrl?.pathname).toBe('/api/ai/upload-and-process'); expect(capturedBody).toBeInstanceOf(FormData); const uploadedFile = (capturedBody as FormData).get('flyerFile') as File; const sentChecksum = (capturedBody as FormData).get('checksum'); expect(uploadedFile.name).toBe('flyer.pdf'); expect(sentChecksum).toBe(checksum); }); it('getJobStatus should call the correct endpoint', async () => { const jobId = 'job-xyz-789'; await apiClient.getJobStatus(jobId); expect(capturedUrl?.pathname).toBe(`/api/ai/jobs/${jobId}/status`); }); }); describe('Receipt API Functions', () => { it('uploadReceipt should send FormData with the receipt image', async () => { const mockFile = new File(['receipt-content'], 'receipt.jpg', { type: 'image/jpeg' }); await apiClient.uploadReceipt(mockFile); expect(capturedUrl?.pathname).toBe('/api/receipts/upload'); expect(capturedBody).toBeInstanceOf(FormData); const uploadedFile = (capturedBody as FormData).get('receiptImage') as File; expect(uploadedFile.name).toBe('receipt.jpg'); }); it('getDealsForReceipt should call the correct endpoint', async () => { const receiptId = 55; await apiClient.getDealsForReceipt(receiptId); expect(capturedUrl?.pathname).toBe(`/api/receipts/${receiptId}/deals`); }); }); describe('Public Personalization API Functions', () => { it('getDietaryRestrictions should call the correct endpoint', async () => { await apiClient.getDietaryRestrictions(); expect(capturedUrl?.pathname).toBe('/api/personalization/dietary-restrictions'); }); it('getAppliances should call the correct endpoint', async () => { await apiClient.getAppliances(); expect(capturedUrl?.pathname).toBe('/api/personalization/appliances'); }); it('getUserDietaryRestrictions should call the correct endpoint', async () => { await apiClient.getUserDietaryRestrictions(); expect(capturedUrl?.pathname).toBe('/api/users/me/dietary-restrictions'); }); it('getUserAppliances should call the correct endpoint', async () => { await apiClient.getUserAppliances(); expect(capturedUrl?.pathname).toBe('/api/users/appliances'); }); }); });