From 8592633c22a1099bdf245ad0b0e0ae608095f054 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Sun, 28 Dec 2025 14:42:11 -0800 Subject: [PATCH] unit test fixes --- src/services/aiApiClient.test.ts | 1 + src/services/aiApiClient.ts | 126 +-- src/services/apiClient.test.ts | 87 +- src/services/apiClient.ts | 897 +++++------------- src/services/flyerDataTransformer.test.ts | 3 - src/services/flyerProcessingService.server.ts | 17 +- src/services/queueService.workers.test.ts | 10 +- src/services/workers.server.test.ts | 10 +- src/tests/utils/mockFactories.ts | 64 ++ 9 files changed, 400 insertions(+), 815 deletions(-) diff --git a/src/services/aiApiClient.test.ts b/src/services/aiApiClient.test.ts index b4a4147..64bf08b 100644 --- a/src/services/aiApiClient.test.ts +++ b/src/services/aiApiClient.test.ts @@ -19,6 +19,7 @@ vi.mock('./logger.client', () => ({ debug: vi.fn(), info: vi.fn(), error: vi.fn(), + warn: vi.fn(), }, })); diff --git a/src/services/aiApiClient.ts b/src/services/aiApiClient.ts index 330196e..2cec34b 100644 --- a/src/services/aiApiClient.ts +++ b/src/services/aiApiClient.ts @@ -12,7 +12,7 @@ import type { GroundedResponse, } from '../types'; import { logger } from './logger.client'; -import { apiFetch } from './apiClient'; +import { apiFetch, authedGet, authedPost, authedPostForm } from './apiClient'; /** * Uploads a flyer file to the backend to be processed asynchronously. @@ -33,14 +33,7 @@ export const uploadAndProcessFlyer = async ( logger.info(`[aiApiClient] Starting background processing for file: ${file.name}`); - const response = await apiFetch( - '/ai/upload-and-process', - { - method: 'POST', - body: formData, - }, - { tokenOverride }, - ); + const response = await authedPostForm('/ai/upload-and-process', formData, { tokenOverride }); if (!response.ok) { let errorBody; @@ -101,7 +94,7 @@ export const getJobStatus = async ( jobId: string, tokenOverride?: string, ): Promise => { - const response = await apiFetch(`/ai/jobs/${jobId}/status`, {}, { tokenOverride }); + const response = await authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride }); // Handle non-OK responses first, as they might not have a JSON body. if (!response.ok) { @@ -141,6 +134,10 @@ export const getJobStatus = async ( return statusData; } catch (error) { + // If it's the specific error we threw, just re-throw it. + if (error instanceof JobFailedError) { + throw error; + } // This now primarily catches JSON parsing errors on an OK response, which is unexpected. logger.error('getJobStatus failed to parse a successful API response.', { error }); throw new Error('Failed to parse job status from a successful API response.'); @@ -156,14 +153,7 @@ export const isImageAFlyer = ( // Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type. // The URL must be relative, as the helper constructs the full path. - return apiFetch( - '/ai/check-flyer', - { - method: 'POST', - body: formData, - }, - { tokenOverride }, - ); + return authedPostForm('/ai/check-flyer', formData, { tokenOverride }); }; export const extractAddressFromImage = ( @@ -173,14 +163,7 @@ export const extractAddressFromImage = ( const formData = new FormData(); formData.append('image', imageFile); - return apiFetch( - '/ai/extract-address', - { - method: 'POST', - body: formData, - }, - { tokenOverride }, - ); + return authedPostForm('/ai/extract-address', formData, { tokenOverride }); }; export const extractLogoFromImage = ( @@ -192,14 +175,7 @@ export const extractLogoFromImage = ( formData.append('images', file); }); - return apiFetch( - '/ai/extract-logo', - { - method: 'POST', - body: formData, - }, - { tokenOverride }, - ); + return authedPostForm('/ai/extract-logo', formData, { tokenOverride }); }; export const getQuickInsights = ( @@ -207,16 +183,7 @@ export const getQuickInsights = ( signal?: AbortSignal, tokenOverride?: string, ): Promise => { - return apiFetch( - '/ai/quick-insights', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items }), - signal, - }, - { tokenOverride, signal }, - ); + return authedPost('/ai/quick-insights', { items }, { tokenOverride, signal }); }; export const getDeepDiveAnalysis = ( @@ -224,16 +191,7 @@ export const getDeepDiveAnalysis = ( signal?: AbortSignal, tokenOverride?: string, ): Promise => { - return apiFetch( - '/ai/deep-dive', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items }), - signal, - }, - { tokenOverride, signal }, - ); + return authedPost('/ai/deep-dive', { items }, { tokenOverride, signal }); }; export const searchWeb = ( @@ -241,16 +199,7 @@ export const searchWeb = ( signal?: AbortSignal, tokenOverride?: string, ): Promise => { - return apiFetch( - '/ai/search-web', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal, - }, - { tokenOverride, signal }, - ); + return authedPost('/ai/search-web', { query }, { tokenOverride, signal }); }; // ============================================================================ @@ -265,15 +214,7 @@ export const planTripWithMaps = async ( tokenOverride?: string, ): Promise => { logger.debug('Stub: planTripWithMaps called with location:', { userLocation }); - return apiFetch( - '/ai/plan-trip', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items, store, userLocation }), - }, - { signal, tokenOverride }, - ); + return authedPost('/ai/plan-trip', { items, store, userLocation }, { signal, tokenOverride }); }; /** @@ -287,16 +228,7 @@ export const generateImageFromText = ( tokenOverride?: string, ): Promise => { logger.debug('Stub: generateImageFromText called with prompt:', { prompt }); - return apiFetch( - '/ai/generate-image', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt }), - signal, - }, - { tokenOverride, signal }, - ); + return authedPost('/ai/generate-image', { prompt }, { tokenOverride, signal }); }; /** @@ -310,16 +242,7 @@ export const generateSpeechFromText = ( tokenOverride?: string, ): Promise => { logger.debug('Stub: generateSpeechFromText called with text:', { text }); - return apiFetch( - '/ai/generate-speech', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - signal, - }, - { tokenOverride, signal }, - ); + return authedPost('/ai/generate-speech', { text }, { tokenOverride, signal }); }; /** @@ -372,11 +295,7 @@ export const rescanImageArea = ( formData.append('cropArea', JSON.stringify(cropArea)); formData.append('extractionType', extractionType); - return apiFetch( - '/ai/rescan-area', - { method: 'POST', body: formData }, - { tokenOverride }, - ); + return authedPostForm('/ai/rescan-area', formData, { tokenOverride }); }; /** @@ -390,12 +309,5 @@ export const compareWatchedItemPrices = ( ): Promise => { // Use the apiFetch wrapper for consistency with other API calls in this file. // This centralizes token handling and base URL logic. - return apiFetch( - '/ai/compare-prices', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items: watchedItems }), - }, - { signal }, - )}; + return authedPost('/ai/compare-prices', { items: watchedItems }, { signal }); +}; diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index c97d073..362d173 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -7,6 +7,17 @@ import { http, HttpResponse } from 'msw'; 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', () => ({ @@ -229,33 +240,6 @@ describe('API Client', () => { }); }); - 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'); @@ -317,10 +301,11 @@ describe('API Client', () => { }); it('addWatchedItem should send a POST request with the correct body', async () => { - await apiClient.addWatchedItem('Apples', 'Produce'); + 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({ itemName: 'Apples', category: 'Produce' }); + expect(capturedBody).toEqual(watchedItemData); }); it('removeWatchedItem should send a DELETE request to the correct URL', async () => { @@ -337,12 +322,12 @@ describe('API Client', () => { }); it('createBudget should send a POST request with budget data', async () => { - const budgetData = { + const budgetData = createMockBudget({ name: 'Groceries', amount_cents: 50000, - period: 'monthly' as const, + period: 'monthly', start_date: '2024-01-01', - }; + }); await apiClient.createBudget(budgetData); expect(capturedUrl?.pathname).toBe('/api/budgets'); @@ -461,7 +446,7 @@ describe('API Client', () => { it('addShoppingListItem should send a POST request with item data', async () => { const listId = 42; - const itemData = { customItemName: 'Paper Towels' }; + const itemData = createMockShoppingListItemPayload({ customItemName: 'Paper Towels' }); await apiClient.addShoppingListItem(listId, itemData); expect(capturedUrl?.pathname).toBe(`/api/users/shopping-lists/${listId}/items`); @@ -547,7 +532,7 @@ describe('API Client', () => { 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 }; + 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); @@ -563,7 +548,7 @@ describe('API Client', () => { 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' }; + 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); @@ -619,14 +604,14 @@ describe('API Client', () => { }); 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({ + const userData = createMockRegisterUserPayload({ email: 'test@example.com', password: 'password123', full_name: 'Test User', - avatar_url: undefined, }); + 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 () => { @@ -654,7 +639,7 @@ describe('API Client', () => { }); it('updateUserAddress should send a PUT request with address data', async () => { - const addressData = { address_line_1: '123 Main St', city: 'Anytown' }; + 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); @@ -942,53 +927,49 @@ describe('API Client', () => { }); it('logSearchQuery should send a POST request with query data', async () => { - const queryData = { query_text: 'apples', result_count: 10, was_successful: true }; + const queryData = createMockSearchQueryPayload({ 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); + 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, }); - - 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); + const { logger } = await import('./logger.client'); - await apiClient.logSearchQuery({ + const queryData = createMockSearchQueryPayload({ query_text: 'test', result_count: 0, was_successful: false, }); - expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError }); - + await apiClient.logSearchQuery(queryData); 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({ + 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); }); }); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index e6aaff9..5bd0946 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -154,88 +154,142 @@ export const apiFetch = async ( return response; }; +// --- API Helper Functions --- + +/** Helper for public GET requests */ +export const publicGet = (endpoint: string): Promise => { + return fetch(`${API_BASE_URL}${endpoint}`); +}; + +/** Helper for public POST requests with a JSON body */ +export const publicPost = (endpoint: string, body: T): Promise => { + return fetch(`${API_BASE_URL}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +}; + +/** Helper for authenticated GET requests */ +export const authedGet = (endpoint: string, options: ApiOptions = {}): Promise => { + return apiFetch(endpoint, { method: 'GET' }, options); +}; + +/** Helper for authenticated POST requests with a JSON body */ +export const authedPost = (endpoint: string, body: T, options: ApiOptions = {}): Promise => { + return apiFetch( + endpoint, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, + options, + ); +}; + +/** Helper for authenticated POST requests with an empty body */ +export const authedPostEmpty = (endpoint: string, options: ApiOptions = {}): Promise => { + return apiFetch(endpoint, { method: 'POST' }, options); +}; + +/** Helper for authenticated POST requests with FormData */ +export const authedPostForm = ( + endpoint: string, + formData: FormData, + options: ApiOptions = {}, +): Promise => { + return apiFetch(endpoint, { method: 'POST', body: formData }, options); +}; + +/** Helper for authenticated PUT requests with a JSON body */ +export const authedPut = (endpoint: string, body: T, options: ApiOptions = {}): Promise => { + return apiFetch( + endpoint, + { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, + options, + ); +}; + +/** Helper for authenticated DELETE requests */ +export const authedDelete = (endpoint: string, options: ApiOptions = {}): Promise => { + return apiFetch(endpoint, { method: 'DELETE' }, options); +}; + +/** Helper for authenticated DELETE requests with a JSON body */ +export const authedDeleteWithBody = ( + endpoint: string, + body: T, + options: ApiOptions = {}, +): Promise => { + return apiFetch( + endpoint, + { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + options, + ); +}; + /** * Pings the backend server to check if it's running and reachable. * @returns A promise that resolves to true if the server responds with 'pong'. */ -export const pingBackend = async (): Promise => { - // This should return the response for the caller to handle. - return fetch(`${API_BASE_URL}/health/ping`); -}; +export const pingBackend = (): Promise => publicGet('/health/ping'); /** * Checks the backend's database schema. * @returns A promise that resolves to an object with success status and a message. */ -export const checkDbSchema = async (): Promise => { - return fetch(`${API_BASE_URL}/health/db-schema`); -}; +export const checkDbSchema = (): Promise => publicGet('/health/db-schema'); /** * Checks the backend's storage directory. * @returns A promise that resolves to an object with success status and a message. */ -export const checkStorage = async (): Promise => { - return fetch(`${API_BASE_URL}/health/storage`); -}; +export const checkStorage = (): Promise => publicGet('/health/storage'); /** * Checks the backend's database connection pool health. * @returns A promise that resolves to an object with success status and a message. */ -export const checkDbPoolHealth = async (): Promise => { - return fetch(`${API_BASE_URL}/health/db-pool`); -}; +export const checkDbPoolHealth = (): Promise => publicGet('/health/db-pool'); /** * Checks the backend's Redis connection health. * @returns A promise that resolves to an object with success status and a message. */ -export const checkRedisHealth = async (): Promise => { - return fetch(`${API_BASE_URL}/health/redis`); -}; +export const checkRedisHealth = (): Promise => publicGet('/health/redis'); /** * Checks the status of the application process managed by PM2. * This is intended for development and diagnostic purposes. */ -export const checkPm2Status = async (): Promise => { - // This is a public health check, so we can use standard fetch. - return fetch(`${API_BASE_URL}/system/pm2-status`); -}; +export const checkPm2Status = (): Promise => publicGet('/system/pm2-status'); /** * Fetches all flyers from the backend. * @returns A promise that resolves to an array of Flyer objects. */ -export const fetchFlyers = async (): Promise => { - return fetch(`${API_BASE_URL}/flyers`); -}; +export const fetchFlyers = (): Promise => publicGet('/flyers'); /** * Fetches a single flyer by its ID. * @param flyerId The ID of the flyer to fetch. * @returns A promise that resolves to the API response. */ -export const fetchFlyerById = async (flyerId: number): Promise => { - return fetch(`${API_BASE_URL}/flyers/${flyerId}`); -}; +export const fetchFlyerById = (flyerId: number): Promise => + publicGet(`/flyers/${flyerId}`); /** * Fetches all master grocery items from the backend. * @returns A promise that resolves to an array of MasterGroceryItem objects. */ -export const fetchMasterItems = async (): Promise => { - return fetch(`${API_BASE_URL}/personalization/master-items`); -}; +export const fetchMasterItems = (): Promise => publicGet('/personalization/master-items'); /** * Fetches all categories from the backend. * @returns A promise that resolves to an array of Category objects. */ -export const fetchCategories = async (): Promise => { - return fetch(`${API_BASE_URL}/categories`); -}; +export const fetchCategories = (): Promise => publicGet('/categories'); // --- Flyer Processing API Function --- @@ -246,7 +300,7 @@ export const fetchCategories = async (): Promise => { * @param tokenOverride Optional token for testing. * @returns A promise that resolves to the API response, which should contain a `jobId`. */ -export const uploadAndProcessFlyer = async ( +export const uploadAndProcessFlyer = ( file: File, checksum: string, tokenOverride?: string, @@ -254,30 +308,18 @@ export const uploadAndProcessFlyer = async ( const formData = new FormData(); formData.append('flyerFile', file); formData.append('checksum', checksum); - return apiFetch( - '/ai/upload-and-process', - { - method: 'POST', - body: formData, - }, - { tokenOverride }, - ); + return authedPostForm('/ai/upload-and-process', formData, { tokenOverride }); }; // --- Flyer Item API Functions --- -export const fetchFlyerItems = async (flyerId: number): Promise => { - return fetch(`${API_BASE_URL}/flyers/${flyerId}/items`); -}; +export const fetchFlyerItems = (flyerId: number): Promise => + publicGet(`/flyers/${flyerId}/items`); export const fetchFlyerItemsForFlyers = async (flyerIds: number[]): Promise => { if (flyerIds.length === 0) { return new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' } }); } - return fetch(`${API_BASE_URL}/flyers/items/batch-fetch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ flyerIds }), - }); + return publicPost('/flyers/items/batch-fetch', { flyerIds }); }; export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise => { @@ -286,11 +328,7 @@ export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise => { const formData = new FormData(); formData.append('logoImage', logoImage); - - // Use apiFetch to ensure the user is authenticated to perform this action. - return apiFetch( - `/stores/${storeId}/logo`, - { - method: 'POST', - body: formData, - // Do not set Content-Type for FormData, browser handles it. - }, - { tokenOverride }, - ); + return authedPostForm(`/stores/${storeId}/logo`, formData, { tokenOverride }); }; /** @@ -327,24 +355,14 @@ export const uploadLogoAndUpdateStore = async ( * @param logoImage The logo image file. * @returns A promise that resolves with the new logo URL. */ -export const uploadBrandLogo = async ( +export const uploadBrandLogo = ( brandId: number, logoImage: File, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('logoImage', logoImage); - - // Use apiFetch to ensure the user is an authenticated admin. - return apiFetch( - `/admin/brands/${brandId}/logo`, - { - method: 'POST', - body: formData, - // Do not set Content-Type for FormData, browser handles it. - }, - { tokenOverride }, - ); + return authedPostForm(`/admin/brands/${brandId}/logo`, formData, { tokenOverride }); }; /** @@ -360,148 +378,67 @@ export const fetchHistoricalPriceData = async ( // Return a Response with an empty array return new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' } }); } - - return apiFetch( - `/price-history`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ masterItemIds }), - }, - { tokenOverride }, - ); + return authedPost('/price-history', { masterItemIds }, { tokenOverride }); }; // --- Watched Items API Functions --- -export const fetchWatchedItems = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/watched-items`, {}, { tokenOverride }); -}; +export const fetchWatchedItems = (tokenOverride?: string): Promise => + authedGet('/users/watched-items', { tokenOverride }); -export const addWatchedItem = async ( +export const addWatchedItem = ( itemName: string, category: string, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/watched-items`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ itemName, category }), - }, - { tokenOverride }, - ); -}; +): Promise => authedPost('/users/watched-items', { itemName, category }, { tokenOverride }); -export const removeWatchedItem = async ( +export const removeWatchedItem = ( masterItemId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/watched-items/${masterItemId}`, - { - method: 'DELETE', - }, - { tokenOverride }, - ); -}; +): Promise => authedDelete(`/users/watched-items/${masterItemId}`, { tokenOverride }); /** * Fetches the best current sale prices for all of the user's watched items. * @param tokenOverride Optional token for testing. * @returns A promise that resolves to an array of WatchedItemDeal objects. */ -export const fetchBestSalePrices = async (tokenOverride?: string): Promise => { - // This endpoint assumes an authenticated user session. - return apiFetch(`/users/deals/best-watched-prices`, {}, { tokenOverride }); -}; +export const fetchBestSalePrices = (tokenOverride?: string): Promise => + authedGet('/users/deals/best-watched-prices', { tokenOverride }); // --- Shopping List API Functions --- -export const fetchShoppingLists = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/shopping-lists`, {}, { tokenOverride }); -}; +export const fetchShoppingLists = (tokenOverride?: string): Promise => + authedGet('/users/shopping-lists', { tokenOverride }); -export const fetchShoppingListById = async ( +export const fetchShoppingListById = ( listId: number, tokenOverride?: string, -): Promise => { - return apiFetch(`/users/shopping-lists/${listId}`, {}, { tokenOverride }); -}; +): Promise => authedGet(`/users/shopping-lists/${listId}`, { tokenOverride }); -export const createShoppingList = async ( - name: string, - tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/shopping-lists`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }, - { tokenOverride }, - ); -}; +export const createShoppingList = (name: string, tokenOverride?: string): Promise => + authedPost('/users/shopping-lists', { name }, { tokenOverride }); -export const deleteShoppingList = async ( - listId: number, - tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/shopping-lists/${listId}`, - { - method: 'DELETE', - }, - { tokenOverride }, - ); -}; +export const deleteShoppingList = (listId: number, tokenOverride?: string): Promise => + authedDelete(`/users/shopping-lists/${listId}`, { tokenOverride }); -export const addShoppingListItem = async ( +export const addShoppingListItem = ( listId: number, item: { masterItemId?: number; customItemName?: string }, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/shopping-lists/${listId}/items`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(item), - }, - { tokenOverride }, - ); -}; +): Promise => + authedPost(`/users/shopping-lists/${listId}/items`, item, { tokenOverride }); -export const updateShoppingListItem = async ( +export const updateShoppingListItem = ( itemId: number, updates: Partial, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/shopping-lists/items/${itemId}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }, - { tokenOverride }, - ); -}; +): Promise => + authedPut(`/users/shopping-lists/items/${itemId}`, updates, { tokenOverride }); -export const removeShoppingListItem = async ( +export const removeShoppingListItem = ( itemId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/shopping-lists/items/${itemId}`, - { - method: 'DELETE', - }, - { tokenOverride }, - ); -}; +): Promise => authedDelete(`/users/shopping-lists/items/${itemId}`, { tokenOverride }); /** * Fetches the full profile for the currently authenticated user. @@ -509,16 +446,10 @@ export const removeShoppingListItem = async ( * @returns A promise that resolves to the user's combined UserProfile object. * @throws An error if the request fails or if the user is not authenticated. */ -export const getAuthenticatedUserProfile = async (options: ApiOptions = {}): Promise => { +export const getAuthenticatedUserProfile = (options: ApiOptions = {}): Promise => { // The token is now passed to apiFetch, which handles the Authorization header. // If no token is provided (in browser context), apiFetch will get it from localStorage. - return apiFetch( - `/users/profile`, - { - method: 'GET', - }, - options, - ); + return authedGet('/users/profile', options); }; export async function loginUser( @@ -526,15 +457,7 @@ export async function loginUser( password: string, rememberMe: boolean, ): Promise { - // This function already returns a Response, so it's correct. - // I'm just simplifying it slightly. - return fetch(`${API_BASE_URL}/auth/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, password, rememberMe }), - }); + return publicPost('/auth/login', { email, password, rememberMe }); } // --- Receipt Processing API Functions --- @@ -544,22 +467,13 @@ export async function loginUser( * @param receiptImage The image file of the receipt. * @returns A promise that resolves with the backend's response, including the newly created receipt record. */ -export const uploadReceipt = async ( +export const uploadReceipt = ( receiptImage: File, tokenOverride?: string, ): Promise => { const formData = new FormData(); formData.append('receiptImage', receiptImage); - - // Use apiFetch, which now correctly handles FormData. - return apiFetch( - `/receipts/upload`, - { - method: 'POST', - body: formData, - }, - { tokenOverride }, - ); + return authedPostForm('/receipts/upload', formData, { tokenOverride }); }; /** @@ -567,12 +481,8 @@ export const uploadReceipt = async ( * @param receiptId The ID of the processed receipt. * @returns A promise that resolves to an array of ReceiptDeal objects. */ -export const getDealsForReceipt = async ( - receiptId: number, - tokenOverride?: string, -): Promise => { - return apiFetch(`/receipts/${receiptId}/deals`, {}, { tokenOverride }); -}; +export const getDealsForReceipt = (receiptId: number, tokenOverride?: string): Promise => + authedGet(`/receipts/${receiptId}/deals`, { tokenOverride }); // --- Analytics & Shopping Enhancement API Functions --- @@ -609,124 +519,63 @@ export const logSearchQuery = async ( .catch((error) => logger.warn('Failed to log search query', { error })); }; -export const getPantryLocations = async (tokenOverride?: string): Promise => { - return apiFetch(`/pantry/locations`, {}, { tokenOverride }); -}; +export const getPantryLocations = (tokenOverride?: string): Promise => + authedGet('/pantry/locations', { tokenOverride }); -export const createPantryLocation = async ( - name: string, - tokenOverride?: string, -): Promise => { - return apiFetch( - `/pantry/locations`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }, - { tokenOverride }, - ); -}; +export const createPantryLocation = (name: string, tokenOverride?: string): Promise => + authedPost('/pantry/locations', { name }, { tokenOverride }); -export const completeShoppingList = async ( +export const completeShoppingList = ( shoppingListId: number, totalSpentCents?: number, tokenOverride?: string, -): Promise => { - return apiFetch( +): Promise => + authedPost( `/users/shopping-lists/${shoppingListId}/complete`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ totalSpentCents }), - }, + { totalSpentCents }, { tokenOverride }, ); -}; -export const getShoppingTripHistory = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/shopping-history`, {}, { tokenOverride }); -}; +export const getShoppingTripHistory = (tokenOverride?: string): Promise => + authedGet('/users/shopping-history', { tokenOverride }); // --- Personalization & Social API Functions --- -export const getDietaryRestrictions = async (): Promise => { - return fetch(`${API_BASE_URL}/personalization/dietary-restrictions`); -}; +export const getDietaryRestrictions = (): Promise => + publicGet('/personalization/dietary-restrictions'); -export const getAppliances = async (): Promise => { - return fetch(`${API_BASE_URL}/personalization/appliances`); -}; +export const getAppliances = (): Promise => publicGet('/personalization/appliances'); -export const getUserDietaryRestrictions = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/me/dietary-restrictions`, {}, { tokenOverride }); -}; +export const getUserDietaryRestrictions = (tokenOverride?: string): Promise => + authedGet('/users/me/dietary-restrictions', { tokenOverride }); -export const setUserDietaryRestrictions = async ( +export const setUserDietaryRestrictions = ( restrictionIds: number[], tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/me/dietary-restrictions`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ restrictionIds }), - }, - { tokenOverride }, - ); -}; +): Promise => + authedPut('/users/me/dietary-restrictions', { restrictionIds }, { tokenOverride }); -export const getCompatibleRecipes = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/me/compatible-recipes`, {}, { tokenOverride }); -}; +export const getCompatibleRecipes = (tokenOverride?: string): Promise => + authedGet('/users/me/compatible-recipes', { tokenOverride }); -export const getUserFeed = async ( +export const getUserFeed = ( limit: number = 20, offset: number = 0, tokenOverride?: string, -): Promise => { - return apiFetch(`/users/feed?limit=${limit}&offset=${offset}`, {}, { tokenOverride }); -}; +): Promise => authedGet(`/users/feed?limit=${limit}&offset=${offset}`, { tokenOverride }); -export const forkRecipe = async ( +export const forkRecipe = ( originalRecipeId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/recipes/${originalRecipeId}/fork`, - { - method: 'POST', - }, - { tokenOverride }, - ); -}; +): Promise => authedPostEmpty(`/recipes/${originalRecipeId}/fork`, { tokenOverride }); -export const followUser = async ( - userIdToFollow: string, - tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/${userIdToFollow}/follow`, - { - method: 'POST', - }, - { tokenOverride }, - ); -}; +export const followUser = (userIdToFollow: string, tokenOverride?: string): Promise => + authedPostEmpty(`/users/${userIdToFollow}/follow`, { tokenOverride }); -export const unfollowUser = async ( +export const unfollowUser = ( userIdToUnfollow: string, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/${userIdToUnfollow}/follow`, - { - method: 'DELETE', - }, - { tokenOverride }, - ); -}; +): Promise => authedDelete(`/users/${userIdToUnfollow}/follow`, { tokenOverride }); // --- Activity Log API Function --- @@ -736,85 +585,53 @@ export const unfollowUser = async ( * @param offset The starting offset for pagination. * @returns A promise that resolves to an array of ActivityLogItem objects. */ -export const fetchActivityLog = async ( +export const fetchActivityLog = ( limit: number = 20, offset: number = 0, tokenOverride?: string, -): Promise => { - return apiFetch(`/admin/activity-log?limit=${limit}&offset=${offset}`, {}, { tokenOverride }); -}; +): Promise => + authedGet(`/admin/activity-log?limit=${limit}&offset=${offset}`, { tokenOverride }); // --- Favorite Recipes API Functions --- /** * Retrieves a list of the currently authenticated user's favorite recipes. - * @param {string} [tokenOverride] Optional token for testing purposes. - * @returns {Promise} A promise that resolves to the API response. + * @param tokenOverride Optional token for testing purposes. + * @returns A promise that resolves to the API response. */ -export const getUserFavoriteRecipes = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/me/favorite-recipes`, {}, { tokenOverride }); -}; +export const getUserFavoriteRecipes = (tokenOverride?: string): Promise => + authedGet('/users/me/favorite-recipes', { tokenOverride }); -export const addFavoriteRecipe = async ( +export const addFavoriteRecipe = ( recipeId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/me/favorite-recipes`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ recipeId }), - }, - { tokenOverride }, - ); -}; +): Promise => authedPost('/users/me/favorite-recipes', { recipeId }, { tokenOverride }); -export const removeFavoriteRecipe = async ( +export const removeFavoriteRecipe = ( recipeId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/me/favorite-recipes/${recipeId}`, - { - method: 'DELETE', - }, - { tokenOverride }, - ); -}; +): Promise => authedDelete(`/users/me/favorite-recipes/${recipeId}`, { tokenOverride }); // --- Recipe Comments API Functions --- -export const getRecipeComments = async (recipeId: number): Promise => { - // This is a public endpoint, so we can use standard fetch. - return fetch(`${API_BASE_URL}/recipes/${recipeId}/comments`); // This was a duplicate, fixed. -}; +export const getRecipeComments = (recipeId: number): Promise => + publicGet(`/recipes/${recipeId}/comments`); /** * Fetches a single recipe by its ID. * @param recipeId The ID of the recipe to fetch. * @returns A promise that resolves to the API response. */ -export const getRecipeById = async (recipeId: number): Promise => { - return fetch(`${API_BASE_URL}/recipes/${recipeId}`); -}; +export const getRecipeById = (recipeId: number): Promise => + publicGet(`/recipes/${recipeId}`); -export const addRecipeComment = async ( +export const addRecipeComment = ( recipeId: number, content: string, parentCommentId?: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/recipes/${recipeId}/comments`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, parentCommentId }), - }, - { tokenOverride }, - ); -}; +): Promise => + authedPost(`/recipes/${recipeId}/comments`, { content, parentCommentId }, { tokenOverride }); /** * Deletes a recipe. @@ -822,54 +639,32 @@ export const addRecipeComment = async ( * @param tokenOverride Optional token for testing purposes. * @returns {Promise} A promise that resolves to the API response. */ -export const deleteRecipe = async (recipeId: number, tokenOverride?: string): Promise => { - return apiFetch(`/recipes/${recipeId}`, { method: 'DELETE' }, { tokenOverride }); -}; +export const deleteRecipe = (recipeId: number, tokenOverride?: string): Promise => + authedDelete(`/recipes/${recipeId}`, { tokenOverride }); // --- Admin API Functions for New Features --- -export const getUnmatchedFlyerItems = async (tokenOverride?: string): Promise => { - return apiFetch(`${API_BASE_URL}/admin/unmatched-items`, {}, { tokenOverride }); -}; +export const getUnmatchedFlyerItems = (tokenOverride?: string): Promise => + authedGet('/admin/unmatched-items', { tokenOverride }); -export const updateRecipeStatus = async ( +export const updateRecipeStatus = ( recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected', tokenOverride?: string, -): Promise => { - return apiFetch( - `/admin/recipes/${recipeId}/status`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status }), - }, - { tokenOverride }, - ); -}; +): Promise => + authedPut(`/admin/recipes/${recipeId}/status`, { status }, { tokenOverride }); -export const updateRecipeCommentStatus = async ( +export const updateRecipeCommentStatus = ( commentId: number, status: 'visible' | 'hidden' | 'reported', tokenOverride?: string, -): Promise => { - return apiFetch( - `/admin/comments/${commentId}/status`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status }), - }, - { tokenOverride }, - ); -}; +): Promise => authedPut(`/admin/comments/${commentId}/status`, { status }, { tokenOverride }); /** * Fetches all brands from the backend. Requires admin privileges. * @returns A promise that resolves to an array of Brand objects. */ -export const fetchAllBrands = async (tokenOverride?: string): Promise => { - return apiFetch(`/admin/brands`, {}, { tokenOverride }); -}; +export const fetchAllBrands = (tokenOverride?: string): Promise => + authedGet('/admin/brands', { tokenOverride }); export interface AppStats { // This interface should ideally be in types.ts flyerCount: number; @@ -890,64 +685,40 @@ export interface DailyStat { * Fetches daily user registration and flyer upload stats for the last 30 days. * @returns A promise that resolves to an array of daily stat objects. */ -export const getDailyStats = async (tokenOverride?: string): Promise => { - return apiFetch(`${API_BASE_URL}/admin/stats/daily`, {}, { tokenOverride }); -}; +export const getDailyStats = (tokenOverride?: string): Promise => + authedGet('/admin/stats/daily', { tokenOverride }); /** * Fetches application-wide statistics. Requires admin privileges. * @returns A promise that resolves to an object containing app stats. */ -export const getApplicationStats = async (tokenOverride?: string): Promise => { - return apiFetch(`${API_BASE_URL}/admin/stats`, {}, { tokenOverride }); -}; +export const getApplicationStats = (tokenOverride?: string): Promise => + authedGet('/admin/stats', { tokenOverride }); // --- Admin Correction API Functions --- -export const getSuggestedCorrections = async (tokenOverride?: string): Promise => { - return apiFetch(`${API_BASE_URL}/admin/corrections`, {}, { tokenOverride }); -}; +export const getSuggestedCorrections = (tokenOverride?: string): Promise => + authedGet('/admin/corrections', { tokenOverride }); -export const approveCorrection = async ( +export const approveCorrection = ( correctionId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/admin/corrections/${correctionId}/approve`, - { - method: 'POST', - }, - { tokenOverride }, - ); -}; +): Promise => authedPostEmpty(`/admin/corrections/${correctionId}/approve`, { tokenOverride }); -export const rejectCorrection = async ( +export const rejectCorrection = ( correctionId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/admin/corrections/${correctionId}/reject`, - { - method: 'POST', - }, - { tokenOverride }, - ); -}; +): Promise => authedPostEmpty(`/admin/corrections/${correctionId}/reject`, { tokenOverride }); -export const updateSuggestedCorrection = async ( +export const updateSuggestedCorrection = ( correctionId: number, newSuggestedValue: string, tokenOverride?: string, -): Promise => { - return apiFetch( +): Promise => + authedPut( `/admin/corrections/${correctionId}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ suggested_value: newSuggestedValue }), - }, + { suggested_value: newSuggestedValue }, { tokenOverride }, ); -}; /** * Enqueues a job to clean up the files associated with a specific flyer. @@ -955,22 +726,16 @@ export const updateSuggestedCorrection = async ( * @param flyerId The ID of the flyer to clean up. * @param tokenOverride Optional token for testing. */ -export const cleanupFlyerFiles = async ( - flyerId: number, - tokenOverride?: string, -): Promise => { - return apiFetch(`/admin/flyers/${flyerId}/cleanup`, { method: 'POST' }, { tokenOverride }); -}; +export const cleanupFlyerFiles = (flyerId: number, tokenOverride?: string): Promise => + authedPostEmpty(`/admin/flyers/${flyerId}/cleanup`, { tokenOverride }); /** * Enqueues a test job designed to fail, for testing the Bull Board UI. * Requires admin privileges. * @param tokenOverride Optional token for testing. */ -export const triggerFailingJob = async (tokenOverride?: string): Promise => { - // This is an admin-only endpoint, so we use apiFetch to include the auth token. - return apiFetch(`/admin/trigger/failing-job`, { method: 'POST' }, { tokenOverride }); -}; +export const triggerFailingJob = (tokenOverride?: string): Promise => + authedPostEmpty('/admin/trigger/failing-job', { tokenOverride }); /** * Fetches the status of a background processing job. @@ -978,31 +743,28 @@ export const triggerFailingJob = async (tokenOverride?: string): Promise => { - return apiFetch(`/ai/jobs/${jobId}/status`, {}, { tokenOverride }); -}; +export const getJobStatus = (jobId: string, tokenOverride?: string): Promise => + authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride }); /** * Triggers the clearing of the geocoding cache on the server. * Requires admin privileges. * @param tokenOverride Optional token for testing. */ -export const clearGeocodeCache = async (tokenOverride?: string): Promise => { - return apiFetch(`/admin/system/clear-geocode-cache`, { method: 'POST' }, { tokenOverride }); -}; +export const clearGeocodeCache = (tokenOverride?: string): Promise => + authedPostEmpty('/admin/system/clear-geocode-cache', { tokenOverride }); -export async function registerUser( +export function registerUser( email: string, password: string, fullName?: string, avatarUrl?: string, ): Promise { - return fetch(`${API_BASE_URL}/auth/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, password, full_name: fullName, avatar_url: avatarUrl }), + return publicPost('/auth/register', { + email, + password, + full_name: fullName, + avatar_url: avatarUrl, }); } @@ -1011,14 +773,8 @@ export async function registerUser( * @param email The user's email address. * @returns A promise that resolves with a success message. */ -export async function requestPasswordReset(email: string): Promise { - return fetch(`${API_BASE_URL}/auth/forgot-password`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email }), - }); +export function requestPasswordReset(email: string): Promise { + return publicPost('/auth/forgot-password', { email }); } /** @@ -1027,14 +783,8 @@ export async function requestPasswordReset(email: string): Promise { * @param newPassword The user's new password. * @returns A promise that resolves with a success message. */ -export async function resetPassword(token: string, newPassword: string): Promise { - return fetch(`${API_BASE_URL}/auth/reset-password`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ token, newPassword }), - }); +export function resetPassword(token: string, newPassword: string): Promise { + return publicPost('/auth/reset-password', { token, newPassword }); } /** * Sends updated user preferences to the backend. @@ -1045,15 +795,7 @@ export async function updateUserPreferences( preferences: Partial, apiOptions: ApiOptions = {}, ): Promise { - return apiFetch( - `${API_BASE_URL}/users/profile/preferences`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(preferences), - }, - apiOptions, - ); + return authedPut('/users/profile/preferences', preferences, apiOptions); } /** @@ -1061,110 +803,59 @@ export async function updateUserPreferences( * @param profileData An object containing the full_name and/or avatar_url to update. * @returns A promise that resolves to the user's full, updated profile object. */ -export async function updateUserProfile( +export function updateUserProfile( profileData: Partial, apiOptions: ApiOptions = {}, ): Promise { - return apiFetch( - `${API_BASE_URL}/users/profile`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(profileData), - }, - apiOptions, - ); + return authedPut('/users/profile', profileData, apiOptions); } /** * Fetches a complete export of the user's data from the backend. * @returns A promise that resolves to a JSON object of the user's data. */ -export async function exportUserData(apiOptions: ApiOptions = {}): Promise { - return apiFetch( - `${API_BASE_URL}/users/data-export`, - { - method: 'GET', - }, - apiOptions, - ); +export function exportUserData(apiOptions: ApiOptions = {}): Promise { + return authedGet('/users/data-export', apiOptions); } -export const getUserAppliances = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/appliances`, {}, { tokenOverride }); -}; +export const getUserAppliances = (tokenOverride?: string): Promise => + authedGet('/users/appliances', { tokenOverride }); /** * Sets the kitchen appliances for the currently authenticated user. * This will replace all existing appliances with the new set. * @param applianceIds An array of numbers representing the IDs of the selected appliances. */ -export const setUserAppliances = async ( +export const setUserAppliances = ( applianceIds: number[], apiOptions: ApiOptions = {}, -): Promise => { - return apiFetch( - `/users/appliances`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ applianceIds }), - }, - apiOptions, - ); -}; +): Promise => authedPut('/users/appliances', { applianceIds }, apiOptions); /** * Sends an address string to the backend to be geocoded. * @param address The full address string. * @param tokenOverride Optional token for testing. */ -export const geocodeAddress = async ( - address: string, - apiOptions: ApiOptions = {}, -): Promise => { - return apiFetch( - `/system/geocode`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address }), - }, - apiOptions, - ); -}; +export const geocodeAddress = (address: string, apiOptions: ApiOptions = {}): Promise => + authedPost('/system/geocode', { address }, apiOptions); /** * Fetches a specific address by its ID. * @param addressId The ID of the address to fetch. * @param tokenOverride Optional token for testing. */ -export const getUserAddress = async ( - addressId: number, - apiOptions: ApiOptions = {}, -): Promise => { - return apiFetch(`/users/addresses/${addressId}`, {}, apiOptions); -}; +export const getUserAddress = (addressId: number, apiOptions: ApiOptions = {}): Promise => + authedGet(`/users/addresses/${addressId}`, apiOptions); /** * Creates or updates the authenticated user's primary address. * @param addressData The full address object. * @param tokenOverride Optional token for testing. */ -export const updateUserAddress = async ( +export const updateUserAddress = ( addressData: Partial
, apiOptions: ApiOptions = {}, -): Promise => { - return apiFetch( - `/users/profile/address`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(addressData), - }, - apiOptions, - ); -}; +): Promise => authedPut('/users/profile/address', addressData, apiOptions); /** * Sends a new password to the backend to be updated. @@ -1175,15 +866,7 @@ export async function updateUserPassword( newPassword: string, apiOptions: ApiOptions = {}, ): Promise { - return apiFetch( - `/users/profile/password`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newPassword }), - }, - apiOptions, - ); + return authedPut('/users/profile/password', { newPassword }, apiOptions); } /** @@ -1197,15 +880,7 @@ export async function updateUserRole( role: 'user' | 'admin', apiOptions: ApiOptions = {}, ): Promise { - return apiFetch( - `/admin/users/${userId}/role`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role }), - }, - apiOptions, - ); + return authedPut(`/admin/users/${userId}/role`, { role }, apiOptions); } /** @@ -1217,15 +892,7 @@ export async function deleteUserAccount( password: string, apiOptions: ApiOptions = {}, ): Promise { - return apiFetch( - `/users/account`, - { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }), - }, - apiOptions, - ); + return authedDeleteWithBody('/users/account', { password }, apiOptions); } // --- Notification API Functions --- @@ -1236,37 +903,30 @@ export async function deleteUserAccount( * @param offset The number of notifications to skip for pagination. * @returns A promise that resolves to the API response. */ -export const getNotifications = async ( +export const getNotifications = ( limit: number = 20, offset: number = 0, tokenOverride?: string, -): Promise => { - return apiFetch(`/users/notifications?limit=${limit}&offset=${offset}`, {}, { tokenOverride }); -}; +): Promise => + authedGet(`/users/notifications?limit=${limit}&offset=${offset}`, { tokenOverride }); /** * Marks all of the user's unread notifications as read. * @returns A promise that resolves to the API response. */ -export const markAllNotificationsAsRead = async (tokenOverride?: string): Promise => { - return apiFetch(`/users/notifications/mark-all-read`, { method: 'POST' }, { tokenOverride }); -}; +export const markAllNotificationsAsRead = (tokenOverride?: string): Promise => + authedPostEmpty('/users/notifications/mark-all-read', { tokenOverride }); /** * Marks a single notification as read. * @param notificationId The ID of the notification to mark as read. * @returns A promise that resolves to the API response. */ -export const markNotificationAsRead = async ( +export const markNotificationAsRead = ( notificationId: number, tokenOverride?: string, -): Promise => { - return apiFetch( - `/users/notifications/${notificationId}/mark-read`, - { method: 'POST' }, - { tokenOverride }, - ); -}; +): Promise => + authedPostEmpty(`/users/notifications/${notificationId}/mark-read`, { tokenOverride }); // --- Budgeting and Spending Analysis API Functions --- @@ -1274,29 +934,18 @@ export const markNotificationAsRead = async ( * Fetches all budgets for the authenticated user. * @returns A promise that resolves to the API response. */ -export const getBudgets = async (tokenOverride?: string): Promise => { - return apiFetch(`/budgets`, {}, { tokenOverride }); -}; +export const getBudgets = (tokenOverride?: string): Promise => + authedGet('/budgets', { tokenOverride }); /** * Creates a new budget for the authenticated user. * @param budgetData The data for the new budget. * @returns A promise that resolves to the API response. */ -export const createBudget = async ( +export const createBudget = ( budgetData: Omit, tokenOverride?: string, -): Promise => { - return apiFetch( - `/budgets`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(budgetData), - }, - { tokenOverride }, - ); -}; +): Promise => authedPost('/budgets', budgetData, { tokenOverride }); /** * Updates an existing budget for the authenticated user. @@ -1304,30 +953,19 @@ export const createBudget = async ( * @param budgetData The data to update. * @returns A promise that resolves to the API response. */ -export const updateBudget = async ( +export const updateBudget = ( budgetId: number, budgetData: Partial>, tokenOverride?: string, -): Promise => { - return apiFetch( - `/budgets/${budgetId}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(budgetData), - }, - { tokenOverride }, - ); -}; +): Promise => authedPut(`/budgets/${budgetId}`, budgetData, { tokenOverride }); /** * Deletes a budget for the authenticated user. * @param budgetId The ID of the budget to delete. * @returns A promise that resolves to the API response. */ -export const deleteBudget = async (budgetId: number, tokenOverride?: string): Promise => { - return apiFetch(`/budgets/${budgetId}`, { method: 'DELETE' }, { tokenOverride }); -}; +export const deleteBudget = (budgetId: number, tokenOverride?: string): Promise => + authedDelete(`/budgets/${budgetId}`, { tokenOverride }); /** * Fetches the user's spending analysis for a given date range. @@ -1335,17 +973,12 @@ export const deleteBudget = async (budgetId: number, tokenOverride?: string): Pr * @param endDate The end date of the analysis period (YYYY-MM-DD). * @returns A promise that resolves to the API response. */ -export const getSpendingAnalysis = async ( +export const getSpendingAnalysis = ( startDate: string, endDate: string, tokenOverride?: string, -): Promise => { - return apiFetch( - `/budgets/spending-analysis?startDate=${startDate}&endDate=${endDate}`, - {}, - { tokenOverride }, - ); -}; +): Promise => + authedGet(`/budgets/spending-analysis?startDate=${startDate}&endDate=${endDate}`, { tokenOverride }); // --- Gamification API Functions --- @@ -1354,29 +987,23 @@ export const getSpendingAnalysis = async ( * This is a public endpoint. * @returns A promise that resolves to the API response. */ -export const getAchievements = async (): Promise => { - // This is a public endpoint, so we can use standard fetch. - return fetch(`${API_BASE_URL}/achievements`); -}; +export const getAchievements = (): Promise => publicGet('/achievements'); /** * Fetches all achievements earned by the currently authenticated user. * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response. */ -export const getUserAchievements = async (tokenOverride?: string): Promise => { - return apiFetch(`/achievements/me`, {}, { tokenOverride }); -}; +export const getUserAchievements = (tokenOverride?: string): Promise => + authedGet('/achievements/me', { tokenOverride }); /** * Fetches the public leaderboard of top users by points. * @param limit The number of users to fetch. Defaults to 10. * @returns A promise that resolves to the API response. */ -export const fetchLeaderboard = async (limit: number = 10): Promise => { - // This is a public endpoint, so we can use standard fetch. - return fetch(`${API_BASE_URL}/achievements/leaderboard?limit=${limit}`); -}; +export const fetchLeaderboard = (limit: number = 10): Promise => + publicGet(`/achievements/leaderboard?limit=${limit}`); /** * Uploads a new avatar image for the authenticated user. @@ -1384,10 +1011,8 @@ export const fetchLeaderboard = async (limit: number = 10): Promise => * @param tokenOverride Optional token for testing purposes. * @returns A promise that resolves to the API response containing the updated profile. */ -export const uploadAvatar = async (avatarFile: File, tokenOverride?: string): Promise => { +export const uploadAvatar = (avatarFile: File, tokenOverride?: string): Promise => { const formData = new FormData(); formData.append('avatar', avatarFile); - - // Use apiFetch, which now correctly handles FormData. - return apiFetch('/users/profile/avatar', { method: 'POST', body: formData }, { tokenOverride }); + return authedPostForm('/users/profile/avatar', formData, { tokenOverride }); }; diff --git a/src/services/flyerDataTransformer.test.ts b/src/services/flyerDataTransformer.test.ts index f4ddd18..eba9165 100644 --- a/src/services/flyerDataTransformer.test.ts +++ b/src/services/flyerDataTransformer.test.ts @@ -109,9 +109,6 @@ describe('FlyerDataTransformer', () => { view_count: 0, click_count: 0, }), - ); // Use a more specific type assertion to check for the added property. - expect((itemsForDb[0] as FlyerItemInsert & { updated_at: string }).updated_at).toBeTypeOf( - 'string', ); // 3. Check that generateFlyerIcon was called correctly diff --git a/src/services/flyerProcessingService.server.ts b/src/services/flyerProcessingService.server.ts index 0a4ca76..5d80d1a 100644 --- a/src/services/flyerProcessingService.server.ts +++ b/src/services/flyerProcessingService.server.ts @@ -8,6 +8,7 @@ import { createFlyerAndItems } from './db/flyer.db'; import { AiDataValidationError, UnsupportedFileTypeError, + FlyerProcessingError, PdfConversionError, } from './processingErrors'; import { FlyerDataTransformer } from './flyerDataTransformer'; @@ -163,25 +164,25 @@ export class FlyerProcessingService { let errorPayload: { errorCode: string; message: string; [key: string]: any }; // Handle our custom, structured processing errors. - if (error instanceof UnsupportedFileTypeError) { + if (wrappedError instanceof FlyerProcessingError) { // Use the properties from the custom error itself. - errorPayload = error.toErrorPayload(); + errorPayload = wrappedError.toErrorPayload(); // Log with specific details based on the error type - if (error instanceof AiDataValidationError) { + if (wrappedError instanceof AiDataValidationError) { logger.error( - { err: error, validationErrors: error.validationErrors, rawData: error.rawData }, + { err: wrappedError, validationErrors: wrappedError.validationErrors, rawData: wrappedError.rawData }, `AI Data Validation failed.`, ); - } else if (error instanceof PdfConversionError) { - logger.error({ err: error, stderr: error.stderr }, `PDF Conversion failed.`); + } else if (wrappedError instanceof PdfConversionError) { + logger.error({ err: wrappedError, stderr: wrappedError.stderr }, `PDF Conversion failed.`); } else { // Generic log for other FlyerProcessingErrors like UnsupportedFileTypeError - logger.error({ err: error }, `${error.name} occurred during processing.`); + logger.error({ err: wrappedError }, `${wrappedError.name} occurred during processing.`); } } else { // Handle generic/unknown errors. logger.error( - { err: error, attemptsMade: job.attemptsMade, totalAttempts: job.opts.attempts }, + { err: wrappedError, attemptsMade: job.attemptsMade, totalAttempts: job.opts.attempts }, `A generic error occurred in job.`, ); errorPayload = { diff --git a/src/services/queueService.workers.test.ts b/src/services/queueService.workers.test.ts index 884e602..2a9db6a 100644 --- a/src/services/queueService.workers.test.ts +++ b/src/services/queueService.workers.test.ts @@ -73,10 +73,12 @@ vi.mock('bullmq', () => ({ vi.mock('./flyerProcessingService.server', () => { // Mock the constructor to return an object with the mocked methods return { - FlyerProcessingService: vi.fn().mockImplementation(() => ({ - processJob: mocks.processFlyerJob, - processCleanupJob: mocks.processCleanupJob, - })), + FlyerProcessingService: vi.fn().mockImplementation(function () { + return { + processJob: mocks.processFlyerJob, + processCleanupJob: mocks.processCleanupJob, + }; + }), }; }); diff --git a/src/services/workers.server.test.ts b/src/services/workers.server.test.ts index 4063eae..efe9362 100644 --- a/src/services/workers.server.test.ts +++ b/src/services/workers.server.test.ts @@ -80,10 +80,12 @@ vi.mock('bullmq', () => ({ vi.mock('./flyerProcessingService.server', () => { // Mock the constructor to return an object with the mocked methods return { - FlyerProcessingService: vi.fn().mockImplementation(() => ({ - processJob: mocks.processFlyerJob, - processCleanupJob: mocks.processCleanupJob, - })), + FlyerProcessingService: vi.fn().mockImplementation(function () { + return { + processJob: mocks.processFlyerJob, + processCleanupJob: mocks.processCleanupJob, + }; + }), }; }); diff --git a/src/tests/utils/mockFactories.ts b/src/tests/utils/mockFactories.ts index 8ec157d..d2f409b 100644 --- a/src/tests/utils/mockFactories.ts +++ b/src/tests/utils/mockFactories.ts @@ -39,6 +39,7 @@ import { ShoppingTripItem, Receipt, ReceiptItem, + SearchQuery, ProcessingStage, UserAlert, UserSubmittedPrice, @@ -1451,3 +1452,66 @@ export const createMockAppliance = (overrides: Partial = {}): Applian ...overrides, }; }; + +// src/tests/utils/mockFactories.ts + +// ... existing factories + +export const createMockShoppingListItemPayload = (overrides: Partial<{ masterItemId: number; customItemName: string }> = {}): { masterItemId?: number; customItemName?: string } => ({ + customItemName: 'Mock Item', + ...overrides, +}); + +export const createMockRecipeCommentPayload = (overrides: Partial<{ content: string; parentCommentId: number }> = {}): { content: string; parentCommentId?: number } => ({ + content: 'This is a mock comment.', + ...overrides, +}); + +export const createMockProfileUpdatePayload = (overrides: Partial = {}): Partial => ({ + full_name: 'Mock User', + ...overrides, +}); + +export const createMockAddressPayload = (overrides: Partial
= {}): Partial
=> ({ + address_line_1: '123 Mock St', + city: 'Mockville', + province_state: 'MS', + postal_code: '12345', + country: 'Mockland', + ...overrides, +}); + +export const createMockSearchQueryPayload = (overrides: Partial> = {}): Omit => ({ + query_text: 'mock search', + result_count: 5, + was_successful: true, + ...overrides, +}); + +export const createMockWatchedItemPayload = (overrides: Partial<{ itemName: string; category: string }> = {}): { itemName: string; category: string } => ({ + itemName: 'Mock Watched Item', + category: 'Pantry', + ...overrides, +}); + +export const createMockRegisterUserPayload = ( + overrides: Partial<{ + email: string; + password: string; + full_name: string; + avatar_url: string | undefined; + }> = {}, +) => ({ + email: 'mock@example.com', + password: 'password123', + full_name: 'Mock User', + avatar_url: undefined, + ...overrides, +}); + +export const createMockLoginPayload = (overrides: Partial<{ email: string; password: string; rememberMe: boolean }> = {}) => ({ + email: 'mock@example.com', + password: 'password123', + rememberMe: false, + ...overrides, +});