1065 lines
42 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|