more integration tests added
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 2m43s

This commit is contained in:
2025-11-25 20:30:15 -08:00
parent 88ddbddb0e
commit b92818ce1f
7 changed files with 444 additions and 107 deletions

View File

@@ -368,12 +368,12 @@ export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise<numb
* @param logoImage The logo image file.
* @returns A promise that resolves with the new logo URL.
*/
export const uploadLogoAndUpdateStore = async (storeId: number, logoImage: File): Promise<{ logoUrl: string }> => {
export const uploadLogoAndUpdateStore = async (storeId: number, logoImage: File, tokenOverride?: string): Promise<{ logoUrl: string }> => {
const formData = new FormData();
formData.append('logoImage', logoImage);
// Use apiFetch to ensure the user is authenticated to perform this action.
const response = await apiFetch(`${API_BASE_URL}/stores/${storeId}/logo`, {
const response = await apiFetch(`/stores/${storeId}/logo`, {
method: 'POST',
body: formData,
// Do not set Content-Type for FormData, browser handles it.
@@ -388,12 +388,12 @@ export const uploadLogoAndUpdateStore = async (storeId: number, logoImage: File)
* @param logoImage The logo image file.
* @returns A promise that resolves with the new logo URL.
*/
export const uploadBrandLogo = async (brandId: number, logoImage: File): Promise<{ logoUrl: string }> => {
export const uploadBrandLogo = async (brandId: number, logoImage: File, tokenOverride?: string): Promise<{ logoUrl: string }> => {
const formData = new FormData();
formData.append('logoImage', logoImage);
// Use apiFetch to ensure the user is an authenticated admin.
const response = await apiFetch(`${API_BASE_URL}/admin/brands/${brandId}/logo`, {
const response = await apiFetch(`/admin/brands/${brandId}/logo`, {
method: 'POST',
body: formData,
// Do not set Content-Type for FormData, browser handles it.
@@ -408,12 +408,12 @@ export const uploadBrandLogo = async (brandId: number, logoImage: File): Promise
* @param masterItemIds An array of master grocery item IDs.
* @returns A promise that resolves to an array of historical price records.
*/
export const fetchHistoricalPriceData = async (masterItemIds: number[]): Promise<{ master_item_id: number; summary_date: string; avg_price_in_cents: number | null; }[]> => {
export const fetchHistoricalPriceData = async (masterItemIds: number[], tokenOverride?: string): Promise<{ master_item_id: number; summary_date: string; avg_price_in_cents: number | null; }[]> => {
if (masterItemIds.length === 0) {
return [];
}
const response = await apiFetch(`${API_BASE_URL}/price-history`, {
const response = await apiFetch(`/price-history`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterItemIds }),
@@ -429,8 +429,8 @@ export const fetchHistoricalPriceData = async (masterItemIds: number[]): Promise
// --- Watched Items API Functions ---
export const fetchWatchedItems = async (): Promise<MasterGroceryItem[]> => {
const response = await apiFetch(`${API_BASE_URL}/watched-items`);
export const fetchWatchedItems = async (tokenOverride?: string): Promise<MasterGroceryItem[]> => {
const response = await apiFetch(`/watched-items`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch watched items.' }));
throw new Error(errorData.message);
@@ -438,12 +438,12 @@ export const fetchWatchedItems = async (): Promise<MasterGroceryItem[]> => {
return response.json();
};
export const addWatchedItem = async (itemName: string, category: string): Promise<MasterGroceryItem> => {
const response = await apiFetch(`${API_BASE_URL}/watched-items`, {
export const addWatchedItem = async (itemName: string, category: string, tokenOverride?: string): Promise<MasterGroceryItem> => {
const response = await apiFetch(`/watched-items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemName, category }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to add watched item.' }));
throw new Error(errorData.message);
@@ -451,10 +451,10 @@ export const addWatchedItem = async (itemName: string, category: string): Promis
return response.json();
};
export const removeWatchedItem = async (masterItemId: number): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/watched-items/${masterItemId}`, {
export const removeWatchedItem = async (masterItemId: number, tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/watched-items/${masterItemId}`, {
method: 'DELETE',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to remove watched item.' }));
throw new Error(errorData.message);
@@ -463,8 +463,8 @@ export const removeWatchedItem = async (masterItemId: number): Promise<void> =>
// --- Shopping List API Functions ---
export const fetchShoppingLists = async (): Promise<ShoppingList[]> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-lists`);
export const fetchShoppingLists = async (tokenOverride?: string): Promise<ShoppingList[]> => {
const response = await apiFetch(`/shopping-lists`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch shopping lists.' }));
throw new Error(errorData.message);
@@ -472,12 +472,12 @@ export const fetchShoppingLists = async (): Promise<ShoppingList[]> => {
return response.json();
};
export const createShoppingList = async (name: string): Promise<ShoppingList> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-lists`, {
export const createShoppingList = async (name: string, tokenOverride?: string): Promise<ShoppingList> => {
const response = await apiFetch(`/shopping-lists`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to create shopping list.' }));
throw new Error(errorData.message);
@@ -485,22 +485,22 @@ export const createShoppingList = async (name: string): Promise<ShoppingList> =>
return response.json();
};
export const deleteShoppingList = async (listId: number): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/${listId}`, {
export const deleteShoppingList = async (listId: number, tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/shopping-lists/${listId}`, {
method: 'DELETE',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to delete shopping list.' }));
throw new Error(errorData.message);
}
};
export const addShoppingListItem = async (listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/${listId}/items`, {
export const addShoppingListItem = async (listId: number, item: { masterItemId?: number, customItemName?: string }, tokenOverride?: string): Promise<ShoppingListItem> => {
const response = await apiFetch(`/shopping-lists/${listId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to add item to list.' }));
throw new Error(errorData.message);
@@ -508,12 +508,12 @@ export const addShoppingListItem = async (listId: number, item: { masterItemId?:
return response.json();
};
export const updateShoppingListItem = async (itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/items/${itemId}`, {
export const updateShoppingListItem = async (itemId: number, updates: Partial<ShoppingListItem>, tokenOverride?: string): Promise<ShoppingListItem> => {
const response = await apiFetch(`/shopping-lists/items/${itemId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to update list item.' }));
throw new Error(errorData.message);
@@ -521,10 +521,10 @@ export const updateShoppingListItem = async (itemId: number, updates: Partial<Sh
return response.json();
};
export const removeShoppingListItem = async (itemId: number): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/items/${itemId}`, {
export const removeShoppingListItem = async (itemId: number, tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/shopping-lists/items/${itemId}`, {
method: 'DELETE',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to remove list item.' }));
throw new Error(errorData.message);
@@ -581,13 +581,13 @@ export async function loginUser(email: string, password: string, rememberMe: boo
* @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 (receiptImage: File): Promise<{ message: string; receipt: Receipt }> => {
export const uploadReceipt = async (receiptImage: File, tokenOverride?: string): Promise<{ message: string; receipt: Receipt }> => {
const formData = new FormData();
formData.append('receiptImage', receiptImage);
// Use apiFetch to ensure the user is authenticated for this action.
// The browser will automatically set the correct 'Content-Type' for FormData.
const response = await apiFetch(`${API_BASE_URL}/receipts/upload`, {
const response = await apiFetch(`/receipts/upload`, {
method: 'POST',
body: formData,
});
@@ -604,8 +604,8 @@ export const uploadReceipt = async (receiptImage: File): Promise<{ message: stri
* @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): Promise<ReceiptDeal[]> => {
const response = await apiFetch(`/receipts/${receiptId}/deals`);
export const getDealsForReceipt = async (receiptId: number, tokenOverride?: string): Promise<ReceiptDeal[]> => {
const response = await apiFetch(`/receipts/${receiptId}/deals`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch deals for receipt.' }));
throw new Error(errorData.message);
@@ -641,9 +641,9 @@ export const trackFlyerItemInteraction = async (itemId: number, type: 'view' | '
* This is a "fire-and-forget" call.
* @param query The search query data to log.
*/
export const logSearchQuery = async (query: Omit<SearchQuery, 'id' | 'created_at' | 'user_id'>): Promise<void> => {
export const logSearchQuery = async (query: Omit<SearchQuery, 'id' | 'created_at' | 'user_id'>, tokenOverride?: string): Promise<void> => {
try {
apiFetch(`${API_BASE_URL}/search/log`, {
apiFetch(`/search/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query),
@@ -658,8 +658,8 @@ export const logSearchQuery = async (query: Omit<SearchQuery, 'id' | 'created_at
* Fetches all pantry locations for the current user.
* @returns A promise that resolves to an array of PantryLocation objects.
*/
export const getPantryLocations = async (): Promise<PantryLocation[]> => {
const response = await apiFetch(`${API_BASE_URL}/pantry/locations`);
export const getPantryLocations = async (tokenOverride?: string): Promise<PantryLocation[]> => {
const response = await apiFetch(`/pantry/locations`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch pantry locations.' }));
throw new Error(errorData.message);
@@ -672,12 +672,12 @@ export const getPantryLocations = async (): Promise<PantryLocation[]> => {
* @param name The name of the new location (e.g., "Fridge").
* @returns A promise that resolves to the newly created PantryLocation object.
*/
export const createPantryLocation = async (name: string): Promise<PantryLocation> => {
const response = await apiFetch(`${API_BASE_URL}/pantry/locations`, {
export const createPantryLocation = async (name: string, tokenOverride?: string): Promise<PantryLocation> => {
const response = await apiFetch(`/pantry/locations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to create pantry location.' }));
throw new Error(errorData.message);
@@ -691,12 +691,12 @@ export const createPantryLocation = async (name: string): Promise<PantryLocation
* @param totalSpentCents Optional total amount spent on the trip.
* @returns A promise that resolves to an object containing the new shopping trip ID.
*/
export const completeShoppingList = async (shoppingListId: number, totalSpentCents?: number): Promise<{ newTripId: number }> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-lists/${shoppingListId}/complete`, {
export const completeShoppingList = async (shoppingListId: number, totalSpentCents?: number, tokenOverride?: string): Promise<{ newTripId: number }> => {
const response = await apiFetch(`/shopping-lists/${shoppingListId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ totalSpentCents }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to complete shopping list.' }));
throw new Error(errorData.message);
@@ -708,8 +708,8 @@ export const completeShoppingList = async (shoppingListId: number, totalSpentCen
* Fetches the historical shopping trips for the current user.
* @returns A promise that resolves to an array of ShoppingTrip objects.
*/
export const getShoppingTripHistory = async (): Promise<ShoppingTrip[]> => {
const response = await apiFetch(`${API_BASE_URL}/shopping-history`);
export const getShoppingTripHistory = async (tokenOverride?: string): Promise<ShoppingTrip[]> => {
const response = await apiFetch(`/shopping-history`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch shopping trip history.' }));
throw new Error(errorData.message);
@@ -749,8 +749,8 @@ export const getAppliances = async (): Promise<Appliance[]> => {
* Fetches the dietary restrictions for the currently authenticated user.
* @returns A promise that resolves to an array of the user's DietaryRestriction objects.
*/
export const getUserDietaryRestrictions = async (): Promise<DietaryRestriction[]> => {
const response = await apiFetch(`${API_BASE_URL}/users/me/dietary-restrictions`);
export const getUserDietaryRestrictions = async (tokenOverride?: string): Promise<DietaryRestriction[]> => {
const response = await apiFetch(`/users/me/dietary-restrictions`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch user dietary restrictions.' }));
throw new Error(errorData.message);
@@ -763,12 +763,12 @@ export const getUserDietaryRestrictions = async (): Promise<DietaryRestriction[]
* This will replace all existing restrictions with the new set.
* @param restrictionIds An array of numbers representing the IDs of the selected restrictions.
*/
export const setUserDietaryRestrictions = async (restrictionIds: number[]): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/users/me/dietary-restrictions`, {
export const setUserDietaryRestrictions = async (restrictionIds: number[], tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/users/me/dietary-restrictions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ restrictionIds }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to set user dietary restrictions.' }));
throw new Error(errorData.message);
@@ -779,8 +779,8 @@ export const setUserDietaryRestrictions = async (restrictionIds: number[]): Prom
* Fetches recipes that are compatible with the user's dietary restrictions.
* @returns A promise that resolves to an array of compatible Recipe objects.
*/
export const getCompatibleRecipes = async (): Promise<Recipe[]> => {
const response = await apiFetch(`${API_BASE_URL}/users/me/compatible-recipes`);
export const getCompatibleRecipes = async (tokenOverride?: string): Promise<Recipe[]> => {
const response = await apiFetch(`/users/me/compatible-recipes`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch compatible recipes.' }));
throw new Error(errorData.message);
@@ -794,8 +794,8 @@ export const getCompatibleRecipes = async (): Promise<Recipe[]> => {
* @param offset The starting offset for pagination.
* @returns A promise that resolves to an array of ActivityLogItem objects.
*/
export const getUserFeed = async (limit: number = 20, offset: number = 0): Promise<ActivityLogItem[]> => {
const response = await apiFetch(`${API_BASE_URL}/users/me/feed?limit=${limit}&offset=${offset}`);
export const getUserFeed = async (limit: number = 20, offset: number = 0, tokenOverride?: string): Promise<ActivityLogItem[]> => {
const response = await apiFetch(`/users/me/feed?limit=${limit}&offset=${offset}`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch user feed.' }));
throw new Error(errorData.message);
@@ -808,10 +808,10 @@ export const getUserFeed = async (limit: number = 20, offset: number = 0): Promi
* @param originalRecipeId The ID of the recipe to fork.
* @returns A promise that resolves to the newly created Recipe object.
*/
export const forkRecipe = async (originalRecipeId: number): Promise<Recipe> => {
const response = await apiFetch(`${API_BASE_URL}/recipes/${originalRecipeId}/fork`, {
export const forkRecipe = async (originalRecipeId: number, tokenOverride?: string): Promise<Recipe> => {
const response = await apiFetch(`/recipes/${originalRecipeId}/fork`, {
method: 'POST',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fork recipe.' }));
throw new Error(errorData.message);
@@ -823,10 +823,10 @@ export const forkRecipe = async (originalRecipeId: number): Promise<Recipe> => {
* Follows another user.
* @param userIdToFollow The UUID of the user to follow.
*/
export const followUser = async (userIdToFollow: string): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/users/${userIdToFollow}/follow`, {
export const followUser = async (userIdToFollow: string, tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/users/${userIdToFollow}/follow`, {
method: 'POST',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to follow user.' }));
throw new Error(errorData.message);
@@ -837,10 +837,10 @@ export const followUser = async (userIdToFollow: string): Promise<void> => {
* Unfollows another user.
* @param userIdToUnfollow The UUID of the user to unfollow.
*/
export const unfollowUser = async (userIdToUnfollow: string): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/users/${userIdToUnfollow}/follow`, {
export const unfollowUser = async (userIdToUnfollow: string, tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/users/${userIdToUnfollow}/follow`, {
method: 'DELETE',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to unfollow user.' }));
throw new Error(errorData.message);
@@ -855,8 +855,8 @@ export const unfollowUser = async (userIdToUnfollow: string): Promise<void> => {
* @param offset The starting offset for pagination.
* @returns A promise that resolves to an array of ActivityLogItem objects.
*/
export const fetchActivityLog = async (limit: number = 20, offset: number = 0): Promise<ActivityLogItem[]> => {
const response = await apiFetch(`${API_BASE_URL}/activity-log?limit=${limit}&offset=${offset}`);
export const fetchActivityLog = async (limit: number = 20, offset: number = 0, tokenOverride?: string): Promise<ActivityLogItem[]> => {
const response = await apiFetch(`/activity-log?limit=${limit}&offset=${offset}`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch activity log.' }));
throw new Error(errorData.message);
@@ -866,8 +866,8 @@ export const fetchActivityLog = async (limit: number = 20, offset: number = 0):
// --- Favorite Recipes API Functions ---
export const getUserFavoriteRecipes = async (): Promise<Recipe[]> => {
const response = await apiFetch(`${API_BASE_URL}/users/favorite-recipes`);
export const getUserFavoriteRecipes = async (tokenOverride?: string): Promise<Recipe[]> => {
const response = await apiFetch(`/users/favorite-recipes`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch favorite recipes.' }));
throw new Error(errorData.message);
@@ -875,12 +875,12 @@ export const getUserFavoriteRecipes = async (): Promise<Recipe[]> => {
return response.json();
};
export const addFavoriteRecipe = async (recipeId: number): Promise<FavoriteRecipe> => {
const response = await apiFetch(`${API_BASE_URL}/users/favorite-recipes`, {
export const addFavoriteRecipe = async (recipeId: number, tokenOverride?: string): Promise<FavoriteRecipe> => {
const response = await apiFetch(`/users/favorite-recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipeId }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to add favorite recipe.' }));
throw new Error(errorData.message);
@@ -888,10 +888,10 @@ export const addFavoriteRecipe = async (recipeId: number): Promise<FavoriteRecip
return response.json();
};
export const removeFavoriteRecipe = async (recipeId: number): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/users/favorite-recipes/${recipeId}`, {
export const removeFavoriteRecipe = async (recipeId: number, tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/users/favorite-recipes/${recipeId}`, {
method: 'DELETE',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to remove favorite recipe.' }));
throw new Error(errorData.message);
@@ -910,12 +910,12 @@ export const getRecipeComments = async (recipeId: number): Promise<RecipeComment
return response.json();
};
export const addRecipeComment = async (recipeId: number, content: string, parentCommentId?: number): Promise<RecipeComment> => {
const response = await apiFetch(`${API_BASE_URL}/recipes/${recipeId}/comments`, {
export const addRecipeComment = async (recipeId: number, content: string, parentCommentId?: number, tokenOverride?: string): Promise<RecipeComment> => {
const response = await apiFetch(`/recipes/${recipeId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, parentCommentId }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to add recipe comment.' }));
throw new Error(errorData.message);
@@ -946,12 +946,12 @@ export const getUnmatchedFlyerItems = async (): Promise<UnmatchedFlyerItem[]> =>
* @param status The new status for the recipe.
* @returns A promise that resolves to the updated Recipe object.
*/
export const updateRecipeStatus = async (recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected'): Promise<Recipe> => {
const response = await apiFetch(`${API_BASE_URL}/admin/recipes/${recipeId}/status`, {
export const updateRecipeStatus = async (recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected', tokenOverride?: string): Promise<Recipe> => {
const response = await apiFetch(`/admin/recipes/${recipeId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to update recipe status.' }));
throw new Error(errorData.message);
@@ -966,12 +966,12 @@ export const updateRecipeStatus = async (recipeId: number, status: 'private' | '
* @param status The new status for the comment.
* @returns A promise that resolves to the updated RecipeComment object.
*/
export const updateRecipeCommentStatus = async (commentId: number, status: 'visible' | 'hidden' | 'reported'): Promise<RecipeComment> => {
const response = await apiFetch(`${API_BASE_URL}/admin/comments/${commentId}/status`, {
export const updateRecipeCommentStatus = async (commentId: number, status: 'visible' | 'hidden' | 'reported', tokenOverride?: string): Promise<RecipeComment> => {
const response = await apiFetch(`/admin/comments/${commentId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to update recipe comment status.' }));
throw new Error(errorData.message);
@@ -983,8 +983,8 @@ export const updateRecipeCommentStatus = async (commentId: number, status: 'visi
* Fetches all brands from the backend. Requires admin privileges.
* @returns A promise that resolves to an array of Brand objects.
*/
export const fetchAllBrands = async (): Promise<Brand[]> => {
const response = await apiFetch(`${API_BASE_URL}/admin/brands`);
export const fetchAllBrands = async (tokenOverride?: string): Promise<Brand[]> => {
const response = await apiFetch(`${API_BASE_URL}/admin/brands`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch brands.' }));
throw new Error(errorData.message);
@@ -1009,8 +1009,8 @@ 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 (): Promise<DailyStat[]> => {
const response = await apiFetch(`${API_BASE_URL}/admin/stats/daily`);
export const getDailyStats = async (tokenOverride?: string): Promise<DailyStat[]> => {
const response = await apiFetch(`${API_BASE_URL}/admin/stats/daily`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch daily statistics.' }));
throw new Error(errorData.message);
@@ -1021,8 +1021,8 @@ export const getDailyStats = async (): Promise<DailyStat[]> => {
* Fetches application-wide statistics. Requires admin privileges.
* @returns A promise that resolves to an object containing app stats.
*/
export const getApplicationStats = async (): Promise<AppStats> => {
const response = await apiFetch(`${API_BASE_URL}/admin/stats`);
export const getApplicationStats = async (tokenOverride?: string): Promise<AppStats> => {
const response = await apiFetch(`${API_BASE_URL}/admin/stats`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch application statistics.' }));
throw new Error(errorData.message);
@@ -1036,8 +1036,8 @@ export const getApplicationStats = async (): Promise<AppStats> => {
* Fetches all pending suggested corrections. Requires admin privileges.
* @returns A promise that resolves to an array of SuggestedCorrection objects.
*/
export const getSuggestedCorrections = async (): Promise<SuggestedCorrection[]> => {
const response = await apiFetch(`${API_BASE_URL}/admin/corrections`);
export const getSuggestedCorrections = async (tokenOverride?: string): Promise<SuggestedCorrection[]> => {
const response = await apiFetch(`${API_BASE_URL}/admin/corrections`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch suggested corrections.' }));
throw new Error(errorData.message);
@@ -1050,10 +1050,10 @@ export const getSuggestedCorrections = async (): Promise<SuggestedCorrection[]>
* @param correctionId The ID of the correction to approve.
* @returns A promise that resolves with the success message.
*/
export const approveCorrection = async (correctionId: number): Promise<{ message: string }> => {
const response = await apiFetch(`${API_BASE_URL}/admin/corrections/${correctionId}/approve`, {
export const approveCorrection = async (correctionId: number, tokenOverride?: string): Promise<{ message: string }> => {
const response = await apiFetch(`/admin/corrections/${correctionId}/approve`, {
method: 'POST',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to approve correction.' }));
throw new Error(errorData.message);
@@ -1065,10 +1065,10 @@ export const approveCorrection = async (correctionId: number): Promise<{ message
* Rejects a suggested correction. Requires admin privileges.
* @param correctionId The ID of the correction to reject.
*/
export const rejectCorrection = async (correctionId: number): Promise<{ message: string }> => {
const response = await apiFetch(`${API_BASE_URL}/admin/corrections/${correctionId}/reject`, {
export const rejectCorrection = async (correctionId: number, tokenOverride?: string): Promise<{ message: string }> => {
const response = await apiFetch(`/admin/corrections/${correctionId}/reject`, {
method: 'POST',
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to reject correction.' }));
throw new Error(errorData.message);
@@ -1082,12 +1082,12 @@ export const rejectCorrection = async (correctionId: number): Promise<{ message:
* @param newSuggestedValue The new value for the suggestion.
* @returns A promise that resolves to the updated SuggestedCorrection object.
*/
export const updateSuggestedCorrection = async (correctionId: number, newSuggestedValue: string): Promise<SuggestedCorrection> => {
const response = await apiFetch(`${API_BASE_URL}/admin/corrections/${correctionId}`, {
export const updateSuggestedCorrection = async (correctionId: number, newSuggestedValue: string, tokenOverride?: string): Promise<SuggestedCorrection> => {
const response = await apiFetch(`/admin/corrections/${correctionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suggested_value: newSuggestedValue }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to update suggested correction.' }));
throw new Error(errorData.message);
@@ -1226,8 +1226,8 @@ export async function exportUserData(tokenOverride?: string): Promise<UserDataEx
* Fetches the kitchen appliances for the currently authenticated user.
* @returns A promise that resolves to an array of the user's Appliance objects.
*/
export const getUserAppliances = async (): Promise<Appliance[]> => {
const response = await apiFetch(`${API_BASE_URL}/users/me/appliances`);
export const getUserAppliances = async (tokenOverride?: string): Promise<Appliance[]> => {
const response = await apiFetch(`/users/me/appliances`, {}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to fetch user appliances.' }));
throw new Error(errorData.message);
@@ -1240,12 +1240,12 @@ export const getUserAppliances = async (): Promise<Appliance[]> => {
* 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 (applianceIds: number[]): Promise<void> => {
const response = await apiFetch(`${API_BASE_URL}/users/me/appliances`, {
export const setUserAppliances = async (applianceIds: number[], tokenOverride?: string): Promise<void> => {
const response = await apiFetch(`/users/me/appliances`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ applianceIds }),
});
}, tokenOverride);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to set user appliances.' }));
throw new Error(errorData.message);

View File

@@ -0,0 +1,111 @@
// src/tests/integration/admin.integration.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { getPool } from '../../services/db/connection';
import type { User } from '../../types';
/**
* @vitest-environment node
*/
const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
describe('Admin API Routes Integration Tests', () => {
let adminUser: User;
let adminToken: string;
let regularUser: User;
let regularUserToken: string;
beforeAll(async () => {
// Log in as the pre-seeded admin user
const adminLoginResponse = await apiClient.loginUser('admin@example.com', 'adminpass', false);
adminUser = adminLoginResponse.user;
adminToken = adminLoginResponse.token;
// Create and log in as a new regular user for permission testing
const regularUserEmail = `regular-user-${Date.now()}@example.com`;
await apiClient.registerUser(regularUserEmail, TEST_PASSWORD, 'Regular User');
const regularUserLoginResponse = await apiClient.loginUser(regularUserEmail, TEST_PASSWORD, false);
regularUser = regularUserLoginResponse.user;
regularUserToken = regularUserLoginResponse.token;
// Cleanup the created user after all tests in this file are done
return async () => {
if (regularUser) {
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [regularUser.user_id]);
}
};
});
describe('GET /api/admin/stats', () => {
it('should allow an admin to fetch application stats', async () => {
const stats = await apiClient.getApplicationStats(adminToken);
expect(stats).toBeDefined();
expect(stats).toHaveProperty('flyerCount');
expect(stats).toHaveProperty('userCount');
expect(stats).toHaveProperty('flyerItemCount');
});
it('should forbid a regular user from fetching application stats', async () => {
await expect(apiClient.getApplicationStats(regularUserToken)).rejects.toThrow(
'Forbidden: Administrator access required.'
);
});
});
describe('GET /api/admin/stats/daily', () => {
it('should allow an admin to fetch daily stats', async () => {
const dailyStats = await apiClient.getDailyStats(adminToken);
expect(dailyStats).toBeDefined();
expect(Array.isArray(dailyStats)).toBe(true);
// The seed script creates users, so we should have some data
expect(dailyStats.length).toBe(30);
expect(dailyStats[0]).toHaveProperty('date');
expect(dailyStats[0]).toHaveProperty('new_users');
expect(dailyStats[0]).toHaveProperty('new_flyers');
});
it('should forbid a regular user from fetching daily stats', async () => {
await expect(apiClient.getDailyStats(regularUserToken)).rejects.toThrow(
'Forbidden: Administrator access required.'
);
});
});
describe('GET /api/admin/corrections', () => {
it('should allow an admin to fetch suggested corrections', async () => {
// This test just verifies access and correct response shape.
// More detailed tests would require seeding corrections.
const corrections = await apiClient.getSuggestedCorrections(adminToken);
expect(corrections).toBeDefined();
expect(Array.isArray(corrections)).toBe(true);
});
it('should forbid a regular user from fetching suggested corrections', async () => {
await expect(apiClient.getSuggestedCorrections(regularUserToken)).rejects.toThrow(
'Forbidden: Administrator access required.'
);
});
});
describe('GET /api/admin/brands', () => {
it('should allow an admin to fetch all brands', async () => {
const brands = await apiClient.fetchAllBrands(adminToken);
expect(brands).toBeDefined();
expect(Array.isArray(brands)).toBe(true);
// The seed script creates brands
expect(brands.length).toBeGreaterThan(0);
expect(brands[0]).toHaveProperty('brand_id');
expect(brands[0]).toHaveProperty('name');
});
it('should forbid a regular user from fetching all brands', async () => {
await expect(apiClient.fetchAllBrands(regularUserToken)).rejects.toThrow(
'Forbidden: Administrator access required.'
);
});
});
// TODO: Add tests for POST/PUT/DELETE admin endpoints, which would require seeding specific data
// (e.g., a correction to approve, a recipe to update status for).
});

View File

@@ -0,0 +1,77 @@
// src/tests/integration/ai.integration.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import * as aiApiClient from '../../services/aiApiClient';
import type { User } from '../../types';
/**
* @vitest-environment node
*/
const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
describe('AI API Routes Integration Tests', () => {
let testUser: User;
let authToken: string;
beforeAll(async () => {
// Create and log in as a new user for authenticated tests
const email = `ai-test-user-${Date.now()}@example.com`;
await apiClient.registerUser(email, TEST_PASSWORD, 'AI Tester');
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
testUser = loginResponse.user;
authToken = loginResponse.token;
});
it('POST /api/ai/check-flyer should return a boolean', async () => {
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const result = await aiApiClient.isImageAFlyer(mockImageFile, authToken);
// The backend is stubbed to always return true for this check
expect(result).toBe(true);
});
it('POST /api/ai/extract-address should return a stubbed address', async () => {
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const result = await aiApiClient.extractAddressFromImage(mockImageFile, authToken);
expect(result).toBe("123 AI Street, Server City");
});
it('POST /api/ai/extract-logo should return a stubbed response', async () => {
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const result = await aiApiClient.extractLogoFromImage([mockImageFile], authToken);
expect(result).toEqual({ store_logo_base_64: null });
});
it('POST /api/ai/quick-insights should return a stubbed insight', async () => {
const result = await aiApiClient.getQuickInsights([], authToken);
expect(result).toBe("This is a server-generated quick insight: buy the cheap stuff!");
});
it('POST /api/ai/deep-dive should return a stubbed analysis', async () => {
const result = await aiApiClient.getDeepDiveAnalysis([], authToken);
expect(result).toBe("This is a server-generated deep dive analysis. It is very detailed.");
});
it('POST /api/ai/search-web should return a stubbed search result', async () => {
const result = await aiApiClient.searchWeb([], authToken);
expect(result).toEqual({ text: "The web says this is good.", sources: [] });
});
it('POST /api/ai/plan-trip should return a stubbed trip plan', async () => {
const mockLocation = { latitude: 48.4284, longitude: -123.3656 };
const result = await aiApiClient.planTripWithMaps([], undefined, mockLocation, authToken);
expect(result).toBeDefined();
expect(result.text).toContain('trip plan');
});
it('POST /api/ai/generate-image should reject because it is not implemented', async () => {
// The backend for this is not stubbed and will throw an error.
// This test confirms that the endpoint is protected and responds as expected to a failure.
await expect(aiApiClient.generateImageFromText("a test prompt", authToken)).rejects.toThrow();
});
it('POST /api/ai/generate-speech should reject because it is not implemented', async () => {
// The backend for this is not stubbed and will throw an error.
await expect(aiApiClient.generateSpeechFromText("a test prompt", authToken)).rejects.toThrow();
});
});

View File

@@ -1,5 +1,5 @@
// src/tests/integration/auth.integration.test.ts
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeAll } from 'vitest';
import { loginUser } from '../../services/apiClient';
import { getPool } from '../../services/db/connection';
@@ -14,6 +14,14 @@ import { getPool } from '../../services/db/connection';
* To run only these tests: `vitest run src/tests/auth.integration.test.ts`
*/
describe('Authentication API Integration', () => {
let refreshTokenCookie: string | undefined;
beforeAll(async () => {
// Log in once to get a valid refresh token cookie for the refresh test
const response = await loginUser('admin@example.com', 'adminpass', true);
// This is a simplified way to grab the cookie for testing purposes.
refreshTokenCookie = (response as any).headers.get('set-cookie')?.split(';')[0];
});
// --- START DEBUG LOGGING ---
// Query the DB from within the test file to see its state.
getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id').then(res => {
@@ -58,4 +66,23 @@ describe('Authentication API Integration', () => {
// We expect the loginUser function to throw an error for a failed login.
await expect(loginUser(adminEmail, wrongPassword, false)).rejects.toThrow('Incorrect email or password.');
});
it('should successfully refresh an access token using a refresh token cookie', async () => {
// Arrange: We have a refreshTokenCookie from the beforeAll hook.
expect(refreshTokenCookie).toBeDefined();
// Act: Make a request to the refresh-token endpoint, including the cookie.
const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
const response = await fetch(`${apiUrl}/auth/refresh-token`, {
method: 'POST',
headers: {
'Cookie': refreshTokenCookie!,
},
});
// Assert: Check for a successful response and a new access token.
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.token).toBeTypeOf('string');
});
});

View File

@@ -0,0 +1,55 @@
// src/tests/integration/public.integration.test.ts
import { describe, it, expect } from 'vitest';
import * as apiClient from '../../services/apiClient';
/**
* @vitest-environment node
*/
describe('Public API Routes Integration Tests', () => {
describe('Health Check Endpoints', () => {
it('GET /api/health/ping should return "pong"', async () => {
const isOk = await apiClient.pingBackend();
expect(isOk).toBe(true);
});
it('GET /api/health/db-schema should return success', async () => {
const result = await apiClient.checkDbSchema();
expect(result.success).toBe(true);
expect(result.message).toBe('All required database tables exist.');
});
it('GET /api/health/storage should return success', async () => {
// This assumes the STORAGE_PATH is correctly set up for the test environment
const result = await apiClient.checkStorage();
expect(result.success).toBe(true);
expect(result.message).toContain('is accessible and writable');
});
it('GET /api/health/db-pool should return success', async () => {
const result = await apiClient.checkDbPoolHealth();
expect(result.success).toBe(true);
expect(result.message).toContain('Pool Status:');
});
});
describe('Public Data Endpoints', () => {
it('GET /api/flyers should return a list of flyers', async () => {
const flyers = await apiClient.fetchFlyers();
expect(flyers).toBeInstanceOf(Array);
// The seed script creates at least one flyer
expect(flyers.length).toBeGreaterThan(0);
expect(flyers[0]).toHaveProperty('flyer_id');
expect(flyers[0]).toHaveProperty('store');
});
it('GET /api/master-items should return a list of master items', async () => {
const masterItems = await apiClient.fetchMasterItems();
expect(masterItems).toBeInstanceOf(Array);
// The seed script creates master items
expect(masterItems.length).toBeGreaterThan(0);
expect(masterItems[0]).toHaveProperty('master_grocery_item_id');
expect(masterItems[0]).toHaveProperty('category_name');
});
});
});

View File

@@ -0,0 +1,20 @@
// src/tests/integration/system.integration.test.ts
import { describe, it, expect } from 'vitest';
import * as apiClient from '../../services/apiClient';
/**
* @vitest-environment node
*/
describe('System API Routes Integration Tests', () => {
describe('GET /api/system/pm2-status', () => {
it('should return a status for PM2', async () => {
// In a typical CI environment without PM2, this will fail gracefully.
// The test verifies that the endpoint responds correctly, even if PM2 isn't running.
const result = await apiClient.checkPm2Status();
expect(result).toBeDefined();
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('message');
});
});
});

View File

@@ -157,4 +157,51 @@ describe('User API Routes Integration Tests', () => {
expect(loginResponse.user).toBeDefined();
expect(loginResponse.user.user_id).toBe(resetUser.user_id);
});
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
it('should allow a user to add and remove a watched item', async () => {
// Act 1: Add a new watched item.
const newItem = await apiClient.addWatchedItem('Integration Test Item', 'Pantry & Dry Goods', authToken);
// Assert 1: Check that the item was created correctly.
expect(newItem).toBeDefined();
expect(newItem.name).toBe('Integration Test Item');
// Act 2: Fetch all watched items for the user.
const watchedItems = await apiClient.fetchWatchedItems(authToken);
// Assert 2: Verify the new item is in the list.
expect(watchedItems.some(item => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(true);
// Act 3: Remove the watched item.
await apiClient.removeWatchedItem(newItem.master_grocery_item_id, authToken);
// Assert 3: Fetch again and verify the item is gone.
const finalWatchedItems = await apiClient.fetchWatchedItems(authToken);
expect(finalWatchedItems.some(item => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(false);
});
it('should allow a user to manage a shopping list and its items', async () => {
// Act 1: Create a new shopping list.
const newList = await apiClient.createShoppingList('My Integration Test List', authToken);
// Assert 1: Check that the list was created.
expect(newList).toBeDefined();
expect(newList.name).toBe('My Integration Test List');
// Act 2: Add an item to the new list.
const addedItem = await apiClient.addShoppingListItem(newList.shopping_list_id, { customItemName: 'Custom Test Item' }, authToken);
// Assert 2: Check that the item was added.
expect(addedItem).toBeDefined();
expect(addedItem.custom_item_name).toBe('Custom Test Item');
// Act 3: Fetch the lists again to verify the item is present.
const lists = await apiClient.fetchShoppingLists(authToken);
const updatedList = lists.find(l => l.shopping_list_id === newList.shopping_list_id);
expect(updatedList).toBeDefined();
expect(updatedList?.items).toHaveLength(1);
expect(updatedList?.items[0].shopping_list_item_id).toBe(addedItem.shopping_list_item_id);
});
});
});