Files
flyer-crawler.projectium.com/src/services/apiClient.test.ts
2025-12-24 18:18:28 -08:00

1065 lines
42 KiB
TypeScript

// 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';
// 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<string, string> = {};
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<string, unknown> | 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<Response> => {
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,
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 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('Analytics API Functions', () => {
it('trackFlyerItemInteraction should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
// 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 { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
await apiClient.logSearchQuery({
query_text: 'test',
result_count: 0,
was_successful: false,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
});
});
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 () => {
await apiClient.addWatchedItem('Apples', 'Produce');
expect(capturedUrl?.pathname).toBe('/api/users/watched-items');
expect(capturedBody).toEqual({ itemName: 'Apples', category: 'Produce' });
});
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 = {
name: 'Groceries',
amount_cents: 50000,
period: 'monthly' as const,
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 = { 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 = { 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}`);
});
});
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 = { 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 () => {
await apiClient.registerUser('test@example.com', 'password123', 'Test User');
expect(capturedUrl?.pathname).toBe('/api/auth/register');
expect(capturedBody).toEqual({
email: 'test@example.com',
password: 'password123',
full_name: 'Test User',
avatar_url: undefined,
});
});
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 = { 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('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('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 = { query_text: 'apples', result_count: 10, was_successful: true };
await apiClient.logSearchQuery(queryData);
expect(capturedUrl?.pathname).toBe('/api/search/log');
expect(capturedBody).toEqual(queryData);
});
it('trackFlyerItemInteraction should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
// 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,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to track flyer item interaction', {
error: apiError,
});
});
it('logSearchQuery should log a warning on failure', async () => {
const { logger } = await import('./logger.client');
const apiError = new Error('Network failed');
vi.mocked(global.fetch).mockRejectedValue(apiError);
await apiClient.logSearchQuery({
query_text: 'test',
result_count: 0,
was_successful: false,
});
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
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 () => {
await apiClient.loginUser('test@example.com', 'password123', true);
expect(capturedUrl?.pathname).toBe('/api/auth/login');
expect(capturedBody).toEqual({
email: 'test@example.com',
password: 'password123',
rememberMe: true,
});
});
});
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');
});
});
});