Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26763c7183 | ||
| f0c5c2c45b | |||
|
|
034bb60fd5 | ||
| d4b389cb79 | |||
|
|
a71fb81468 | ||
| 9bee0a013b |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.25",
|
||||
"version": "0.7.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.25",
|
||||
"version": "0.7.28",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.7.25",
|
||||
"version": "0.7.28",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -2115,6 +2115,61 @@ AS $$
|
||||
ORDER BY potential_savings_cents DESC;
|
||||
$$;
|
||||
|
||||
-- Function to get a user's spending breakdown by category for a given date range.
|
||||
DROP FUNCTION IF EXISTS public.get_spending_by_category(UUID, DATE, DATE);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_spending_by_category(p_user_id UUID, p_start_date DATE, p_end_date DATE)
|
||||
RETURNS TABLE (
|
||||
category_id BIGINT,
|
||||
category_name TEXT,
|
||||
total_spent_cents BIGINT
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
WITH all_purchases AS (
|
||||
-- CTE 1: Combine purchases from completed shopping trips.
|
||||
-- We only consider items that have a price paid.
|
||||
SELECT
|
||||
sti.master_item_id,
|
||||
sti.price_paid_cents
|
||||
FROM public.shopping_trip_items sti
|
||||
JOIN public.shopping_trips st ON sti.shopping_trip_id = st.shopping_trip_id
|
||||
WHERE st.user_id = p_user_id
|
||||
AND st.completed_at::date BETWEEN p_start_date AND p_end_date
|
||||
AND sti.price_paid_cents IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- CTE 2: Combine purchases from processed receipts.
|
||||
SELECT
|
||||
ri.master_item_id,
|
||||
ri.price_paid_cents
|
||||
FROM public.receipt_items ri
|
||||
JOIN public.receipts r ON ri.receipt_id = r.receipt_id
|
||||
WHERE r.user_id = p_user_id
|
||||
AND r.transaction_date::date BETWEEN p_start_date AND p_end_date
|
||||
AND ri.master_item_id IS NOT NULL -- Only include items matched to a master item
|
||||
)
|
||||
-- Final Aggregation: Group all combined purchases by category and sum the spending.
|
||||
SELECT
|
||||
c.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ap.price_paid_cents)::BIGINT AS total_spent_cents
|
||||
FROM all_purchases ap
|
||||
-- Join with master_grocery_items to get the category_id for each purchase.
|
||||
JOIN public.master_grocery_items mgi ON ap.master_item_id = mgi.master_grocery_item_id
|
||||
-- Join with categories to get the category name for display.
|
||||
JOIN public.categories c ON mgi.category_id = c.category_id
|
||||
GROUP BY
|
||||
c.category_id, c.name
|
||||
HAVING
|
||||
SUM(ap.price_paid_cents) > 0
|
||||
ORDER BY
|
||||
total_spent_cents DESC;
|
||||
$$;
|
||||
|
||||
-- Function to approve a suggested correction and apply it.
|
||||
DROP FUNCTION IF EXISTS public.approve_correction(BIGINT);
|
||||
|
||||
@@ -2669,6 +2724,58 @@ CREATE TRIGGER on_new_recipe_collection_share
|
||||
AFTER INSERT ON public.shared_recipe_collections
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
|
||||
|
||||
-- 10. Trigger function to geocode a store location's address.
|
||||
-- This function is designed to be extensible for external geocoding services.
|
||||
DROP FUNCTION IF EXISTS public.geocode_store_location();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.geocode_store_location()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
full_address TEXT;
|
||||
BEGIN
|
||||
-- Only proceed if the address has actually changed.
|
||||
-- Note: We check against the linked address fields via the NEW.address_id in a real scenario,
|
||||
-- but for this trigger to work effectively, it usually requires a direct update on the address table
|
||||
-- or this trigger should be moved to the 'addresses' table.
|
||||
-- However, based on the provided logic, we are keeping the placeholder structure.
|
||||
|
||||
-- Placeholder logic:
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- Logic to fetch address string based on NEW.address_id and geocode
|
||||
NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger to call the geocoding function.
|
||||
DROP TRIGGER IF EXISTS on_store_location_address_change ON public.store_locations;
|
||||
CREATE TRIGGER on_store_location_address_change
|
||||
BEFORE INSERT OR UPDATE ON public.store_locations
|
||||
FOR EACH ROW EXECUTE FUNCTION public.geocode_store_location();
|
||||
|
||||
-- 11. Trigger function to increment the fork_count on the original recipe.
|
||||
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Only run if the recipe is a fork (original_recipe_id is not null).
|
||||
IF NEW.original_recipe_id IS NOT NULL THEN
|
||||
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
|
||||
-- Award 'First Fork' achievement.
|
||||
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
|
||||
CREATE TRIGGER on_recipe_fork
|
||||
AFTER INSERT ON public.recipes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();
|
||||
|
||||
-- =================================================================
|
||||
-- Function: get_best_sale_prices_for_all_users()
|
||||
-- Description: Retrieves the best sale price for every item on every user's watchlist.
|
||||
|
||||
@@ -12,13 +12,6 @@ import '@testing-library/jest-dom';
|
||||
// We can get a typed reference to it for individual test overrides.
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.client', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('RecipeSuggester Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -12,12 +12,7 @@ import {
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
|
||||
|
||||
// Explicitly mock apiClient to ensure stable spies are used
|
||||
vi.mock('../services/apiClient', () => ({
|
||||
countFlyerItemsForFlyers: vi.fn(),
|
||||
fetchFlyerItemsForFlyers: vi.fn(),
|
||||
}));
|
||||
|
||||
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||
// Mock the hooks to avoid Missing Context errors
|
||||
vi.mock('./useFlyers', () => ({
|
||||
useFlyers: () => mockUseFlyers(),
|
||||
@@ -30,14 +25,6 @@ vi.mock('../hooks/useUserData', () => ({
|
||||
// The apiClient is globally mocked in our test setup, so we just need to cast it
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock the logger to prevent console noise
|
||||
vi.mock('../services/logger.client', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(), // Added to prevent crashes on abort logging
|
||||
},
|
||||
}));
|
||||
|
||||
// Set a consistent "today" for testing flyer validity to make tests deterministic
|
||||
const TODAY = new Date('2024-01-15T12:00:00.000Z');
|
||||
|
||||
|
||||
@@ -11,21 +11,9 @@ import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { logger } from '../services/logger.client';
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('../services/apiClient', () => ({
|
||||
// Mock other functions if needed
|
||||
getAuthenticatedUserProfile: vi.fn(),
|
||||
}));
|
||||
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||
vi.mock('../services/tokenStorage');
|
||||
|
||||
// Mock the logger to spy on its methods
|
||||
vi.mock('../services/logger.client', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
const mockedTokenStorage = vi.mocked(tokenStorage);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useFlyerItems } from './useFlyerItems';
|
||||
import { useApiOnMount } from './useApiOnMount';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||
|
||||
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
|
||||
@@ -168,15 +169,12 @@ describe('useFlyerItems Hook', () => {
|
||||
|
||||
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
|
||||
const mockResponse = new Response();
|
||||
vi.mocked(apiClient.fetchFlyerItems).mockResolvedValue(mockResponse);
|
||||
//FIX: Missing apiClient import here
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
mockedApiClient.fetchFlyerItems.mockResolvedValue(mockResponse);
|
||||
const response = await wrappedFetcher(123);
|
||||
|
||||
expect(apiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
|
||||
expect(mockedApiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
import * as apiClient from '../services/apiClient';
|
||||
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
@@ -11,13 +11,6 @@ import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||
// The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`.
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.client', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons to prevent rendering errors in the test environment
|
||||
vi.mock('lucide-react', () => ({
|
||||
AlertCircle: () => <div data-testid="alert-circle-icon" />,
|
||||
|
||||
@@ -967,6 +967,13 @@ describe('ProfileManager', () => {
|
||||
|
||||
it('should show error notification when auto-geocoding fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
// FIX: Mock getUserAddress to return an address *without* coordinates.
|
||||
// This is the condition required to trigger the auto-geocoding logic.
|
||||
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||
mockedApiClient.getUserAddress.mockResolvedValue(
|
||||
new Response(JSON.stringify(addressWithoutCoords)),
|
||||
);
|
||||
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||
|
||||
// Wait for initial load
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('AuthProvider', () => {
|
||||
await waitFor(() => {
|
||||
// The error is now caught and displayed by the TestConsumer
|
||||
expect(screen.getByTestId('error-display')).toHaveTextContent(
|
||||
'Login succeeded, but failed to fetch your data: API is down',
|
||||
'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.',
|
||||
);
|
||||
|
||||
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile');
|
||||
|
||||
@@ -103,17 +103,22 @@ export class FlyerRepository {
|
||||
const result = await this.db.query<Flyer>(query, values);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
const isChecksumError =
|
||||
error instanceof Error && error.message.includes('flyers_checksum_check');
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
let checkMsg = 'A database check constraint failed.';
|
||||
|
||||
if (errorMessage.includes('flyers_checksum_check')) {
|
||||
checkMsg =
|
||||
'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).';
|
||||
} else if (errorMessage.includes('flyers_status_check')) {
|
||||
checkMsg = 'Invalid status provided for flyer.';
|
||||
} else if (errorMessage.includes('url_check')) {
|
||||
checkMsg = 'Invalid URL format provided for image or icon.';
|
||||
}
|
||||
|
||||
handleDbError(error, logger, 'Database error in insertFlyer', { flyerData }, {
|
||||
uniqueMessage: 'A flyer with this checksum already exists.',
|
||||
fkMessage: 'The specified user or store for this flyer does not exist.',
|
||||
// Provide a more specific message for the checksum constraint violation,
|
||||
// which is a common issue during seeding or testing with placeholder data.
|
||||
checkMessage: isChecksumError
|
||||
? 'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).'
|
||||
: 'Invalid status provided for flyer.',
|
||||
checkMessage: checkMsg,
|
||||
defaultMessage: 'Failed to insert flyer into database.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ describe('FlyerDataTransformer', () => {
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const baseUrl = `http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
// Assert
|
||||
// 0. Check logging
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
@@ -83,8 +85,8 @@ describe('FlyerDataTransformer', () => {
|
||||
// 1. Check flyer data
|
||||
expect(flyerData).toEqual({
|
||||
file_name: originalFileName,
|
||||
image_url: '/flyer-images/flyer-page-1.jpg',
|
||||
icon_url: '/flyer-images/icons/icon-flyer-page-1.webp',
|
||||
image_url: `${baseUrl}/flyer-images/flyer-page-1.jpg`,
|
||||
icon_url: `${baseUrl}/flyer-images/icons/icon-flyer-page-1.webp`,
|
||||
checksum,
|
||||
store_name: 'Test Store',
|
||||
valid_from: '2024-01-01',
|
||||
@@ -151,6 +153,8 @@ describe('FlyerDataTransformer', () => {
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const baseUrl = `http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
// Assert
|
||||
// 0. Check logging
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
@@ -167,8 +171,8 @@ describe('FlyerDataTransformer', () => {
|
||||
expect(itemsForDb).toHaveLength(0);
|
||||
expect(flyerData).toEqual({
|
||||
file_name: originalFileName,
|
||||
image_url: '/flyer-images/another.png',
|
||||
icon_url: '/flyer-images/icons/icon-another.webp',
|
||||
image_url: `${baseUrl}/flyer-images/another.png`,
|
||||
icon_url: `${baseUrl}/flyer-images/icons/icon-another.webp`,
|
||||
checksum,
|
||||
store_name: 'Unknown Store (auto)', // Should use fallback
|
||||
valid_from: null,
|
||||
|
||||
@@ -75,10 +75,13 @@ export class FlyerDataTransformer {
|
||||
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
|
||||
}
|
||||
|
||||
// Construct proper URLs including protocol and host to satisfy DB constraints
|
||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
const flyerData: FlyerInsert = {
|
||||
file_name: originalFileName,
|
||||
image_url: `/flyer-images/${path.basename(firstImage)}`,
|
||||
icon_url: `/flyer-images/icons/${iconFileName}`,
|
||||
image_url: new URL(`/flyer-images/${path.basename(firstImage)}`, baseUrl).href,
|
||||
icon_url: new URL(`/flyer-images/icons/${iconFileName}`, baseUrl).href,
|
||||
checksum,
|
||||
store_name: storeName,
|
||||
valid_from: extractedData.valid_from,
|
||||
|
||||
@@ -62,4 +62,19 @@ vi.mock('../../services/apiClient', () => ({
|
||||
geocodeAddress: vi.fn(),
|
||||
getFlyersForReview: vi.fn(),
|
||||
fetchLeaderboard: vi.fn(),
|
||||
// --- Added to fix "No export is defined on the mock" errors ---
|
||||
fetchFlyerItems: vi.fn(),
|
||||
createShoppingList: vi.fn(),
|
||||
deleteShoppingList: vi.fn(),
|
||||
addShoppingListItem: vi.fn(),
|
||||
updateShoppingListItem: vi.fn(),
|
||||
removeShoppingListItem: vi.fn(),
|
||||
addWatchedItem: vi.fn(),
|
||||
removeWatchedItem: vi.fn(),
|
||||
fetchBestSalePrices: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
getUserAchievements: vi.fn(),
|
||||
uploadAvatar: vi.fn(),
|
||||
countFlyerItemsForFlyers: vi.fn(),
|
||||
fetchFlyerItemsForFlyers: vi.fn(),
|
||||
}));
|
||||
Reference in New Issue
Block a user