Compare commits

..

6 Commits

Author SHA1 Message Date
Gitea Actions
5bc8f6a42b ci: Bump version to 0.12.25 [skip ci] 2026-01-31 03:35:28 +05:00
4fd5e900af minor test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m22s
2026-01-30 14:29:45 -08:00
Gitea Actions
39ab773b82 ci: Bump version to 0.12.24 [skip ci] 2026-01-30 06:23:37 +05:00
75406cd924 typescript fix
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m7s
2026-01-29 17:21:55 -08:00
Gitea Actions
8fb0a57f02 ci: Bump version to 0.12.23 [skip ci] 2026-01-30 05:24:50 +05:00
c78323275b more unit tests - done for now
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m28s
2026-01-29 16:21:48 -08:00
32 changed files with 12086 additions and 338 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.12.22",
"version": "0.12.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.12.22",
"version": "0.12.25",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.12.22",
"version": "0.12.25",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -0,0 +1,126 @@
// src/hooks/queries/useBestSalePricesQuery.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useBestSalePricesQuery } from './useBestSalePricesQuery';
import * as apiClient from '../../services/apiClient';
import type { WatchedItemDeal } from '../../types';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useBestSalePricesQuery', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
it('should fetch best sale prices successfully', async () => {
const mockDeals: WatchedItemDeal[] = [
{
master_item_id: 101,
item_name: 'Organic Bananas',
best_price_in_cents: 59,
store: {
store_id: 1,
name: 'Green Grocer',
logo_url: null,
locations: [
{
address_line_1: '123 Main St',
city: 'Springfield',
province_state: 'ON',
postal_code: 'A1B2C3',
},
],
},
flyer_id: 56,
valid_to: '2026-02-01T23:59:59Z',
},
];
mockedApiClient.fetchBestSalePrices.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockDeals }),
} as Response);
const { result } = renderHook(() => useBestSalePricesQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchBestSalePrices).toHaveBeenCalled();
expect(result.current.data).toEqual(mockDeals);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Authentication required' }),
} as Response);
const { result } = renderHook(() => useBestSalePricesQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Authentication required');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useBestSalePricesQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useBestSalePricesQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch best sale prices');
});
it('should return empty array for no deals', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
const { result } = renderHook(() => useBestSalePricesQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should not fetch when disabled', () => {
renderHook(() => useBestSalePricesQuery(false), { wrapper });
expect(mockedApiClient.fetchBestSalePrices).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,175 @@
// src/hooks/queries/useBrandsQuery.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useBrandsQuery } from './useBrandsQuery';
import * as apiClient from '../../services/apiClient';
import type { Brand } from '../../types';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useBrandsQuery', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
it('should fetch brands successfully', async () => {
const mockBrands: Brand[] = [
{
brand_id: 1,
name: 'Organic Valley',
logo_url: 'https://example.com/organic-valley.png',
store_id: null,
store_name: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
brand_id: 2,
name: "Kellogg's",
logo_url: null,
store_id: 5,
store_name: 'SuperMart',
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-02T00:00:00Z',
},
];
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockBrands }),
} as Response);
const { result } = renderHook(() => useBrandsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchAllBrands).toHaveBeenCalled();
expect(result.current.data).toEqual(mockBrands);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Authentication required' }),
} as Response);
const { result } = renderHook(() => useBrandsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Authentication required');
});
it('should handle API error without message (JSON parse failure)', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useBrandsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useBrandsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch brands');
});
it('should return empty array for no brands', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
const { result } = renderHook(() => useBrandsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when success is false', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: false, error: 'Something went wrong' }),
} as Response);
const { result } = renderHook(() => useBrandsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when data is not an array', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: null }),
} as Response);
const { result } = renderHook(() => useBrandsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should not fetch when disabled', () => {
renderHook(() => useBrandsQuery(false), { wrapper });
expect(mockedApiClient.fetchAllBrands).not.toHaveBeenCalled();
});
it('should fetch when explicitly enabled', async () => {
const mockBrands: Brand[] = [
{
brand_id: 1,
name: 'Test Brand',
logo_url: null,
store_id: null,
store_name: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
mockedApiClient.fetchAllBrands.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockBrands }),
} as Response);
const { result } = renderHook(() => useBrandsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchAllBrands).toHaveBeenCalled();
expect(result.current.data).toEqual(mockBrands);
});
});

View File

@@ -0,0 +1,235 @@
// src/hooks/queries/useFlyerItemCountQuery.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useFlyerItemCountQuery } from './useFlyerItemCountQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useFlyerItemCountQuery', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
it('should fetch flyer item count successfully', async () => {
const flyerIds = [1, 2, 3];
const mockCount = { count: 42 };
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockCount }),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith(flyerIds);
expect(result.current.data).toEqual(mockCount);
});
it('should handle API error with error message', async () => {
const flyerIds = [1, 2];
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Authentication required' }),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Authentication required');
});
it('should handle API error without message (JSON parse error)', async () => {
const flyerIds = [1, 2];
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
const flyerIds = [1, 2];
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to count flyer items');
});
it('should return zero count for empty flyerIds array without calling API', async () => {
const flyerIds: number[] = [];
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds, true), { wrapper });
// Query should be disabled due to flyerIds.length === 0
expect(result.current.isPending).toBe(true);
expect(result.current.fetchStatus).toBe('idle');
expect(mockedApiClient.countFlyerItemsForFlyers).not.toHaveBeenCalled();
});
it('should not fetch when disabled via enabled parameter', () => {
const flyerIds = [1, 2, 3];
renderHook(() => useFlyerItemCountQuery(flyerIds, false), { wrapper });
expect(mockedApiClient.countFlyerItemsForFlyers).not.toHaveBeenCalled();
});
it('should return count of zero when API returns zero', async () => {
const flyerIds = [99, 100];
const mockCount = { count: 0 };
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockCount }),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ count: 0 });
});
it('should handle response without data wrapper (fallback to json)', async () => {
const flyerIds = [1];
const mockCount = { count: 15 };
// Some API responses might return data directly without the { success, data } wrapper
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockCount),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Should fall back to the raw json when data is undefined
expect(result.current.data).toEqual(mockCount);
});
it('should use different cache keys for different flyerIds arrays', async () => {
const flyerIds1 = [1, 2];
const flyerIds2 = [3, 4];
const mockCount1 = { count: 10 };
const mockCount2 = { count: 20 };
mockedApiClient.countFlyerItemsForFlyers
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, data: mockCount1 }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, data: mockCount2 }),
} as Response);
const { result: result1 } = renderHook(() => useFlyerItemCountQuery(flyerIds1), { wrapper });
await waitFor(() => expect(result1.current.isSuccess).toBe(true));
const { result: result2 } = renderHook(() => useFlyerItemCountQuery(flyerIds2), { wrapper });
await waitFor(() => expect(result2.current.isSuccess).toBe(true));
// Both calls should have been made since they have different cache keys
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledTimes(2);
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith(flyerIds1);
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith(flyerIds2);
});
it('should handle large count values', async () => {
const flyerIds = [1, 2, 3, 4, 5];
const mockCount = { count: 999999 };
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockCount }),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ count: 999999 });
});
it('should handle network error', async () => {
const flyerIds = [1, 2];
mockedApiClient.countFlyerItemsForFlyers.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Network error');
});
it('should default enabled to true when not specified', async () => {
const flyerIds = [1];
const mockCount = { count: 5 };
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockCount }),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalled();
});
it('should handle single flyerId in array', async () => {
const flyerIds = [42];
const mockCount = { count: 7 };
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockCount }),
} as Response);
const { result } = renderHook(() => useFlyerItemCountQuery(flyerIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([42]);
expect(result.current.data).toEqual({ count: 7 });
});
});

View File

@@ -0,0 +1,310 @@
// src/hooks/queries/useFlyerItemsForFlyersQuery.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useFlyerItemsForFlyersQuery } from './useFlyerItemsForFlyersQuery';
import * as apiClient from '../../services/apiClient';
import type { FlyerItem } from '../../types';
import { createMockFlyerItem } from '../../tests/utils/mockFactories';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useFlyerItemsForFlyersQuery', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
it('should fetch flyer items for multiple flyers successfully', async () => {
const mockFlyerItems: FlyerItem[] = [
createMockFlyerItem({
flyer_item_id: 1,
flyer_id: 100,
item: 'Organic Bananas',
price_display: '$0.59/lb',
price_in_cents: 59,
quantity: 'lb',
master_item_id: 1001,
master_item_name: 'Bananas',
category_id: 1,
category_name: 'Produce',
}),
createMockFlyerItem({
flyer_item_id: 2,
flyer_id: 100,
item: 'Whole Milk',
price_display: '$3.99',
price_in_cents: 399,
quantity: 'gal',
master_item_id: 1002,
master_item_name: 'Milk',
category_id: 2,
category_name: 'Dairy',
}),
createMockFlyerItem({
flyer_item_id: 3,
flyer_id: 101,
item: 'Chicken Breast',
price_display: '$5.99/lb',
price_in_cents: 599,
quantity: 'lb',
master_item_id: 1003,
master_item_name: 'Chicken',
category_id: 3,
category_name: 'Meat',
}),
];
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockFlyerItems }),
} as Response);
const flyerIds = [100, 101];
const { result } = renderHook(() => useFlyerItemsForFlyersQuery(flyerIds), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith(flyerIds);
expect(result.current.data).toEqual(mockFlyerItems);
expect(result.current.data).toHaveLength(3);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Authentication required' }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Authentication required');
});
it('should handle API error without message (JSON parse error)', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch flyer items');
});
it('should return empty array for no flyer items', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should not fetch when disabled explicitly', () => {
renderHook(() => useFlyerItemsForFlyersQuery([100], false), { wrapper });
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
});
it('should not fetch when flyerIds array is empty', () => {
renderHook(() => useFlyerItemsForFlyersQuery([]), { wrapper });
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
});
it('should not fetch when flyerIds is empty even if enabled is true', () => {
renderHook(() => useFlyerItemsForFlyersQuery([], true), { wrapper });
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
});
it('should return empty array when success is false in response', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: false, error: 'Some error' }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when data is not an array', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: null }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when data is an object instead of array', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: { item: 'not an array' } }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should fetch for single flyer ID', async () => {
const mockFlyerItems: FlyerItem[] = [
createMockFlyerItem({
flyer_item_id: 1,
flyer_id: 100,
item: 'Bread',
price_display: '$2.49',
price_in_cents: 249,
}),
];
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockFlyerItems }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith([100]);
expect(result.current.data).toEqual(mockFlyerItems);
});
it('should handle 404 error status', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'Flyers not found' }),
} as Response);
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([999]), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Flyers not found');
});
it('should handle network error', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useFlyerItemsForFlyersQuery([100]), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Network error');
});
it('should be enabled by default when flyerIds has items', async () => {
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
// Call without the enabled parameter (uses default value of true)
renderHook(() => useFlyerItemsForFlyersQuery([100]), { wrapper });
await waitFor(() => expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalled());
});
it('should use consistent query key regardless of flyer IDs order', async () => {
const mockItems: FlyerItem[] = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 100 })];
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockItems }),
} as Response);
// First call with [100, 200, 50]
const { result: result1 } = renderHook(() => useFlyerItemsForFlyersQuery([100, 200, 50]), {
wrapper,
});
await waitFor(() => expect(result1.current.isSuccess).toBe(true));
// API should be called with original order
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith([100, 200, 50]);
// Second call with same IDs in different order should use cached result
// because query key uses sorted IDs (50,100,200)
const { result: result2 } = renderHook(() => useFlyerItemsForFlyersQuery([50, 200, 100]), {
wrapper,
});
// Should immediately have data from cache (no additional API call)
await waitFor(() => expect(result2.current.isSuccess).toBe(true));
// API should still only have been called once (cached)
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1);
expect(result2.current.data).toEqual(mockItems);
});
});

View File

@@ -0,0 +1,193 @@
// src/hooks/queries/useLeaderboardQuery.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useLeaderboardQuery } from './useLeaderboardQuery';
import * as apiClient from '../../services/apiClient';
import type { LeaderboardUser } from '../../types';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useLeaderboardQuery', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
it('should fetch leaderboard successfully', async () => {
const mockLeaderboard: LeaderboardUser[] = [
{
user_id: 'user-123',
full_name: 'Top Scorer',
avatar_url: 'https://example.com/avatar1.png',
points: 1500,
rank: '1',
},
{
user_id: 'user-456',
full_name: 'Second Place',
avatar_url: null,
points: 1200,
rank: '2',
},
];
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockLeaderboard }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchLeaderboard).toHaveBeenCalledWith(10);
expect(result.current.data).toEqual(mockLeaderboard);
});
it('should fetch leaderboard with custom limit', async () => {
const mockLeaderboard: LeaderboardUser[] = [
{
user_id: 'user-789',
full_name: 'Champion',
avatar_url: 'https://example.com/avatar.png',
points: 2000,
rank: '1',
},
];
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockLeaderboard }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(5), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchLeaderboard).toHaveBeenCalledWith(5);
expect(result.current.data).toEqual(mockLeaderboard);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Authentication required' }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Authentication required');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch leaderboard');
});
it('should return empty array for no users on leaderboard', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when success is false', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: false, data: null }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when data is not an array', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: { invalid: 'data' } }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should not fetch when disabled', () => {
renderHook(() => useLeaderboardQuery(10, false), { wrapper });
expect(mockedApiClient.fetchLeaderboard).not.toHaveBeenCalled();
});
it('should handle users with null full_name and avatar_url', async () => {
const mockLeaderboard: LeaderboardUser[] = [
{
user_id: 'user-anon',
full_name: null,
avatar_url: null,
points: 100,
rank: '1',
},
];
mockedApiClient.fetchLeaderboard.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockLeaderboard }),
} as Response);
const { result } = renderHook(() => useLeaderboardQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockLeaderboard);
expect(result.current.data?.[0].full_name).toBeNull();
expect(result.current.data?.[0].avatar_url).toBeNull();
});
});

View File

@@ -0,0 +1,216 @@
// src/hooks/queries/usePriceHistoryQuery.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { usePriceHistoryQuery } from './usePriceHistoryQuery';
import * as apiClient from '../../services/apiClient';
import type { HistoricalPriceDataPoint } from '../../types';
import { createMockHistoricalPriceDataPoint } from '../../tests/utils/mockFactories';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('usePriceHistoryQuery', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
it('should fetch price history successfully', async () => {
const masterItemIds = [101, 102];
const mockPriceHistory: HistoricalPriceDataPoint[] = [
createMockHistoricalPriceDataPoint({
master_item_id: 101,
avg_price_in_cents: 299,
summary_date: '2026-01-15',
}),
createMockHistoricalPriceDataPoint({
master_item_id: 101,
avg_price_in_cents: 349,
summary_date: '2026-01-16',
}),
createMockHistoricalPriceDataPoint({
master_item_id: 102,
avg_price_in_cents: 199,
summary_date: '2026-01-15',
}),
];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockPriceHistory }),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchHistoricalPriceData).toHaveBeenCalledWith(masterItemIds);
expect(result.current.data).toEqual(mockPriceHistory);
});
it('should handle API error with error message', async () => {
const masterItemIds = [101];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Authentication required' }),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Authentication required');
});
it('should handle API error without message (JSON parse failure)', async () => {
const masterItemIds = [101];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
const masterItemIds = [101];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch price history');
});
it('should return empty array for no price history data', async () => {
const masterItemIds = [101];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when success is false', async () => {
const masterItemIds = [101];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: false, data: null }),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should return empty array when data is not an array', async () => {
const masterItemIds = [101];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: 'not an array' }),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should not fetch when masterItemIds is empty', async () => {
const { result } = renderHook(() => usePriceHistoryQuery([]), { wrapper });
// Query should not be enabled with empty array
expect(result.current.fetchStatus).toBe('idle');
expect(mockedApiClient.fetchHistoricalPriceData).not.toHaveBeenCalled();
});
it('should not fetch when explicitly disabled', () => {
const masterItemIds = [101, 102];
renderHook(() => usePriceHistoryQuery(masterItemIds, false), { wrapper });
expect(mockedApiClient.fetchHistoricalPriceData).not.toHaveBeenCalled();
});
it('should return empty array from queryFn when masterItemIds becomes empty during execution', async () => {
// This tests the early return within queryFn for empty arrays
// The query is enabled by default, but if masterItemIds is empty, queryFn returns []
const masterItemIds: number[] = [];
// Even if enabled is forced to true, the queryFn should return empty array
// Note: The hook's enabled condition prevents this from running normally,
// but the queryFn has defensive code that returns [] if masterItemIds.length === 0
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds, true), { wrapper });
// Query is disabled when masterItemIds is empty due to enabled condition
expect(result.current.fetchStatus).toBe('idle');
expect(mockedApiClient.fetchHistoricalPriceData).not.toHaveBeenCalled();
});
it('should handle price history with null avg_price_in_cents values', async () => {
const masterItemIds = [101];
const mockPriceHistory: HistoricalPriceDataPoint[] = [
createMockHistoricalPriceDataPoint({
master_item_id: 101,
avg_price_in_cents: null,
summary_date: '2026-01-15',
}),
createMockHistoricalPriceDataPoint({
master_item_id: 101,
avg_price_in_cents: 299,
summary_date: '2026-01-16',
}),
];
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockPriceHistory }),
} as Response);
const { result } = renderHook(() => usePriceHistoryQuery(masterItemIds), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockPriceHistory);
expect(result.current.data?.[0].avg_price_in_cents).toBeNull();
expect(result.current.data?.[1].avg_price_in_cents).toBe(299);
});
});

View File

@@ -0,0 +1,413 @@
// src/hooks/queries/useUserProfileDataQuery.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useUserProfileDataQuery } from './useUserProfileDataQuery';
import * as apiClient from '../../services/apiClient';
import type { UserProfile, Achievement, UserAchievement } from '../../types';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useUserProfileDataQuery', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
const mockProfile: UserProfile = {
full_name: 'Test User',
avatar_url: 'https://example.com/avatar.png',
address_id: 1,
points: 100,
role: 'user',
preferences: { darkMode: false, unitSystem: 'metric' },
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
user: {
user_id: 'user-123',
email: 'test@example.com',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
address: {
address_id: 1,
address_line_1: '123 Main St',
city: 'Test City',
province_state: 'ON',
postal_code: 'A1B 2C3',
country: 'Canada',
latitude: null,
longitude: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
};
const mockAchievements: (UserAchievement & Achievement)[] = [
{
user_id: 'user-123',
achievement_id: 1,
achieved_at: '2025-01-15T10:00:00Z',
name: 'First Upload',
description: 'Uploaded your first flyer',
icon: 'trophy',
points_value: 10,
created_at: '2025-01-01T00:00:00Z',
},
{
user_id: 'user-123',
achievement_id: 2,
achieved_at: '2025-01-20T15:30:00Z',
name: 'Deal Hunter',
description: 'Found 10 deals',
icon: 'star',
points_value: 25,
created_at: '2025-01-01T00:00:00Z',
},
];
it('should fetch user profile and achievements successfully', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockAchievements }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalled();
expect(mockedApiClient.getUserAchievements).toHaveBeenCalled();
expect(result.current.data).toEqual({
profile: mockProfile,
achievements: mockAchievements,
});
});
it('should handle profile API error with error message', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Authentication required' }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockAchievements }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Authentication required');
});
it('should handle profile API error without message', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockAchievements }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when profile error.message is empty', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockAchievements }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch user profile');
});
it('should handle achievements API error with error message', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ message: 'Access denied' }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Access denied');
});
it('should handle achievements API error without message', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when achievements error.message is empty', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch user achievements');
});
it('should return empty array for no achievements', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
profile: mockProfile,
achievements: [],
});
});
it('should handle undefined achievements data gracefully', async () => {
// When API returns response without data wrapper (legacy format)
// achievementsJson.data will be undefined, falling back to achievementsJson itself
// Then achievements || [] will convert falsy value to empty array
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
// Return empty array directly (no wrapper) - this tests the fallback path
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
profile: mockProfile,
achievements: [],
});
});
it('should handle response without data wrapper (direct response)', async () => {
// Some APIs may return data directly without the { success, data } wrapper
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProfile),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAchievements),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
profile: mockProfile,
achievements: mockAchievements,
});
});
it('should not fetch when disabled', () => {
renderHook(() => useUserProfileDataQuery(false), { wrapper });
expect(mockedApiClient.getAuthenticatedUserProfile).not.toHaveBeenCalled();
expect(mockedApiClient.getUserAchievements).not.toHaveBeenCalled();
});
it('should fetch when enabled is explicitly true', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockAchievements }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(true), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalled();
expect(mockedApiClient.getUserAchievements).toHaveBeenCalled();
});
it('should handle profile with minimal data', async () => {
const minimalProfile: UserProfile = {
full_name: null,
avatar_url: null,
address_id: null,
points: 0,
role: 'user',
preferences: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
user: {
user_id: 'user-456',
email: 'minimal@example.com',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
address: null,
};
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: minimalProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
profile: minimalProfile,
achievements: [],
});
});
it('should handle admin user profile', async () => {
const adminProfile: UserProfile = {
...mockProfile,
role: 'admin',
points: 500,
};
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: adminProfile }),
} as Response);
mockedApiClient.getUserAchievements.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: mockAchievements }),
} as Response);
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.profile.role).toBe('admin');
expect(result.current.data?.profile.points).toBe(500);
});
it('should call both APIs in parallel', async () => {
let profileCallTime: number | null = null;
let achievementsCallTime: number | null = null;
mockedApiClient.getAuthenticatedUserProfile.mockImplementation(() => {
profileCallTime = Date.now();
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, data: mockProfile }),
} as Response);
});
mockedApiClient.getUserAchievements.mockImplementation(() => {
achievementsCallTime = Date.now();
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, data: mockAchievements }),
} as Response);
});
const { result } = renderHook(() => useUserProfileDataQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Both calls should have happened
expect(profileCallTime).not.toBeNull();
expect(achievementsCallTime).not.toBeNull();
// Both calls should have been made nearly simultaneously (within 50ms)
// This verifies Promise.all is being used for parallel execution
expect(
Math.abs(
(profileCallTime as unknown as number) - (achievementsCallTime as unknown as number),
),
).toBeLessThan(50);
});
});

View File

@@ -0,0 +1,161 @@
// src/hooks/useUserProfileData.test.ts
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useUserProfileData } from './useUserProfileData';
import * as useUserProfileDataQueryModule from './queries/useUserProfileDataQuery';
import type { UserProfile, Achievement } from '../types';
// Mock the underlying query hook
vi.mock('./queries/useUserProfileDataQuery');
const mockedUseUserProfileDataQuery = vi.mocked(
useUserProfileDataQueryModule.useUserProfileDataQuery,
);
// Mock factories for consistent test data
const createMockUserProfile = (overrides: Partial<UserProfile> = {}): UserProfile => ({
user: {
user_id: 'user-123',
email: 'test@example.com',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
full_name: 'Test User',
avatar_url: null,
address_id: null,
points: 0,
role: 'user',
preferences: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
address: null,
...overrides,
});
const createMockAchievement = (overrides: Partial<Achievement> = {}): Achievement => ({
achievement_id: 1,
name: 'First Upload',
description: 'You uploaded your first flyer!',
points_value: 10,
created_at: new Date().toISOString(),
...overrides,
});
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Turn off retries for tests
},
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe('useUserProfileData Hook', () => {
const mockProfileData = createMockUserProfile();
const mockAchievementsData = [createMockAchievement()];
const mockQueryData = {
profile: mockProfileData,
achievements: mockAchievementsData,
};
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock to a default loading state
mockedUseUserProfileDataQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as ReturnType<typeof mockedUseUserProfileDataQuery>);
});
it('should return loading state initially', () => {
const { result } = renderHook(() => useUserProfileData(), { wrapper: createWrapper() });
expect(result.current.isLoading).toBe(true);
expect(result.current.profile).toBeNull();
expect(result.current.achievements).toEqual([]);
expect(result.current.error).toBeNull();
});
it('should return profile and achievements on successful fetch', () => {
mockedUseUserProfileDataQuery.mockReturnValue({
data: mockQueryData,
isLoading: false,
error: null,
} as ReturnType<typeof mockedUseUserProfileDataQuery>);
const { result } = renderHook(() => useUserProfileData(), { wrapper: createWrapper() });
expect(result.current.isLoading).toBe(false);
expect(result.current.profile).toEqual(mockProfileData);
expect(result.current.achievements).toEqual(mockAchievementsData);
expect(result.current.error).toBeNull();
});
it('should return an error message on failure', () => {
const mockError = new Error('Failed to fetch profile');
mockedUseUserProfileDataQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: mockError,
} as ReturnType<typeof mockedUseUserProfileDataQuery>);
const { result } = renderHook(() => useUserProfileData(), { wrapper: createWrapper() });
expect(result.current.isLoading).toBe(false);
expect(result.current.profile).toBeNull();
expect(result.current.achievements).toEqual([]);
expect(result.current.error).toBe('Failed to fetch profile');
});
it('setProfile should update the profile in the query cache with a new object', () => {
const queryClient = new QueryClient();
queryClient.setQueryData(['user-profile-data'], mockQueryData);
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUserProfileData(), { wrapper });
const updatedProfile: UserProfile = { ...mockProfileData, full_name: 'Updated' };
act(() => {
result.current.setProfile(updatedProfile);
});
const updatedData = queryClient.getQueryData(['user-profile-data']) as typeof mockQueryData;
expect(updatedData.profile).toEqual(updatedProfile);
});
it('setProfile should not throw if oldData is undefined', () => {
const { result } = renderHook(() => useUserProfileData(), { wrapper: createWrapper() });
const newProfile = createMockUserProfile();
expect(() => {
act(() => {
result.current.setProfile(newProfile);
});
}).not.toThrow();
const cachedData = result.current.profile;
expect(cachedData).toBeNull();
});
it('should maintain stable function references across rerenders', () => {
const { result, rerender } = renderHook(() => useUserProfileData(), {
wrapper: createWrapper(),
});
const initialSetProfile = result.current.setProfile;
rerender();
expect(result.current.setProfile).toBe(initialSetProfile);
});
});

View File

@@ -0,0 +1,304 @@
// src/hooks/useWebSocket.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useWebSocket } from './useWebSocket';
import { eventBus } from '../services/eventBus';
// Mock eventBus
vi.mock('../services/eventBus', () => ({
eventBus: {
dispatch: vi.fn(),
},
}));
// A mock WebSocket class for testing
class MockWebSocket {
static instances: MockWebSocket[] = [];
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
url: string;
readyState: number;
onopen: () => void = () => {};
onclose: (event: { code: number; reason: string; wasClean: boolean }) => void = () => {};
onmessage: (event: { data: string }) => void = () => {};
onerror: (error: Event) => void = () => {};
send = vi.fn();
close = vi.fn((code = 1000, reason = 'Client disconnecting') => {
if (this.readyState === MockWebSocket.CLOSED || this.readyState === MockWebSocket.CLOSING)
return;
this.readyState = MockWebSocket.CLOSING;
setTimeout(() => {
this.readyState = MockWebSocket.CLOSED;
this.onclose({ code, reason, wasClean: code === 1000 });
}, 0);
});
constructor(url: string) {
this.url = url;
this.readyState = MockWebSocket.CONNECTING;
MockWebSocket.instances.push(this);
}
// --- Test Helper Methods ---
_open() {
this.readyState = MockWebSocket.OPEN;
this.onopen();
}
_message(data: object | string) {
if (typeof data === 'string') {
this.onmessage({ data });
} else {
this.onmessage({ data: JSON.stringify(data) });
}
}
_error(errorEvent = new Event('error')) {
this.onerror(errorEvent);
}
_close(code: number, reason: string) {
if (this.readyState === MockWebSocket.CLOSED) return;
this.readyState = MockWebSocket.CLOSED;
this.onclose({ code, reason, wasClean: code === 1000 });
}
static get lastInstance(): MockWebSocket | undefined {
return this.instances[this.instances.length - 1];
}
static clearInstances() {
this.instances = [];
}
}
describe('useWebSocket Hook', () => {
const mockToken = 'test-token';
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
global.WebSocket = MockWebSocket as any;
MockWebSocket.clearInstances();
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'testhost.com' },
writable: true,
});
Object.defineProperty(document, 'cookie', {
writable: true,
value: `accessToken=${mockToken}`,
});
vi.clearAllMocks();
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should not connect on mount if autoConnect is false', () => {
renderHook(() => useWebSocket({ autoConnect: false }));
expect(MockWebSocket.instances).toHaveLength(0);
});
it('should auto-connect on mount by default', () => {
const { result } = renderHook(() => useWebSocket());
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.lastInstance?.url).toBe(`wss://testhost.com/ws?token=${mockToken}`);
expect(result.current.isConnecting).toBe(true);
});
it('should set an error state if no access token is found', () => {
document.cookie = ''; // No token
const { result } = renderHook(() => useWebSocket());
expect(result.current.isConnected).toBe(false);
expect(result.current.isConnecting).toBe(false);
expect(result.current.error).toBe('No access token found. Please log in.');
expect(MockWebSocket.instances).toHaveLength(0);
});
it('should transition to connected state on WebSocket open', () => {
const onConnect = vi.fn();
const { result } = renderHook(() => useWebSocket({ onConnect }));
expect(result.current.isConnecting).toBe(true);
act(() => MockWebSocket.lastInstance?._open());
expect(result.current.isConnected).toBe(true);
expect(result.current.isConnecting).toBe(false);
expect(onConnect).toHaveBeenCalled();
});
it('should handle incoming messages and dispatch to eventBus', () => {
renderHook(() => useWebSocket());
act(() => MockWebSocket.lastInstance?._open());
const dealData = { flyerId: 1 };
act(() => MockWebSocket.lastInstance?._message({ type: 'deal-notification', data: dealData }));
expect(eventBus.dispatch).toHaveBeenCalledWith('notification:deal', dealData);
});
it('should log an error for invalid JSON messages', () => {
renderHook(() => useWebSocket());
act(() => MockWebSocket.lastInstance?._open());
const invalidJson = 'this is not json';
act(() => MockWebSocket.lastInstance?._message(invalidJson));
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[WebSocket] Failed to parse message:',
expect.any(SyntaxError),
);
expect(eventBus.dispatch).not.toHaveBeenCalled();
});
it('should respond to ping with pong', () => {
renderHook(() => useWebSocket());
act(() => MockWebSocket.lastInstance?._open());
act(() => MockWebSocket.lastInstance?._message({ type: 'ping', data: {} }));
expect(MockWebSocket.lastInstance?.send).toHaveBeenCalledWith(
expect.stringContaining('"type":"pong"'),
);
});
it('should disconnect and clean up when disconnect is called', () => {
const onDisconnect = vi.fn();
const { result } = renderHook(() => useWebSocket({ onDisconnect }));
act(() => MockWebSocket.lastInstance?._open());
act(() => result.current.disconnect());
expect(MockWebSocket.lastInstance?.close).toHaveBeenCalledWith(1000, 'Client disconnecting');
expect(result.current.isConnected).toBe(false);
act(() => vi.runAllTimers());
expect(onDisconnect).toHaveBeenCalled();
// Ensure no reconnection attempt is made
expect(MockWebSocket.instances).toHaveLength(1);
});
it('should attempt to reconnect on unexpected close', () => {
const { result } = renderHook(() => useWebSocket({ reconnectDelay: 1000 }));
act(() => MockWebSocket.lastInstance?._open());
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal closure'));
expect(result.current.isConnected).toBe(false);
act(() => vi.advanceTimersByTime(1000));
expect(MockWebSocket.instances).toHaveLength(2);
expect(result.current.isConnecting).toBe(true);
});
it('should use exponential backoff for reconnection', () => {
renderHook(() => useWebSocket({ reconnectDelay: 1000, maxReconnectAttempts: 3 }));
act(() => MockWebSocket.lastInstance?._open());
// 1st failure -> 1s delay
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal'));
act(() => vi.advanceTimersByTime(1000));
expect(MockWebSocket.instances).toHaveLength(2);
// 2nd failure -> 2s delay
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal'));
act(() => vi.advanceTimersByTime(2000));
expect(MockWebSocket.instances).toHaveLength(3);
// 3rd failure -> 4s delay
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal'));
act(() => vi.advanceTimersByTime(4000));
expect(MockWebSocket.instances).toHaveLength(4);
});
it('should stop reconnecting after maxReconnectAttempts', () => {
const { result } = renderHook(() =>
useWebSocket({ reconnectDelay: 100, maxReconnectAttempts: 1 }),
);
act(() => MockWebSocket.lastInstance?._open());
// 1st failure
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal'));
act(() => vi.advanceTimersByTime(100));
expect(MockWebSocket.instances).toHaveLength(2);
// 2nd failure (should be the last)
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal'));
act(() => vi.advanceTimersByTime(5000));
expect(MockWebSocket.instances).toHaveLength(2); // No new instance
expect(result.current.error).toBe('Failed to reconnect after multiple attempts');
});
it('should reset reconnect attempts on a successful connection', () => {
renderHook(() => useWebSocket({ reconnectDelay: 100, maxReconnectAttempts: 2 }));
act(() => MockWebSocket.lastInstance?._open());
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal'));
act(() => vi.advanceTimersByTime(100)); // 1st reconnect attempt
expect(MockWebSocket.instances).toHaveLength(2);
act(() => MockWebSocket.lastInstance?._open()); // Reconnect succeeds
act(() => MockWebSocket.lastInstance?._close(1006, 'Abnormal'));
act(() => vi.advanceTimersByTime(100)); // Delay should be reset to base
expect(MockWebSocket.instances).toHaveLength(3);
});
it('should send a message when connected', () => {
const { result } = renderHook(() => useWebSocket());
act(() => MockWebSocket.lastInstance?._open());
const message = { type: 'ping' as const, data: {}, timestamp: new Date().toISOString() };
act(() => result.current.send(message));
expect(MockWebSocket.lastInstance?.send).toHaveBeenCalledWith(JSON.stringify(message));
});
it('should warn when trying to send a message while not connected', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(() => useWebSocket());
// Do not open connection
const message = { type: 'ping' as const, data: {}, timestamp: new Date().toISOString() };
act(() => result.current.send(message));
expect(MockWebSocket.lastInstance?.send).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith('[WebSocket] Cannot send message: not connected');
consoleWarnSpy.mockRestore();
});
it('should clean up on unmount', () => {
const { unmount } = renderHook(() => useWebSocket());
const instance = MockWebSocket.lastInstance;
unmount();
expect(instance?.close).toHaveBeenCalled();
act(() => vi.advanceTimersByTime(5000));
expect(MockWebSocket.instances).toHaveLength(1); // No new reconnect attempts
});
it('should maintain stable function references across rerenders', () => {
const { result, rerender } = renderHook(() => useWebSocket());
const initialConnect = result.current.connect;
const initialDisconnect = result.current.disconnect;
const initialSend = result.current.send;
rerender();
expect(result.current.connect).toBe(initialConnect);
expect(result.current.disconnect).toBe(initialDisconnect);
expect(result.current.send).toBe(initialSend);
});
});

View File

@@ -0,0 +1,478 @@
// src/pages/DealsPage.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { DealsPage } from './DealsPage';
import { useAuth } from '../hooks/useAuth';
import { useWatchedItems } from '../hooks/useWatchedItems';
import { useShoppingLists } from '../hooks/useShoppingLists';
import {
createMockUser,
createMockUserProfile,
createMockMasterGroceryItem,
createMockShoppingList,
resetMockIds,
} from '../tests/utils/mockFactories';
import type { MasterGroceryItem, ShoppingList } from '../types';
// Mock the hooks that DealsPage depends on
vi.mock('../hooks/useAuth');
vi.mock('../hooks/useWatchedItems');
vi.mock('../hooks/useShoppingLists');
// Mock the child components to isolate DealsPage logic
vi.mock('../features/shopping/WatchedItemsList', () => ({
WatchedItemsList: vi.fn(({ items, user, activeListId }) => (
<div data-testid="watched-items-list">
<span data-testid="watched-items-count">{items?.length ?? 0} items</span>
<span data-testid="watched-items-user">{user ? 'logged-in' : 'logged-out'}</span>
<span data-testid="watched-items-active-list">{activeListId ?? 'none'}</span>
</div>
)),
}));
vi.mock('../features/charts/PriceChart', () => ({
PriceChart: vi.fn(({ unitSystem, user }) => (
<div data-testid="price-chart">
<span data-testid="price-chart-unit-system">{unitSystem}</span>
<span data-testid="price-chart-user">{user ? 'logged-in' : 'logged-out'}</span>
</div>
)),
}));
vi.mock('../features/charts/PriceHistoryChart', () => ({
PriceHistoryChart: vi.fn(() => <div data-testid="price-history-chart">Price History Chart</div>),
}));
// Cast the mocked hooks for type-safe assertions
const mockedUseAuth = useAuth as Mock;
const mockedUseWatchedItems = useWatchedItems as Mock;
const mockedUseShoppingLists = useShoppingLists as Mock;
describe('DealsPage Component', () => {
// Create mock data
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockUserProfile = createMockUserProfile({ user: mockUser });
const mockWatchedItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Bananas' }),
];
const mockShoppingLists: ShoppingList[] = [
createMockShoppingList({ shopping_list_id: 101, name: 'Weekly Groceries' }),
createMockShoppingList({ shopping_list_id: 102, name: 'Party Shopping' }),
];
// Mock function implementations
const mockAddWatchedItem = vi.fn();
const mockRemoveWatchedItem = vi.fn();
const mockAddItemToList = vi.fn();
const mockSetActiveListId = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
resetMockIds();
// Set up default mock implementations for authenticated user with data
mockedUseAuth.mockReturnValue({
userProfile: mockUserProfile,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
mockedUseWatchedItems.mockReturnValue({
watchedItems: mockWatchedItems,
addWatchedItem: mockAddWatchedItem,
removeWatchedItem: mockRemoveWatchedItem,
error: null,
});
mockedUseShoppingLists.mockReturnValue({
shoppingLists: mockShoppingLists,
activeListId: 101,
setActiveListId: mockSetActiveListId,
createList: vi.fn(),
deleteList: vi.fn(),
addItemToList: mockAddItemToList,
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null,
});
});
describe('Page Rendering', () => {
it('should render the page title', () => {
render(<DealsPage />);
expect(
screen.getByRole('heading', { name: /my deals & watched items/i }),
).toBeInTheDocument();
});
it('should render all three main components', () => {
render(<DealsPage />);
expect(screen.getByTestId('watched-items-list')).toBeInTheDocument();
expect(screen.getByTestId('price-chart')).toBeInTheDocument();
expect(screen.getByTestId('price-history-chart')).toBeInTheDocument();
});
it('should apply correct layout classes for max width and spacing', () => {
const { container } = render(<DealsPage />);
const mainContainer = container.firstChild as HTMLElement;
expect(mainContainer).toHaveClass('max-w-4xl');
expect(mainContainer).toHaveClass('mx-auto');
expect(mainContainer).toHaveClass('p-4');
expect(mainContainer).toHaveClass('space-y-6');
});
});
describe('Props Passing to WatchedItemsList', () => {
it('should pass watched items to WatchedItemsList', () => {
render(<DealsPage />);
expect(screen.getByTestId('watched-items-count')).toHaveTextContent('2 items');
});
it('should pass user to WatchedItemsList when authenticated', () => {
render(<DealsPage />);
expect(screen.getByTestId('watched-items-user')).toHaveTextContent('logged-in');
});
it('should pass null user to WatchedItemsList when not authenticated', () => {
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<DealsPage />);
expect(screen.getByTestId('watched-items-user')).toHaveTextContent('logged-out');
});
it('should pass activeListId to WatchedItemsList', () => {
render(<DealsPage />);
expect(screen.getByTestId('watched-items-active-list')).toHaveTextContent('101');
});
it('should pass "none" when no active list is selected', () => {
mockedUseShoppingLists.mockReturnValue({
...mockedUseShoppingLists(),
activeListId: null,
});
render(<DealsPage />);
expect(screen.getByTestId('watched-items-active-list')).toHaveTextContent('none');
});
});
describe('Props Passing to PriceChart', () => {
it('should pass imperial unit system to PriceChart', () => {
render(<DealsPage />);
expect(screen.getByTestId('price-chart-unit-system')).toHaveTextContent('imperial');
});
it('should pass user to PriceChart when authenticated', () => {
render(<DealsPage />);
expect(screen.getByTestId('price-chart-user')).toHaveTextContent('logged-in');
});
it('should pass null user to PriceChart when not authenticated', () => {
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<DealsPage />);
expect(screen.getByTestId('price-chart-user')).toHaveTextContent('logged-out');
});
});
describe('User Authentication States', () => {
it('should render correctly when user is authenticated', () => {
render(<DealsPage />);
// Both components should receive the user
expect(screen.getByTestId('watched-items-user')).toHaveTextContent('logged-in');
expect(screen.getByTestId('price-chart-user')).toHaveTextContent('logged-in');
});
it('should render correctly when user is not authenticated', () => {
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<DealsPage />);
// Both components should receive null user
expect(screen.getByTestId('watched-items-user')).toHaveTextContent('logged-out');
expect(screen.getByTestId('price-chart-user')).toHaveTextContent('logged-out');
});
it('should handle undefined user within userProfile gracefully', () => {
// Edge case where userProfile exists but user is undefined
mockedUseAuth.mockReturnValue({
userProfile: { ...mockUserProfile, user: undefined },
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<DealsPage />);
// Should treat undefined user as logged out
expect(screen.getByTestId('watched-items-user')).toHaveTextContent('logged-out');
expect(screen.getByTestId('price-chart-user')).toHaveTextContent('logged-out');
});
});
describe('Watched Items Data States', () => {
it('should render with empty watched items list', () => {
mockedUseWatchedItems.mockReturnValue({
watchedItems: [],
addWatchedItem: mockAddWatchedItem,
removeWatchedItem: mockRemoveWatchedItem,
error: null,
});
render(<DealsPage />);
expect(screen.getByTestId('watched-items-count')).toHaveTextContent('0 items');
});
it('should render with multiple watched items', () => {
const manyItems = Array.from({ length: 10 }, (_, i) =>
createMockMasterGroceryItem({
master_grocery_item_id: i + 1,
name: `Item ${i + 1}`,
}),
);
mockedUseWatchedItems.mockReturnValue({
watchedItems: manyItems,
addWatchedItem: mockAddWatchedItem,
removeWatchedItem: mockRemoveWatchedItem,
error: null,
});
render(<DealsPage />);
expect(screen.getByTestId('watched-items-count')).toHaveTextContent('10 items');
});
});
describe('Shopping Lists Data States', () => {
it('should render when no shopping lists exist', () => {
mockedUseShoppingLists.mockReturnValue({
...mockedUseShoppingLists(),
shoppingLists: [],
activeListId: null,
});
render(<DealsPage />);
expect(screen.getByTestId('watched-items-active-list')).toHaveTextContent('none');
});
it('should render when shopping lists exist but none is active', () => {
mockedUseShoppingLists.mockReturnValue({
...mockedUseShoppingLists(),
activeListId: null,
});
render(<DealsPage />);
expect(screen.getByTestId('watched-items-active-list')).toHaveTextContent('none');
});
});
describe('handleAddItemFromWatchedList Function', () => {
it('should call addItemToList with correct parameters when activeListId exists', async () => {
// Import the mocked component to access its props
const { WatchedItemsList } = await import('../features/shopping/WatchedItemsList');
const MockedWatchedItemsList = vi.mocked(WatchedItemsList);
render(<DealsPage />);
// Get the onAddItemToList prop passed to WatchedItemsList
const lastCall =
MockedWatchedItemsList.mock.calls[MockedWatchedItemsList.mock.calls.length - 1];
const onAddItemToList = lastCall[0].onAddItemToList;
// Simulate calling the handler with a master item ID
onAddItemToList(42);
// Verify addItemToList was called with the active list ID and master item ID
expect(mockAddItemToList).toHaveBeenCalledTimes(1);
expect(mockAddItemToList).toHaveBeenCalledWith(101, { masterItemId: 42 });
});
it('should not call addItemToList when activeListId is null', async () => {
mockedUseShoppingLists.mockReturnValue({
...mockedUseShoppingLists(),
activeListId: null,
addItemToList: mockAddItemToList,
});
const { WatchedItemsList } = await import('../features/shopping/WatchedItemsList');
const MockedWatchedItemsList = vi.mocked(WatchedItemsList);
render(<DealsPage />);
// Get the onAddItemToList prop passed to WatchedItemsList
const lastCall =
MockedWatchedItemsList.mock.calls[MockedWatchedItemsList.mock.calls.length - 1];
const onAddItemToList = lastCall[0].onAddItemToList;
// Simulate calling the handler with a master item ID
onAddItemToList(42);
// Verify addItemToList was NOT called because there's no active list
expect(mockAddItemToList).not.toHaveBeenCalled();
});
});
describe('Callback Props Verification', () => {
it('should pass addWatchedItem function to WatchedItemsList', async () => {
const { WatchedItemsList } = await import('../features/shopping/WatchedItemsList');
const MockedWatchedItemsList = vi.mocked(WatchedItemsList);
render(<DealsPage />);
const lastCall =
MockedWatchedItemsList.mock.calls[MockedWatchedItemsList.mock.calls.length - 1];
expect(lastCall[0].onAddItem).toBe(mockAddWatchedItem);
});
it('should pass removeWatchedItem function to WatchedItemsList', async () => {
const { WatchedItemsList } = await import('../features/shopping/WatchedItemsList');
const MockedWatchedItemsList = vi.mocked(WatchedItemsList);
render(<DealsPage />);
const lastCall =
MockedWatchedItemsList.mock.calls[MockedWatchedItemsList.mock.calls.length - 1];
expect(lastCall[0].onRemoveItem).toBe(mockRemoveWatchedItem);
});
});
describe('Component Structure', () => {
it('should render components in the correct order', () => {
const { container } = render(<DealsPage />);
const mainContainer = container.firstChild as HTMLElement;
const children = Array.from(mainContainer.children);
// First child should be the heading
expect(children[0].tagName).toBe('H1');
expect(children[0]).toHaveTextContent(/my deals & watched items/i);
// Subsequent children are the three main components
// (indices may vary based on actual DOM structure)
expect(screen.getByTestId('watched-items-list')).toBeInTheDocument();
expect(screen.getByTestId('price-chart')).toBeInTheDocument();
expect(screen.getByTestId('price-history-chart')).toBeInTheDocument();
});
it('should have proper heading styling', () => {
render(<DealsPage />);
const heading = screen.getByRole('heading', { name: /my deals & watched items/i });
expect(heading).toHaveClass('text-3xl');
expect(heading).toHaveClass('font-bold');
});
});
describe('Edge Cases', () => {
it('should handle userProfile with null user property', () => {
mockedUseAuth.mockReturnValue({
userProfile: { ...mockUserProfile, user: null } as unknown,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<DealsPage />);
// Should render without crashing and treat user as null
expect(screen.getByTestId('watched-items-list')).toBeInTheDocument();
expect(screen.getByTestId('watched-items-user')).toHaveTextContent('logged-out');
});
it('should handle undefined watchedItems gracefully', () => {
mockedUseWatchedItems.mockReturnValue({
watchedItems: undefined,
addWatchedItem: mockAddWatchedItem,
removeWatchedItem: mockRemoveWatchedItem,
error: null,
});
// This should not throw an error - the component should handle undefined
render(<DealsPage />);
// The mock will receive undefined and show "0 items" or handle it
expect(screen.getByTestId('watched-items-list')).toBeInTheDocument();
});
it('should render PriceHistoryChart without any props', () => {
render(<DealsPage />);
// PriceHistoryChart doesn't take props from DealsPage
expect(screen.getByTestId('price-history-chart')).toBeInTheDocument();
expect(screen.getByTestId('price-history-chart')).toHaveTextContent('Price History Chart');
});
});
describe('Hook Integration', () => {
it('should call useAuth hook', () => {
render(<DealsPage />);
expect(mockedUseAuth).toHaveBeenCalled();
});
it('should call useWatchedItems hook', () => {
render(<DealsPage />);
expect(mockedUseWatchedItems).toHaveBeenCalled();
});
it('should call useShoppingLists hook', () => {
render(<DealsPage />);
expect(mockedUseShoppingLists).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,564 @@
// src/pages/FlyersPage.test.tsx
import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { FlyersPage } from './FlyersPage';
import { createMockFlyer, createMockUserProfile, resetMockIds } from '../tests/utils/mockFactories';
import type { Flyer, UserProfile } from '../types';
// Unmock the component to test the real implementation
vi.unmock('./FlyersPage');
// Mock the hooks used by FlyersPage
const mockUseAuth = vi.fn();
const mockUseFlyers = vi.fn();
const mockUseFlyerSelection = vi.fn();
vi.mock('../hooks/useAuth', () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock('../hooks/useFlyers', () => ({
useFlyers: () => mockUseFlyers(),
}));
vi.mock('../hooks/useFlyerSelection', () => ({
useFlyerSelection: (options: { flyers: Flyer[] }) => mockUseFlyerSelection(options),
}));
// Mock child components to isolate the FlyersPage logic
vi.mock('../features/flyer/FlyerList', async () => {
const { MockFlyerList } = await import('../tests/utils/componentMocks');
return { FlyerList: MockFlyerList };
});
vi.mock('../features/flyer/FlyerUploader', async () => {
const { MockFlyerUploader } = await import('../tests/utils/componentMocks');
return { FlyerUploader: MockFlyerUploader };
});
// Mock the logger to prevent console output during tests
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('FlyersPage Component', () => {
// Default mock implementations
const mockRefetchFlyers = vi.fn();
const mockHandleFlyerSelect = vi.fn();
const defaultAuthReturn = {
userProfile: null as UserProfile | null,
authStatus: 'SIGNED_OUT' as const,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
};
const defaultFlyersReturn = {
flyers: [] as Flyer[],
isLoadingFlyers: false,
flyersError: null,
fetchNextFlyersPage: vi.fn(),
hasNextFlyersPage: false,
isRefetchingFlyers: false,
refetchFlyers: mockRefetchFlyers,
};
const defaultSelectionReturn = {
selectedFlyer: null as Flyer | null,
handleFlyerSelect: mockHandleFlyerSelect,
flyerIdFromUrl: undefined,
};
beforeEach(() => {
vi.clearAllMocks();
resetMockIds();
// Set up default mock implementations
mockUseAuth.mockReturnValue(defaultAuthReturn);
mockUseFlyers.mockReturnValue(defaultFlyersReturn);
mockUseFlyerSelection.mockReturnValue(defaultSelectionReturn);
});
const renderPage = () => {
return render(
<MemoryRouter>
<FlyersPage />
</MemoryRouter>,
);
};
describe('Basic Rendering', () => {
it('should render the page title', () => {
renderPage();
expect(screen.getByRole('heading', { name: /flyers/i, level: 1 })).toBeInTheDocument();
});
it('should render the FlyerList component', () => {
renderPage();
expect(screen.getByTestId('flyer-list')).toBeInTheDocument();
});
it('should render the FlyerUploader component', () => {
renderPage();
expect(screen.getByTestId('flyer-uploader')).toBeInTheDocument();
});
it('should have the correct page structure with spacing', () => {
const { container } = renderPage();
// Check for the main container with styling classes
const mainDiv = container.querySelector('.max-w-4xl');
expect(mainDiv).toBeInTheDocument();
expect(mainDiv).toHaveClass('mx-auto', 'p-4', 'space-y-6');
});
});
describe('Empty State (No Flyers)', () => {
it('should show empty message when there are no flyers', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: [],
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-flyer-count', '0');
expect(screen.getByTestId('no-flyers-message')).toBeInTheDocument();
});
});
describe('With Flyers Data', () => {
const mockFlyers: Flyer[] = [
createMockFlyer({
flyer_id: 1,
store: { store_id: 1, name: 'Safeway', created_at: '', updated_at: '' },
item_count: 25,
}),
createMockFlyer({
flyer_id: 2,
store: { store_id: 2, name: 'Walmart', created_at: '', updated_at: '' },
item_count: 40,
}),
createMockFlyer({
flyer_id: 3,
store: { store_id: 3, name: 'Costco', created_at: '', updated_at: '' },
item_count: 60,
}),
];
it('should pass flyers to FlyerList component', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-flyer-count', '3');
});
it('should render all flyer items', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
expect(screen.getByTestId('flyer-item-1')).toBeInTheDocument();
expect(screen.getByTestId('flyer-item-2')).toBeInTheDocument();
expect(screen.getByTestId('flyer-item-3')).toBeInTheDocument();
});
it('should display store names in flyer list', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
expect(screen.getByText(/Safeway - 25 items/)).toBeInTheDocument();
expect(screen.getByText(/Walmart - 40 items/)).toBeInTheDocument();
expect(screen.getByText(/Costco - 60 items/)).toBeInTheDocument();
});
});
describe('Flyer Selection', () => {
const mockFlyers: Flyer[] = [
createMockFlyer({
flyer_id: 1,
store: { store_id: 1, name: 'Safeway', created_at: '', updated_at: '' },
item_count: 25,
}),
createMockFlyer({
flyer_id: 2,
store: { store_id: 2, name: 'Walmart', created_at: '', updated_at: '' },
item_count: 40,
}),
];
it('should pass selectedFlyerId to FlyerList when a flyer is selected', () => {
const selectedFlyer = mockFlyers[0];
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
mockUseFlyerSelection.mockReturnValue({
...defaultSelectionReturn,
selectedFlyer,
handleFlyerSelect: mockHandleFlyerSelect,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-selected-id', '1');
});
it('should pass null selectedFlyerId when no flyer is selected', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
mockUseFlyerSelection.mockReturnValue({
...defaultSelectionReturn,
selectedFlyer: null,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-selected-id', 'none');
});
it('should call handleFlyerSelect when a flyer is clicked', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
const flyerItem = screen.getByTestId('flyer-item-1');
const selectButton = within(flyerItem).getByRole('button');
fireEvent.click(selectButton);
expect(mockHandleFlyerSelect).toHaveBeenCalledTimes(1);
expect(mockHandleFlyerSelect).toHaveBeenCalledWith(mockFlyers[0]);
});
it('should call useFlyerSelection with the correct flyers', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
expect(mockUseFlyerSelection).toHaveBeenCalledWith({ flyers: mockFlyers });
});
});
describe('User Authentication States', () => {
const mockFlyers: Flyer[] = [
createMockFlyer({
flyer_id: 1,
store: { store_id: 1, name: 'Safeway', created_at: '', updated_at: '' },
item_count: 25,
}),
];
it('should pass null profile to FlyerList when user is not authenticated', () => {
mockUseAuth.mockReturnValue({
...defaultAuthReturn,
userProfile: null,
authStatus: 'SIGNED_OUT',
});
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-profile-role', 'none');
});
it('should pass user profile to FlyerList when user is authenticated', () => {
const userProfile = createMockUserProfile({ role: 'user' });
mockUseAuth.mockReturnValue({
...defaultAuthReturn,
userProfile,
authStatus: 'AUTHENTICATED',
});
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-profile-role', 'user');
});
it('should pass admin profile to FlyerList when user is admin', () => {
const adminProfile = createMockUserProfile({ role: 'admin' });
mockUseAuth.mockReturnValue({
...defaultAuthReturn,
userProfile: adminProfile,
authStatus: 'AUTHENTICATED',
});
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-profile-role', 'admin');
});
});
describe('FlyerUploader Integration', () => {
it('should pass refetchFlyers to FlyerUploader as onProcessingComplete', () => {
renderPage();
// Click the mock upload complete button
const completeButton = screen.getByTestId('mock-upload-complete-btn');
fireEvent.click(completeButton);
// Verify refetchFlyers was called
expect(mockRefetchFlyers).toHaveBeenCalledTimes(1);
});
it('should trigger data refresh when upload completes', () => {
const mockFlyers: Flyer[] = [
createMockFlyer({
flyer_id: 1,
store: { store_id: 1, name: 'Safeway', created_at: '', updated_at: '' },
}),
];
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
// Simulate upload completion
const completeButton = screen.getByTestId('mock-upload-complete-btn');
fireEvent.click(completeButton);
expect(mockRefetchFlyers).toHaveBeenCalled();
});
});
describe('Hook Integration', () => {
it('should call useAuth hook', () => {
renderPage();
expect(mockUseAuth).toHaveBeenCalled();
});
it('should call useFlyers hook', () => {
renderPage();
expect(mockUseFlyers).toHaveBeenCalled();
});
it('should call useFlyerSelection with flyers from useFlyers', () => {
const mockFlyers: Flyer[] = [
createMockFlyer({ flyer_id: 1 }),
createMockFlyer({ flyer_id: 2 }),
];
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
expect(mockUseFlyerSelection).toHaveBeenCalledWith({ flyers: mockFlyers });
});
});
describe('Component Props Passing', () => {
it('should pass all required props to FlyerList', () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
const selectedFlyer = mockFlyers[0];
const userProfile = createMockUserProfile({ role: 'admin' });
mockUseAuth.mockReturnValue({
...defaultAuthReturn,
userProfile,
});
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
mockUseFlyerSelection.mockReturnValue({
selectedFlyer,
handleFlyerSelect: mockHandleFlyerSelect,
flyerIdFromUrl: undefined,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
// Verify all props are passed correctly
expect(flyerList).toHaveAttribute('data-flyer-count', '1');
expect(flyerList).toHaveAttribute('data-selected-id', '1');
expect(flyerList).toHaveAttribute('data-profile-role', 'admin');
});
it('should handle selectedFlyer being null gracefully', () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
mockUseFlyerSelection.mockReturnValue({
selectedFlyer: null,
handleFlyerSelect: mockHandleFlyerSelect,
flyerIdFromUrl: undefined,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-selected-id', 'none');
});
});
describe('Edge Cases', () => {
it('should handle flyer with missing store gracefully', () => {
const flyerWithoutStore = createMockFlyer({
flyer_id: 1,
item_count: 10,
});
// Remove the store to test fallback behavior
(flyerWithoutStore as unknown as { store: undefined }).store = undefined;
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: [flyerWithoutStore],
});
renderPage();
// Should show "Unknown Store" as fallback
expect(screen.getByText(/Unknown Store - 10 items/)).toBeInTheDocument();
});
it('should handle undefined selectedFlyer flyer_id', () => {
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: [],
});
mockUseFlyerSelection.mockReturnValue({
selectedFlyer: null,
handleFlyerSelect: mockHandleFlyerSelect,
flyerIdFromUrl: undefined,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-selected-id', 'none');
});
it('should handle multiple rapid flyer selections', () => {
const mockFlyers: Flyer[] = [
createMockFlyer({ flyer_id: 1 }),
createMockFlyer({ flyer_id: 2 }),
createMockFlyer({ flyer_id: 3 }),
];
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
// Rapidly click different flyers
fireEvent.click(within(screen.getByTestId('flyer-item-1')).getByRole('button'));
fireEvent.click(within(screen.getByTestId('flyer-item-2')).getByRole('button'));
fireEvent.click(within(screen.getByTestId('flyer-item-3')).getByRole('button'));
expect(mockHandleFlyerSelect).toHaveBeenCalledTimes(3);
expect(mockHandleFlyerSelect).toHaveBeenNthCalledWith(1, mockFlyers[0]);
expect(mockHandleFlyerSelect).toHaveBeenNthCalledWith(2, mockFlyers[1]);
expect(mockHandleFlyerSelect).toHaveBeenNthCalledWith(3, mockFlyers[2]);
});
it('should handle large number of flyers', () => {
const manyFlyers = Array.from({ length: 100 }, (_, i) =>
createMockFlyer({
flyer_id: i + 1,
store: { store_id: i + 1, name: `Store ${i + 1}`, created_at: '', updated_at: '' },
item_count: (i + 1) * 10,
}),
);
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: manyFlyers,
});
renderPage();
const flyerList = screen.getByTestId('flyer-list');
expect(flyerList).toHaveAttribute('data-flyer-count', '100');
});
});
describe('Accessibility', () => {
it('should have a main heading for the page', () => {
renderPage();
const heading = screen.getByRole('heading', { level: 1, name: /flyers/i });
expect(heading).toBeInTheDocument();
});
it('should render interactive elements as buttons', () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockUseFlyers.mockReturnValue({
...defaultFlyersReturn,
flyers: mockFlyers,
});
renderPage();
// Both flyer selection and upload complete should be accessible buttons
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,469 @@
// src/pages/ShoppingListsPage.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ShoppingListsPage } from './ShoppingListsPage';
import { useAuth } from '../hooks/useAuth';
import { useShoppingLists } from '../hooks/useShoppingLists';
import type { ShoppingList, ShoppingListItem, User, UserProfile } from '../types';
import {
createMockUser,
createMockUserProfile,
createMockShoppingList,
createMockShoppingListItem,
} from '../tests/utils/mockFactories';
// Mock the hooks used by ShoppingListsPage
vi.mock('../hooks/useAuth');
vi.mock('../hooks/useShoppingLists');
// Mock the ShoppingListComponent to isolate ShoppingListsPage logic
vi.mock('../features/shopping/ShoppingList', () => ({
ShoppingListComponent: vi.fn(
({
user,
lists,
activeListId,
onSelectList,
onCreateList,
onDeleteList,
onAddItem,
onUpdateItem,
onRemoveItem,
}: {
user: User | null;
lists: ShoppingList[];
activeListId: number | null;
onSelectList: (listId: number) => void;
onCreateList: (name: string) => Promise<void>;
onDeleteList: (listId: number) => Promise<void>;
onAddItem: (item: { customItemName: string }) => Promise<void>;
onUpdateItem: (itemId: number, updates: Partial<ShoppingListItem>) => Promise<void>;
onRemoveItem: (itemId: number) => Promise<void>;
}) => (
<div data-testid="shopping-list-component">
<span data-testid="user-status">{user ? 'authenticated' : 'not-authenticated'}</span>
<span data-testid="lists-count">{lists.length}</span>
<span data-testid="active-list-id">{activeListId ?? 'none'}</span>
<button data-testid="select-list-btn" onClick={() => onSelectList(999)}>
Select List
</button>
<button data-testid="create-list-btn" onClick={() => onCreateList('New List')}>
Create List
</button>
<button data-testid="delete-list-btn" onClick={() => onDeleteList(1)}>
Delete List
</button>
<button
data-testid="add-item-btn"
onClick={() => onAddItem({ customItemName: 'Test Item' })}
>
Add Item
</button>
<button
data-testid="update-item-btn"
onClick={() => onUpdateItem(10, { is_purchased: true })}
>
Update Item
</button>
<button data-testid="remove-item-btn" onClick={() => onRemoveItem(10)}>
Remove Item
</button>
</div>
),
),
}));
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseShoppingLists = vi.mocked(useShoppingLists);
describe('ShoppingListsPage', () => {
const mockUser: User = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockUserProfile: UserProfile = createMockUserProfile({ user: mockUser });
const mockShoppingLists: ShoppingList[] = [
createMockShoppingList({
shopping_list_id: 1,
name: 'Groceries',
user_id: 'user-123',
items: [
createMockShoppingListItem({
shopping_list_item_id: 101,
shopping_list_id: 1,
custom_item_name: 'Apples',
}),
],
}),
createMockShoppingList({
shopping_list_id: 2,
name: 'Hardware',
user_id: 'user-123',
items: [],
}),
];
// Mock functions from useShoppingLists
const mockSetActiveListId = vi.fn();
const mockCreateList = vi.fn();
const mockDeleteList = vi.fn();
const mockAddItemToList = vi.fn();
const mockUpdateItemInList = vi.fn();
const mockRemoveItemFromList = vi.fn();
const defaultUseShoppingListsReturn = {
shoppingLists: mockShoppingLists,
activeListId: 1,
setActiveListId: mockSetActiveListId,
createList: mockCreateList,
deleteList: mockDeleteList,
addItemToList: mockAddItemToList,
updateItemInList: mockUpdateItemInList,
removeItemFromList: mockRemoveItemFromList,
isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null,
};
beforeEach(() => {
vi.resetAllMocks();
// Default authenticated user
mockedUseAuth.mockReturnValue({
userProfile: mockUserProfile,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
// Default shopping lists state
mockedUseShoppingLists.mockReturnValue(defaultUseShoppingListsReturn);
});
describe('Rendering', () => {
it('should render the page title', () => {
render(<ShoppingListsPage />);
expect(screen.getByRole('heading', { name: 'Shopping Lists' })).toBeInTheDocument();
});
it('should render the ShoppingListComponent', () => {
render(<ShoppingListsPage />);
expect(screen.getByTestId('shopping-list-component')).toBeInTheDocument();
});
it('should pass the correct user to ShoppingListComponent when authenticated', () => {
render(<ShoppingListsPage />);
expect(screen.getByTestId('user-status')).toHaveTextContent('authenticated');
});
it('should pass null user to ShoppingListComponent when not authenticated', () => {
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<ShoppingListsPage />);
expect(screen.getByTestId('user-status')).toHaveTextContent('not-authenticated');
});
it('should pass the shopping lists to ShoppingListComponent', () => {
render(<ShoppingListsPage />);
expect(screen.getByTestId('lists-count')).toHaveTextContent('2');
});
it('should pass the active list ID to ShoppingListComponent', () => {
render(<ShoppingListsPage />);
expect(screen.getByTestId('active-list-id')).toHaveTextContent('1');
});
it('should handle empty shopping lists', () => {
mockedUseShoppingLists.mockReturnValue({
...defaultUseShoppingListsReturn,
shoppingLists: [],
activeListId: null,
});
render(<ShoppingListsPage />);
expect(screen.getByTestId('lists-count')).toHaveTextContent('0');
expect(screen.getByTestId('active-list-id')).toHaveTextContent('none');
});
});
describe('User State', () => {
it('should extract user from userProfile when available', () => {
render(<ShoppingListsPage />);
// The component should pass the user object to ShoppingListComponent
expect(screen.getByTestId('user-status')).toHaveTextContent('authenticated');
});
it('should pass null user when userProfile is null', () => {
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<ShoppingListsPage />);
expect(screen.getByTestId('user-status')).toHaveTextContent('not-authenticated');
});
it('should pass null user when userProfile has no user property', () => {
mockedUseAuth.mockReturnValue({
userProfile: { ...mockUserProfile, user: undefined as unknown as User },
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<ShoppingListsPage />);
// When userProfile.user is undefined, the nullish coalescing should return null
expect(screen.getByTestId('user-status')).toHaveTextContent('not-authenticated');
});
});
describe('Callback Props', () => {
it('should pass setActiveListId to ShoppingListComponent', async () => {
render(<ShoppingListsPage />);
const selectButton = screen.getByTestId('select-list-btn');
selectButton.click();
expect(mockSetActiveListId).toHaveBeenCalledWith(999);
});
it('should pass createList to ShoppingListComponent', async () => {
render(<ShoppingListsPage />);
const createButton = screen.getByTestId('create-list-btn');
createButton.click();
expect(mockCreateList).toHaveBeenCalledWith('New List');
});
it('should pass deleteList to ShoppingListComponent', async () => {
render(<ShoppingListsPage />);
const deleteButton = screen.getByTestId('delete-list-btn');
deleteButton.click();
expect(mockDeleteList).toHaveBeenCalledWith(1);
});
it('should pass updateItemInList to ShoppingListComponent', async () => {
render(<ShoppingListsPage />);
const updateButton = screen.getByTestId('update-item-btn');
updateButton.click();
expect(mockUpdateItemInList).toHaveBeenCalledWith(10, { is_purchased: true });
});
it('should pass removeItemFromList to ShoppingListComponent', async () => {
render(<ShoppingListsPage />);
const removeButton = screen.getByTestId('remove-item-btn');
removeButton.click();
expect(mockRemoveItemFromList).toHaveBeenCalledWith(10);
});
});
describe('handleAddItemToShoppingList', () => {
it('should call addItemToList with activeListId when adding an item', async () => {
mockAddItemToList.mockResolvedValue(undefined);
render(<ShoppingListsPage />);
const addButton = screen.getByTestId('add-item-btn');
addButton.click();
await waitFor(() => {
expect(mockAddItemToList).toHaveBeenCalledWith(1, { customItemName: 'Test Item' });
});
});
it('should not call addItemToList when activeListId is null', async () => {
mockedUseShoppingLists.mockReturnValue({
...defaultUseShoppingListsReturn,
activeListId: null,
});
render(<ShoppingListsPage />);
const addButton = screen.getByTestId('add-item-btn');
addButton.click();
// Wait a tick to ensure any async operations would have completed
await waitFor(() => {
expect(mockAddItemToList).not.toHaveBeenCalled();
});
});
it('should handle addItemToList with masterItemId', async () => {
// Re-mock ShoppingListComponent to test with masterItemId
const ShoppingListComponent = vi.mocked(
await import('../features/shopping/ShoppingList'),
).ShoppingListComponent;
// Get the onAddItem prop from the last render call
const lastCallProps = (ShoppingListComponent as unknown as Mock).mock.calls[0]?.[0];
if (lastCallProps?.onAddItem) {
await lastCallProps.onAddItem({ masterItemId: 42 });
expect(mockAddItemToList).toHaveBeenCalledWith(1, { masterItemId: 42 });
}
});
});
describe('Integration with useShoppingLists', () => {
it('should use the correct hooks', () => {
render(<ShoppingListsPage />);
expect(mockedUseAuth).toHaveBeenCalled();
expect(mockedUseShoppingLists).toHaveBeenCalled();
});
it('should reflect changes when shoppingLists updates', () => {
const { rerender } = render(<ShoppingListsPage />);
expect(screen.getByTestId('lists-count')).toHaveTextContent('2');
// Simulate adding a new list
mockedUseShoppingLists.mockReturnValue({
...defaultUseShoppingListsReturn,
shoppingLists: [
...mockShoppingLists,
createMockShoppingList({
shopping_list_id: 3,
name: 'New List',
user_id: 'user-123',
}),
],
});
rerender(<ShoppingListsPage />);
expect(screen.getByTestId('lists-count')).toHaveTextContent('3');
});
it('should reflect changes when activeListId updates', () => {
const { rerender } = render(<ShoppingListsPage />);
expect(screen.getByTestId('active-list-id')).toHaveTextContent('1');
mockedUseShoppingLists.mockReturnValue({
...defaultUseShoppingListsReturn,
activeListId: 2,
});
rerender(<ShoppingListsPage />);
expect(screen.getByTestId('active-list-id')).toHaveTextContent('2');
});
});
describe('Page Structure', () => {
it('should have correct CSS classes for layout', () => {
const { container } = render(<ShoppingListsPage />);
const pageContainer = container.firstChild as HTMLElement;
expect(pageContainer).toHaveClass('max-w-4xl', 'mx-auto', 'p-4', 'space-y-6');
});
it('should have correctly styled heading', () => {
render(<ShoppingListsPage />);
const heading = screen.getByRole('heading', { name: 'Shopping Lists' });
expect(heading).toHaveClass('text-3xl', 'font-bold', 'text-gray-900');
});
});
describe('Edge Cases', () => {
it('should handle auth loading state gracefully', () => {
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'Determining...',
isLoading: true,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<ShoppingListsPage />);
// Page should still render even during auth loading
expect(screen.getByRole('heading', { name: 'Shopping Lists' })).toBeInTheDocument();
expect(screen.getByTestId('user-status')).toHaveTextContent('not-authenticated');
});
it('should handle shopping lists with items correctly', () => {
const listsWithItems = [
createMockShoppingList({
shopping_list_id: 1,
name: 'With Items',
items: [
createMockShoppingListItem({
shopping_list_item_id: 1,
custom_item_name: 'Item 1',
is_purchased: false,
}),
createMockShoppingListItem({
shopping_list_item_id: 2,
custom_item_name: 'Item 2',
is_purchased: true,
}),
],
}),
];
mockedUseShoppingLists.mockReturnValue({
...defaultUseShoppingListsReturn,
shoppingLists: listsWithItems,
});
render(<ShoppingListsPage />);
expect(screen.getByTestId('lists-count')).toHaveTextContent('1');
});
it('should handle async callback errors gracefully', async () => {
// The useShoppingLists hook catches errors internally and logs them,
// so we mock it to resolve (the real error handling is tested in useShoppingLists.test.tsx)
mockAddItemToList.mockResolvedValue(undefined);
render(<ShoppingListsPage />);
const addButton = screen.getByTestId('add-item-btn');
// Should not throw when clicked
expect(() => addButton.click()).not.toThrow();
await waitFor(() => {
expect(mockAddItemToList).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,78 @@
// src/pages/admin/AdminStoresPage.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminStoresPage } from './AdminStoresPage';
import { QueryWrapper } from '../../tests/utils/renderWithProviders';
// Mock the AdminStoreManager child component to isolate the test
vi.mock('./components/AdminStoreManager', () => ({
AdminStoreManager: () => <div data-testid="admin-store-manager-mock">Admin Store Manager</div>,
}));
// Mock the logger to prevent console output during tests
vi.mock('../../services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Helper function to render the component within router and query contexts
const renderWithRouter = () => {
return render(
<QueryWrapper>
<MemoryRouter>
<AdminStoresPage />
</MemoryRouter>
</QueryWrapper>,
);
};
describe('AdminStoresPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the main heading and description', () => {
renderWithRouter();
expect(screen.getByRole('heading', { name: /store management/i })).toBeInTheDocument();
expect(screen.getByText('Manage stores and their locations.')).toBeInTheDocument();
});
it('should render a link back to the admin dashboard', () => {
renderWithRouter();
const link = screen.getByRole('link', { name: /back to admin dashboard/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/admin');
});
it('should render the AdminStoreManager component', () => {
renderWithRouter();
expect(screen.getByTestId('admin-store-manager-mock')).toBeInTheDocument();
expect(screen.getByText('Admin Store Manager')).toBeInTheDocument();
});
it('should have proper page layout structure', () => {
const { container } = renderWithRouter();
// Check for the main container with expected classes
const mainContainer = container.querySelector('.max-w-6xl');
expect(mainContainer).toBeInTheDocument();
expect(mainContainer).toHaveClass('mx-auto', 'py-8', 'px-4');
});
it('should render the back link with the left arrow entity', () => {
renderWithRouter();
// The back link should contain the larr HTML entity (left arrow)
const link = screen.getByRole('link', { name: /back to admin dashboard/i });
expect(link.textContent).toContain('\u2190'); // Unicode for &larr;
});
});

View File

@@ -0,0 +1,672 @@
// src/pages/admin/components/AdminStoreManager.test.tsx
import React from 'react';
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import toast from 'react-hot-toast';
import { AdminStoreManager } from './AdminStoreManager';
import * as apiClient from '../../../services/apiClient';
import { createMockStoreWithLocations } from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
import type { StoreWithLocations } from '../../../types';
// Must explicitly call vi.mock() for apiClient
vi.mock('../../../services/apiClient');
// Mock react-hot-toast
vi.mock('react-hot-toast', () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock the StoreForm component to isolate AdminStoreManager testing
vi.mock('./StoreForm', () => ({
StoreForm: ({
store,
onSuccess,
onCancel,
}: {
store?: StoreWithLocations;
onSuccess: () => void;
onCancel: () => void;
}) => (
<div data-testid="store-form-mock">
<span data-testid="store-form-mode">{store ? 'edit' : 'create'}</span>
{store && <span data-testid="store-form-store-id">{store.store_id}</span>}
<button onClick={onSuccess} data-testid="store-form-success">
Submit
</button>
<button onClick={onCancel} data-testid="store-form-cancel">
Cancel
</button>
</div>
),
}));
// Mock the ErrorDisplay component
vi.mock('../../../components/ErrorDisplay', () => ({
ErrorDisplay: ({ message }: { message: string }) => (
<div data-testid="error-display">{message}</div>
),
}));
// Mock the logger to prevent console output during tests
vi.mock('../../../services/logger.client', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedToast = vi.mocked(toast, true);
const mockStores: StoreWithLocations[] = [
createMockStoreWithLocations({
store_id: 1,
name: 'Loblaws',
logo_url: 'https://example.com/loblaws.png',
locations: [
{ address: { address_line_1: '123 Main St', city: 'Toronto' } },
{ address: { address_line_1: '456 Oak Ave', city: 'Mississauga' } },
],
}),
createMockStoreWithLocations({
store_id: 2,
name: 'No Frills',
logo_url: null,
locations: [],
}),
createMockStoreWithLocations({
store_id: 3,
name: 'Walmart',
logo_url: 'https://example.com/walmart.png',
locations: [{ address: { address_line_1: '789 Pine St', city: 'Vancouver' } }],
}),
];
// Helper to create a successful API response
const createSuccessResponse = (data: unknown) =>
new Response(JSON.stringify({ data }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// Helper to create a failed API response
const createErrorResponse = (status: number, body?: string) => new Response(body || '', { status });
describe('AdminStoreManager', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: successful response with stores
mockedApiClient.getStores.mockResolvedValue(createSuccessResponse(mockStores));
mockedApiClient.deleteStore.mockResolvedValue(createSuccessResponse({}));
// Reset window.confirm mock
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
describe('Loading State', () => {
it('should render a loading state while fetching stores', async () => {
// Make getStores hang indefinitely for this test
mockedApiClient.getStores.mockImplementation(
() => new Promise(() => {}), // Never resolves
);
renderWithProviders(<AdminStoreManager />);
expect(screen.getByText('Loading stores...')).toBeInTheDocument();
});
});
describe('Error State', () => {
it('should display an error message if fetching stores fails', async () => {
mockedApiClient.getStores.mockResolvedValue(
createErrorResponse(500, 'Internal Server Error'),
);
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByTestId('error-display')).toBeInTheDocument();
expect(screen.getByText(/Failed to load stores/i)).toBeInTheDocument();
});
});
it('should display a generic error message for network failures', async () => {
mockedApiClient.getStores.mockRejectedValue(new Error('Network Error'));
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByTestId('error-display')).toBeInTheDocument();
expect(screen.getByText(/Failed to load stores: Network Error/i)).toBeInTheDocument();
});
});
});
describe('Success State - Store List', () => {
it('should render the list of stores when data is fetched successfully', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /store management/i })).toBeInTheDocument();
expect(screen.getByText('Loblaws')).toBeInTheDocument();
expect(screen.getByText('No Frills')).toBeInTheDocument();
expect(screen.getByText('Walmart')).toBeInTheDocument();
});
});
it('should display store logos when available', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
const loblawsLogo = screen.getByAltText('Loblaws logo');
expect(loblawsLogo).toHaveAttribute('src', 'https://example.com/loblaws.png');
const walmartLogo = screen.getByAltText('Walmart logo');
expect(walmartLogo).toHaveAttribute('src', 'https://example.com/walmart.png');
});
});
it('should display "No Logo" placeholder when logo_url is null', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
// No Frills has no logo
const noLogoElements = screen.getAllByText('No Logo');
expect(noLogoElements.length).toBeGreaterThanOrEqual(1);
});
it('should display location count and first address for stores with locations', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
// Loblaws has 2 locations
expect(screen.getByText('2 location(s)')).toBeInTheDocument();
expect(screen.getByText('123 Main St, Toronto')).toBeInTheDocument();
expect(screen.getByText('+ 1 more')).toBeInTheDocument();
// Walmart has 1 location
expect(screen.getByText('1 location(s)')).toBeInTheDocument();
expect(screen.getByText('789 Pine St, Vancouver')).toBeInTheDocument();
});
});
it('should display "No locations" for stores without locations', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('No locations')).toBeInTheDocument();
});
});
it('should render Edit and Delete buttons for each store', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
// There are 3 stores, so should have 3 Edit and 3 Delete buttons
const editButtons = screen.getAllByRole('button', { name: /edit/i });
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
expect(editButtons).toHaveLength(3);
expect(deleteButtons).toHaveLength(3);
});
});
it('should render "Create Store" button', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /create store/i })).toBeInTheDocument();
});
});
it('should render an empty state message when no stores exist', async () => {
mockedApiClient.getStores.mockResolvedValue(createSuccessResponse([]));
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('No stores found. Create one to get started!')).toBeInTheDocument();
});
});
});
describe('Table Structure', () => {
it('should render a table with correct column headers', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByRole('columnheader', { name: /logo/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /store name/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /locations/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /actions/i })).toBeInTheDocument();
});
});
it('should render one row per store plus the header row', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
// 1 header row + 3 data rows
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(4);
});
});
});
describe('Create Store Modal', () => {
it('should open the create modal when "Create Store" button is clicked', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(screen.getByText('Create New Store')).toBeInTheDocument();
expect(screen.getByTestId('store-form-mock')).toBeInTheDocument();
expect(screen.getByTestId('store-form-mode')).toHaveTextContent('create');
});
});
it('should close the create modal when cancel is clicked', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
// Open modal
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(screen.getByText('Create New Store')).toBeInTheDocument();
});
// Click cancel
fireEvent.click(screen.getByTestId('store-form-cancel'));
await waitFor(() => {
expect(screen.queryByText('Create New Store')).not.toBeInTheDocument();
});
});
it('should close the create modal and refresh data when form submission succeeds', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
// Open modal
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(screen.getByTestId('store-form-mock')).toBeInTheDocument();
});
// Submit the form (triggers onSuccess)
fireEvent.click(screen.getByTestId('store-form-success'));
await waitFor(() => {
// Modal should be closed
expect(screen.queryByText('Create New Store')).not.toBeInTheDocument();
});
});
});
describe('Edit Store Modal', () => {
it('should open the edit modal when "Edit" button is clicked', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
// Find the Loblaws row and click its Edit button
const loblawsRow = screen.getByText('Loblaws').closest('tr');
const editButton = within(loblawsRow!).getByRole('button', { name: /edit/i });
fireEvent.click(editButton);
await waitFor(() => {
expect(screen.getByText('Edit Store')).toBeInTheDocument();
expect(screen.getByTestId('store-form-mock')).toBeInTheDocument();
expect(screen.getByTestId('store-form-mode')).toHaveTextContent('edit');
expect(screen.getByTestId('store-form-store-id')).toHaveTextContent('1');
});
});
it('should pass the correct store to the form in edit mode', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Walmart')).toBeInTheDocument();
});
// Click Edit on Walmart (store_id: 3)
const walmartRow = screen.getByText('Walmart').closest('tr');
const editButton = within(walmartRow!).getByRole('button', { name: /edit/i });
fireEvent.click(editButton);
await waitFor(() => {
expect(screen.getByTestId('store-form-store-id')).toHaveTextContent('3');
});
});
it('should close the edit modal when cancel is clicked', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
// Open edit modal
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /edit/i }));
await waitFor(() => {
expect(screen.getByText('Edit Store')).toBeInTheDocument();
});
// Click cancel
fireEvent.click(screen.getByTestId('store-form-cancel'));
await waitFor(() => {
expect(screen.queryByText('Edit Store')).not.toBeInTheDocument();
});
});
it('should close the edit modal and refresh data when form submission succeeds', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
// Open edit modal
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /edit/i }));
await waitFor(() => {
expect(screen.getByTestId('store-form-mock')).toBeInTheDocument();
});
// Submit form
fireEvent.click(screen.getByTestId('store-form-success'));
await waitFor(() => {
expect(screen.queryByText('Edit Store')).not.toBeInTheDocument();
});
});
});
describe('Delete Store', () => {
it('should show a confirmation dialog before deleting', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
expect(confirmSpy).toHaveBeenCalledWith(
expect.stringContaining('Are you sure you want to delete "Loblaws"'),
);
});
it('should not delete if user cancels the confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
// API should not be called
expect(mockedApiClient.deleteStore).not.toHaveBeenCalled();
});
it('should call deleteStore API when user confirms deletion', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedApiClient.deleteStore).toHaveBeenCalledWith(1);
});
});
it('should show a loading toast while deleting', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedToast.loading.mockReturnValue('delete-toast-id');
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedToast.loading).toHaveBeenCalledWith('Deleting store...');
});
});
it('should show success toast after successful deletion', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedToast.loading.mockReturnValue('delete-toast-id');
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedToast.success).toHaveBeenCalledWith('Store deleted successfully!', {
id: 'delete-toast-id',
});
});
});
it('should show error toast when deletion fails with response body', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedToast.loading.mockReturnValue('delete-toast-id');
mockedApiClient.deleteStore.mockResolvedValue(
createErrorResponse(400, 'Store has active flyers'),
);
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Delete failed: Store has active flyers', {
id: 'delete-toast-id',
});
});
});
it('should show error toast with status code when response body is empty', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedToast.loading.mockReturnValue('delete-toast-id');
mockedApiClient.deleteStore.mockResolvedValue(createErrorResponse(500));
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith(
'Delete failed: Delete failed with status 500',
{ id: 'delete-toast-id' },
);
});
});
it('should show error toast when API call throws an exception', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedToast.loading.mockReturnValue('delete-toast-id');
mockedApiClient.deleteStore.mockRejectedValue(new Error('Network error'));
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Delete failed: Network error', {
id: 'delete-toast-id',
});
});
});
it('should handle non-Error objects thrown during deletion', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedToast.loading.mockReturnValue('delete-toast-id');
mockedApiClient.deleteStore.mockRejectedValue('A string error');
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Delete failed: A string error', {
id: 'delete-toast-id',
});
});
});
it('should include correct warning message in confirmation dialog about locations and linked data', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('No Frills')).toBeInTheDocument();
});
const noFrillsRow = screen.getByText('No Frills').closest('tr');
fireEvent.click(within(noFrillsRow!).getByRole('button', { name: /delete/i }));
expect(confirmSpy).toHaveBeenCalledWith(
expect.stringContaining('delete all associated locations'),
);
expect(confirmSpy).toHaveBeenCalledWith(
expect.stringContaining('may affect flyers/receipts'),
);
});
});
describe('API Calls', () => {
it('should call getStores with includeLocations=true on mount', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(mockedApiClient.getStores).toHaveBeenCalledWith(true);
});
});
});
describe('Query Invalidation', () => {
it('should refetch stores after successful store deletion', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByText('Loblaws')).toBeInTheDocument();
});
// Initial call
expect(mockedApiClient.getStores).toHaveBeenCalledTimes(1);
const loblawsRow = screen.getByText('Loblaws').closest('tr');
fireEvent.click(within(loblawsRow!).getByRole('button', { name: /delete/i }));
await waitFor(() => {
expect(mockedToast.success).toHaveBeenCalled();
});
// Should have been called again due to query invalidation
await waitFor(() => {
expect(mockedApiClient.getStores).toHaveBeenCalledTimes(2);
});
});
});
describe('Accessibility', () => {
it('should have accessible table structure', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
// There should be 2 rowgroups: thead and tbody
const rowgroups = screen.getAllByRole('rowgroup');
expect(rowgroups).toHaveLength(2);
});
});
it('should have proper scope attribute on column headers', async () => {
renderWithProviders(<AdminStoreManager />);
await waitFor(() => {
const headers = screen.getAllByRole('columnheader');
headers.forEach((header) => {
expect(header).toHaveAttribute('scope', 'col');
});
});
});
});
});

View File

@@ -0,0 +1,893 @@
// src/pages/admin/components/StoreForm.test.tsx
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import toast from 'react-hot-toast';
import { StoreForm } from './StoreForm';
import * as apiClient from '../../../services/apiClient';
import { createMockStoreWithLocations } from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
// Mock apiClient module
vi.mock('../../../services/apiClient');
// Mock react-hot-toast
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
loading: vi.fn(() => 'toast-id'),
},
}));
// Mock the logger to prevent console noise
vi.mock('../../../services/logger.client', () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedToast = vi.mocked(toast);
describe('StoreForm', () => {
const mockOnSuccess = vi.fn();
const mockOnCancel = vi.fn();
const defaultProps = {
onSuccess: mockOnSuccess,
onCancel: mockOnCancel,
};
beforeEach(() => {
vi.clearAllMocks();
});
// =========================================================================
// Rendering Tests
// =========================================================================
describe('Rendering', () => {
it('should render empty form in create mode', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
// Check that the form fields are present
expect(screen.getByLabelText(/store name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/logo url/i)).toBeInTheDocument();
expect(screen.getByLabelText(/include store address/i)).toBeInTheDocument();
// Check buttons
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create store/i })).toBeInTheDocument();
// Store name should be empty
expect(screen.getByLabelText(/store name/i)).toHaveValue('');
expect(screen.getByLabelText(/logo url/i)).toHaveValue('');
});
it('should render pre-filled form in edit mode', () => {
const mockStore = createMockStoreWithLocations({
store_id: 1,
name: 'Test Store',
logo_url: 'https://example.com/logo.png',
});
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
// Check that the form is pre-filled
expect(screen.getByLabelText(/store name/i)).toHaveValue('Test Store');
expect(screen.getByLabelText(/logo url/i)).toHaveValue('https://example.com/logo.png');
// In edit mode, button says "Update Store"
expect(screen.getByRole('button', { name: /update store/i })).toBeInTheDocument();
// In edit mode, address checkbox should say "Add a new location" and be unchecked
expect(screen.getByLabelText(/add a new location/i)).toBeInTheDocument();
expect(screen.getByLabelText(/add a new location/i)).not.toBeChecked();
});
it('should show address fields when include address checkbox is checked in create mode', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
// In create mode, checkbox should be checked by default
expect(screen.getByLabelText(/include store address/i)).toBeChecked();
// Address fields should be visible
expect(screen.getByLabelText(/address line 1/i)).toBeInTheDocument();
expect(screen.getByLabelText(/city/i)).toBeInTheDocument();
expect(screen.getByLabelText(/province\/state/i)).toBeInTheDocument();
expect(screen.getByLabelText(/postal code/i)).toBeInTheDocument();
expect(screen.getByLabelText(/country/i)).toBeInTheDocument();
// Province should default to 'ON' and country to 'Canada'
expect(screen.getByLabelText(/province\/state/i)).toHaveValue('ON');
expect(screen.getByLabelText(/country/i)).toHaveValue('Canada');
});
it('should hide address fields when checkbox is unchecked', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
// Uncheck the address checkbox
fireEvent.click(screen.getByLabelText(/include store address/i));
// Address fields should be hidden
expect(screen.queryByLabelText(/address line 1/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/city/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/province\/state/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/postal code/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/country/i)).not.toBeInTheDocument();
});
it('should show address fields when "Add a new location" is checked in edit mode', () => {
const mockStore = createMockStoreWithLocations({ store_id: 1, name: 'Test Store' });
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
// Initially address fields should be hidden in edit mode
expect(screen.queryByLabelText(/address line 1/i)).not.toBeInTheDocument();
// Check the "Add a new location" checkbox
fireEvent.click(screen.getByLabelText(/add a new location/i));
// Address fields should now be visible
expect(screen.getByLabelText(/address line 1/i)).toBeInTheDocument();
expect(screen.getByLabelText(/city/i)).toBeInTheDocument();
});
});
// =========================================================================
// User Input Tests
// =========================================================================
describe('User Input', () => {
it('should allow typing in the store name field', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
const nameInput = screen.getByLabelText(/store name/i);
fireEvent.change(nameInput, { target: { value: 'Loblaws' } });
expect(nameInput).toHaveValue('Loblaws');
});
it('should allow typing in the logo URL field', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
const logoInput = screen.getByLabelText(/logo url/i);
fireEvent.change(logoInput, { target: { value: 'https://example.com/logo.png' } });
expect(logoInput).toHaveValue('https://example.com/logo.png');
});
it('should allow typing in all address fields', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
const addressLine1 = screen.getByLabelText(/address line 1/i);
const city = screen.getByLabelText(/city/i);
const provinceState = screen.getByLabelText(/province\/state/i);
const postalCode = screen.getByLabelText(/postal code/i);
const country = screen.getByLabelText(/country/i);
fireEvent.change(addressLine1, { target: { value: '123 Main St' } });
fireEvent.change(city, { target: { value: 'Toronto' } });
fireEvent.change(provinceState, { target: { value: 'Ontario' } });
fireEvent.change(postalCode, { target: { value: 'M5V 1A1' } });
fireEvent.change(country, { target: { value: 'USA' } });
expect(addressLine1).toHaveValue('123 Main St');
expect(city).toHaveValue('Toronto');
expect(provinceState).toHaveValue('Ontario');
expect(postalCode).toHaveValue('M5V 1A1');
expect(country).toHaveValue('USA');
});
it('should toggle the include address checkbox', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
const checkbox = screen.getByLabelText(/include store address/i);
// Initially checked in create mode
expect(checkbox).toBeChecked();
// Uncheck
fireEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
// Check again
fireEvent.click(checkbox);
expect(checkbox).toBeChecked();
});
});
// =========================================================================
// Form Validation Tests
// =========================================================================
describe('Form Validation', () => {
// Note: The StoreForm has HTML5 `required` attributes on certain inputs.
// When a field with `required` is empty, browser validation prevents the
// submit event from firing, so the JavaScript validation in handleSubmit
// is a secondary layer for cases like whitespace-only values.
it('should have required attribute on store name field', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
const nameInput = screen.getByLabelText(/store name/i);
expect(nameInput).toHaveAttribute('required');
});
it('should show error toast when store name is whitespace only', async () => {
// This tests the JS validation for whitespace-only values
// (browser validation doesn't catch whitespace-only)
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: ' ' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck address
// Directly trigger form submit to bypass HTML5 validation
const form = document.querySelector('form');
if (form) {
fireEvent.submit(form);
}
expect(mockedToast.error).toHaveBeenCalledWith('Store name is required');
});
it('should show error toast when address fields contain only whitespace', async () => {
// This tests the JS validation for whitespace-only values in address fields
renderWithProviders(<StoreForm {...defaultProps} />);
// Fill store name
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
// Fill address fields with whitespace only (browser validation won't catch this)
fireEvent.change(screen.getByLabelText(/address line 1/i), { target: { value: ' ' } });
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: ' ' } });
fireEvent.change(screen.getByLabelText(/postal code/i), { target: { value: ' ' } });
// Directly trigger form submit to bypass HTML5 validation
const form = document.querySelector('form');
if (form) {
fireEvent.submit(form);
}
expect(mockedToast.error).toHaveBeenCalledWith(
'All address fields are required when adding a location',
);
expect(mockedApiClient.createStore).not.toHaveBeenCalled();
});
it('should have required attribute on address fields when checkbox is checked', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
// Checkbox is checked by default in create mode, address fields should be visible
expect(screen.getByLabelText(/address line 1/i)).toHaveAttribute('required');
expect(screen.getByLabelText(/city/i)).toHaveAttribute('required');
expect(screen.getByLabelText(/province\/state/i)).toHaveAttribute('required');
expect(screen.getByLabelText(/postal code/i)).toHaveAttribute('required');
});
it('should show error toast when address line 1 is whitespace but other fields are filled', async () => {
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.change(screen.getByLabelText(/address line 1/i), { target: { value: ' ' } });
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Toronto' } });
fireEvent.change(screen.getByLabelText(/postal code/i), { target: { value: 'M5V 1A1' } });
const form = document.querySelector('form');
if (form) {
fireEvent.submit(form);
}
expect(mockedToast.error).toHaveBeenCalledWith(
'All address fields are required when adding a location',
);
});
it('should show error toast when city is whitespace but other fields are filled', async () => {
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.change(screen.getByLabelText(/address line 1/i), {
target: { value: '123 Main St' },
});
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: ' ' } });
fireEvent.change(screen.getByLabelText(/postal code/i), { target: { value: 'M5V 1A1' } });
const form = document.querySelector('form');
if (form) {
fireEvent.submit(form);
}
expect(mockedToast.error).toHaveBeenCalledWith(
'All address fields are required when adding a location',
);
});
it('should show error toast when postal code is whitespace but other fields are filled', async () => {
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.change(screen.getByLabelText(/address line 1/i), {
target: { value: '123 Main St' },
});
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Toronto' } });
fireEvent.change(screen.getByLabelText(/postal code/i), { target: { value: ' ' } });
const form = document.querySelector('form');
if (form) {
fireEvent.submit(form);
}
expect(mockedToast.error).toHaveBeenCalledWith(
'All address fields are required when adding a location',
);
});
});
// =========================================================================
// Create Store Submission Tests
// =========================================================================
describe('Create Store Submission', () => {
it('should call createStore API with correct data when submitting without address', async () => {
mockedApiClient.createStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 1, name: 'New Store' }), { status: 201 }),
);
renderWithProviders(<StoreForm {...defaultProps} />);
// Fill the form
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'New Store' } });
fireEvent.change(screen.getByLabelText(/logo url/i), {
target: { value: 'https://example.com/logo.png' },
});
// Uncheck address
fireEvent.click(screen.getByLabelText(/include store address/i));
// Submit
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedApiClient.createStore).toHaveBeenCalledWith({
name: 'New Store',
logo_url: 'https://example.com/logo.png',
});
expect(mockedToast.loading).toHaveBeenCalledWith('Creating store...');
expect(mockedToast.success).toHaveBeenCalledWith('Store created successfully!', {
id: 'toast-id',
});
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should call createStore API with address when checkbox is checked', async () => {
mockedApiClient.createStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 1, name: 'New Store' }), { status: 201 }),
);
renderWithProviders(<StoreForm {...defaultProps} />);
// Fill the form with address
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'New Store' } });
fireEvent.change(screen.getByLabelText(/address line 1/i), {
target: { value: '123 Main St' },
});
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Toronto' } });
fireEvent.change(screen.getByLabelText(/postal code/i), { target: { value: 'M5V 1A1' } });
// Submit
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedApiClient.createStore).toHaveBeenCalledWith({
name: 'New Store',
logo_url: undefined,
address: {
address_line_1: '123 Main St',
city: 'Toronto',
province_state: 'ON',
postal_code: 'M5V 1A1',
country: 'Canada',
},
});
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should trim whitespace from input fields before submission', async () => {
mockedApiClient.createStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 1 }), { status: 201 }),
);
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), {
target: { value: ' Trimmed Store ' },
});
fireEvent.change(screen.getByLabelText(/logo url/i), {
target: { value: ' https://example.com/logo.png ' },
});
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedApiClient.createStore).toHaveBeenCalledWith({
name: 'Trimmed Store',
logo_url: 'https://example.com/logo.png',
});
});
});
it('should not include logo_url if empty', async () => {
mockedApiClient.createStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 1 }), { status: 201 }),
);
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), {
target: { value: 'No Logo Store' },
});
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedApiClient.createStore).toHaveBeenCalledWith({
name: 'No Logo Store',
logo_url: undefined,
});
});
});
it('should show error toast when createStore API fails', async () => {
mockedApiClient.createStore.mockResolvedValue(
new Response('Store already exists', { status: 400 }),
);
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Failed: Store already exists', {
id: 'toast-id',
});
expect(mockOnSuccess).not.toHaveBeenCalled();
});
});
it('should show generic error message when response body is empty', async () => {
mockedApiClient.createStore.mockResolvedValue(new Response('', { status: 500 }));
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith(
'Failed: Create failed with status 500',
expect.any(Object),
);
});
});
it('should handle network error during store creation', async () => {
mockedApiClient.createStore.mockRejectedValue(new Error('Network error'));
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Failed: Network error', {
id: 'toast-id',
});
expect(mockOnSuccess).not.toHaveBeenCalled();
});
});
it('should handle non-Error thrown during submission', async () => {
mockedApiClient.createStore.mockRejectedValue('String error');
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Failed: String error', {
id: 'toast-id',
});
});
});
});
// =========================================================================
// Update Store Submission Tests
// =========================================================================
describe('Update Store Submission', () => {
it('should call updateStore API with correct data', async () => {
const mockStore = createMockStoreWithLocations({
store_id: 42,
name: 'Old Store Name',
logo_url: 'https://example.com/old-logo.png',
});
mockedApiClient.updateStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 42, name: 'Updated Store' }), { status: 200 }),
);
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
// Change the name
fireEvent.change(screen.getByLabelText(/store name/i), {
target: { value: 'Updated Store Name' },
});
// Submit
fireEvent.click(screen.getByRole('button', { name: /update store/i }));
await waitFor(() => {
expect(mockedApiClient.updateStore).toHaveBeenCalledWith(42, {
name: 'Updated Store Name',
logo_url: 'https://example.com/old-logo.png',
});
expect(mockedToast.loading).toHaveBeenCalledWith('Updating store...');
expect(mockedToast.success).toHaveBeenCalledWith('Store updated successfully!', {
id: 'toast-id',
});
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should call addStoreLocation when adding location in edit mode', async () => {
const mockStore = createMockStoreWithLocations({
store_id: 42,
name: 'Existing Store',
});
mockedApiClient.updateStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 42 }), { status: 200 }),
);
mockedApiClient.addStoreLocation.mockResolvedValue(
new Response(JSON.stringify({ store_location_id: 1 }), { status: 201 }),
);
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
// Check the "Add a new location" checkbox
fireEvent.click(screen.getByLabelText(/add a new location/i));
// Fill address fields
fireEvent.change(screen.getByLabelText(/address line 1/i), {
target: { value: '456 New St' },
});
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Vancouver' } });
fireEvent.change(screen.getByLabelText(/province\/state/i), { target: { value: 'BC' } });
fireEvent.change(screen.getByLabelText(/postal code/i), { target: { value: 'V6B 1A1' } });
// Submit
fireEvent.click(screen.getByRole('button', { name: /update store/i }));
await waitFor(() => {
expect(mockedApiClient.updateStore).toHaveBeenCalled();
expect(mockedApiClient.addStoreLocation).toHaveBeenCalledWith(42, {
address_line_1: '456 New St',
city: 'Vancouver',
province_state: 'BC',
postal_code: 'V6B 1A1',
country: 'Canada',
});
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should not call addStoreLocation when checkbox is unchecked in edit mode', async () => {
const mockStore = createMockStoreWithLocations({
store_id: 42,
name: 'Existing Store',
});
mockedApiClient.updateStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 42 }), { status: 200 }),
);
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
// Just update the name (checkbox is unchecked by default in edit mode)
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /update store/i }));
await waitFor(() => {
expect(mockedApiClient.updateStore).toHaveBeenCalled();
expect(mockedApiClient.addStoreLocation).not.toHaveBeenCalled();
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should show error toast when updateStore API fails', async () => {
const mockStore = createMockStoreWithLocations({
store_id: 42,
name: 'Existing Store',
});
mockedApiClient.updateStore.mockResolvedValue(new Response('Update failed', { status: 400 }));
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /update store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Failed: Update failed', {
id: 'toast-id',
});
expect(mockOnSuccess).not.toHaveBeenCalled();
});
});
it('should show error toast when addStoreLocation fails after updateStore succeeds', async () => {
const mockStore = createMockStoreWithLocations({
store_id: 42,
name: 'Existing Store',
});
mockedApiClient.updateStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 42 }), { status: 200 }),
);
mockedApiClient.addStoreLocation.mockResolvedValue(
new Response('Location add failed', { status: 400 }),
);
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
// Check the "Add a new location" checkbox
fireEvent.click(screen.getByLabelText(/add a new location/i));
// Fill address fields
fireEvent.change(screen.getByLabelText(/address line 1/i), {
target: { value: '456 New St' },
});
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Vancouver' } });
fireEvent.change(screen.getByLabelText(/postal code/i), { target: { value: 'V6B 1A1' } });
fireEvent.click(screen.getByRole('button', { name: /update store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith(
'Failed: Location add failed: Location add failed',
{ id: 'toast-id' },
);
expect(mockOnSuccess).not.toHaveBeenCalled();
});
});
it('should handle generic error message when updateStore response body is empty', async () => {
const mockStore = createMockStoreWithLocations({
store_id: 42,
name: 'Existing Store',
});
mockedApiClient.updateStore.mockResolvedValue(new Response('', { status: 500 }));
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /update store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith(
'Failed: Update failed with status 500',
expect.any(Object),
);
});
});
});
// =========================================================================
// Button Disable State Tests
// =========================================================================
describe('Button Disable States', () => {
it('should disable buttons while submitting', async () => {
// Create a promise that we can control
let resolvePromise: (value: Response) => void;
const pendingPromise = new Promise<Response>((resolve) => {
resolvePromise = resolve;
});
mockedApiClient.createStore.mockReturnValue(pendingPromise);
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
const submitButton = screen.getByRole('button', { name: /create store/i });
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(submitButton);
// Check that buttons are disabled and text changes
await waitFor(() => {
expect(screen.getByRole('button', { name: /saving\.\.\./i })).toBeDisabled();
expect(cancelButton).toBeDisabled();
});
// Resolve the promise
resolvePromise!(new Response(JSON.stringify({ store_id: 1 }), { status: 201 }));
await waitFor(() => {
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should re-enable buttons after submission fails', async () => {
mockedApiClient.createStore.mockRejectedValue(new Error('Failed'));
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalled();
});
// Buttons should be re-enabled
expect(screen.getByRole('button', { name: /create store/i })).not.toBeDisabled();
expect(screen.getByRole('button', { name: /cancel/i })).not.toBeDisabled();
});
});
// =========================================================================
// Cancel Button Tests
// =========================================================================
describe('Cancel Button', () => {
it('should call onCancel when cancel button is clicked', () => {
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it('should call onCancel when cancel button is clicked in edit mode', () => {
const mockStore = createMockStoreWithLocations({ store_id: 1, name: 'Test Store' });
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
});
// =========================================================================
// Form Submission via Form Element
// =========================================================================
describe('Form Submission', () => {
it('should submit form via form submit event', async () => {
mockedApiClient.createStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 1 }), { status: 201 }),
);
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
// Submit via form element using document.querySelector since form doesn't have role
const form = document.querySelector('form');
expect(form).toBeInTheDocument();
if (form) {
fireEvent.submit(form);
}
await waitFor(() => {
expect(mockedApiClient.createStore).toHaveBeenCalled();
});
});
it('should submit form via submit button click', async () => {
mockedApiClient.createStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 1 }), { status: 201 }),
);
renderWithProviders(<StoreForm {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/store name/i), { target: { value: 'Test Store' } });
fireEvent.click(screen.getByLabelText(/include store address/i)); // Uncheck
// Submit via button click
fireEvent.click(screen.getByRole('button', { name: /create store/i }));
await waitFor(() => {
expect(mockedApiClient.createStore).toHaveBeenCalled();
});
});
});
// =========================================================================
// Edge Cases
// =========================================================================
describe('Edge Cases', () => {
it('should handle store with null logo_url in edit mode', () => {
const mockStore = createMockStoreWithLocations({
store_id: 1,
name: 'Store Without Logo',
logo_url: null,
});
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
expect(screen.getByLabelText(/store name/i)).toHaveValue('Store Without Logo');
expect(screen.getByLabelText(/logo url/i)).toHaveValue('');
});
it('should handle store with undefined logo_url in edit mode', () => {
const mockStore = createMockStoreWithLocations({
store_id: 1,
name: 'Store Without Logo',
logo_url: undefined,
});
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
expect(screen.getByLabelText(/store name/i)).toHaveValue('Store Without Logo');
expect(screen.getByLabelText(/logo url/i)).toHaveValue('');
});
it('should clear logo_url when submitting with empty string', async () => {
const mockStore = createMockStoreWithLocations({
store_id: 1,
name: 'Store With Logo',
logo_url: 'https://example.com/logo.png',
});
mockedApiClient.updateStore.mockResolvedValue(
new Response(JSON.stringify({ store_id: 1 }), { status: 200 }),
);
renderWithProviders(<StoreForm {...defaultProps} store={mockStore} />);
// Clear the logo URL
fireEvent.change(screen.getByLabelText(/logo url/i), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: /update store/i }));
await waitFor(() => {
expect(mockedApiClient.updateStore).toHaveBeenCalledWith(1, {
name: 'Store With Logo',
logo_url: undefined,
});
});
});
it('should prevent default form submission behavior', async () => {
renderWithProviders(<StoreForm {...defaultProps} />);
const form = document.querySelector('form');
expect(form).toBeInTheDocument();
// The form has an onSubmit handler that calls e.preventDefault()
// This is tested implicitly by the fact that the page doesn't reload
// and our mocks are called instead
});
});
});

View File

@@ -0,0 +1,506 @@
// src/routes/category.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
import { createMockCategory } from '../tests/utils/mockFactories';
import type { Category } from '../types';
// 1. Use vi.hoisted to create mock functions before vi.mock hoisting
const { mockCategoryDbService } = vi.hoisted(() => ({
mockCategoryDbService: {
getAllCategories: vi.fn(),
getCategoryById: vi.fn(),
getCategoryByName: vi.fn(),
},
}));
// Mock the CategoryDbService with the hoisted mock object
vi.mock('../services/db/category.db', () => ({
CategoryDbService: mockCategoryDbService,
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Import the router AFTER all mocks are defined
import categoryRouter from './category.routes';
// Define a reusable matcher for the logger object
const expectLogger = expect.objectContaining({
info: expect.any(Function),
error: expect.any(Function),
});
describe('Category Routes (/api/v1/categories)', () => {
const basePath = '/api/v1/categories';
const app = createTestApp({ router: categoryRouter, basePath });
beforeEach(() => {
vi.clearAllMocks();
});
// ===========================================================================
// GET / - List all categories
// ===========================================================================
describe('GET /', () => {
it('should return 200 with list of all categories', async () => {
// Arrange
const mockCategories: Category[] = [
createMockCategory({ category_id: 1, name: 'Bakery & Bread' }),
createMockCategory({ category_id: 2, name: 'Dairy & Eggs' }),
createMockCategory({ category_id: 3, name: 'Fruits & Vegetables' }),
];
mockCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
// Act
const response = await supertest(app).get('/api/v1/categories');
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual(mockCategories);
expect(mockCategoryDbService.getAllCategories).toHaveBeenCalledWith(expectLogger);
});
it('should return 200 with empty array when no categories exist', async () => {
// Arrange
mockCategoryDbService.getAllCategories.mockResolvedValue([]);
// Act
const response = await supertest(app).get('/api/v1/categories');
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
expect(Array.isArray(response.body.data)).toBe(true);
});
it('should return 500 if the database call fails', async () => {
// Arrange
const dbError = new Error('Database connection failed');
mockCategoryDbService.getAllCategories.mockRejectedValue(dbError);
// Act
const response = await supertest(app).get('/api/v1/categories');
// Assert
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('Database connection failed');
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
}),
expect.stringMatching(/Unhandled API Error/),
);
});
it('should return 500 if the database throws a non-Error object', async () => {
// Arrange
const dbError = { message: 'Unexpected error', code: 'UNKNOWN' };
mockCategoryDbService.getAllCategories.mockRejectedValue(dbError);
// Act
const response = await supertest(app).get('/api/v1/categories');
// Assert
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('Unexpected error');
});
});
// ===========================================================================
// GET /:id - Get category by ID
// ===========================================================================
describe('GET /:id', () => {
it('should return 200 with category details for valid ID', async () => {
// Arrange
const mockCategory = createMockCategory({
category_id: 5,
name: 'Dairy & Eggs',
});
mockCategoryDbService.getCategoryById.mockResolvedValue(mockCategory);
// Act
const response = await supertest(app).get('/api/v1/categories/5');
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual(mockCategory);
expect(mockCategoryDbService.getCategoryById).toHaveBeenCalledWith(5, expectLogger);
});
it('should return 404 for non-existent category ID', async () => {
// Arrange
mockCategoryDbService.getCategoryById.mockResolvedValue(null);
// Act
const response = await supertest(app).get('/api/v1/categories/999999');
// Assert
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Category with ID 999999 not found');
expect(mockCategoryDbService.getCategoryById).toHaveBeenCalledWith(999999, expectLogger);
});
it('should return 400 for non-numeric category ID', async () => {
// Act
const response = await supertest(app).get('/api/v1/categories/invalid');
// Assert
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
expect(mockCategoryDbService.getCategoryById).not.toHaveBeenCalled();
});
it('should return 400 for negative category ID', async () => {
// Act
const response = await supertest(app).get('/api/v1/categories/-1');
// Assert
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
expect(mockCategoryDbService.getCategoryById).not.toHaveBeenCalled();
});
it('should return 400 for zero category ID', async () => {
// Act
const response = await supertest(app).get('/api/v1/categories/0');
// Assert
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
expect(mockCategoryDbService.getCategoryById).not.toHaveBeenCalled();
});
it('should return 400 for floating point category ID', async () => {
// Act - The parseInt will convert 1.5 to 1, so this should actually succeed
const _response = await supertest(app).get('/api/v1/categories/1.5');
// Assert - parseInt('1.5', 10) returns 1, which is valid
// This tests that the route handles string-to-int conversion correctly
expect(mockCategoryDbService.getCategoryById).toHaveBeenCalledWith(1, expectLogger);
});
it('should return 500 if the database call fails', async () => {
// Arrange
const dbError = new Error('Database query timeout');
mockCategoryDbService.getCategoryById.mockRejectedValue(dbError);
// Act
const response = await supertest(app).get('/api/v1/categories/1');
// Assert
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('Database query timeout');
});
it('should handle very large category IDs', async () => {
// Arrange
mockCategoryDbService.getCategoryById.mockResolvedValue(null);
// Act
const response = await supertest(app).get('/api/v1/categories/2147483647');
// Assert
expect(response.status).toBe(404);
expect(mockCategoryDbService.getCategoryById).toHaveBeenCalledWith(2147483647, expectLogger);
});
});
// ===========================================================================
// GET /lookup - Lookup category by name
// ===========================================================================
describe('GET /lookup', () => {
it('should return 200 with category for exact name match', async () => {
// Arrange
const mockCategory = createMockCategory({
category_id: 3,
name: 'Dairy & Eggs',
});
mockCategoryDbService.getCategoryByName.mockResolvedValue(mockCategory);
// Act
const response = await supertest(app).get(
'/api/v1/categories/lookup?name=Dairy%20%26%20Eggs',
);
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual(mockCategory);
expect(mockCategoryDbService.getCategoryByName).toHaveBeenCalledWith(
'Dairy & Eggs',
expectLogger,
);
});
it('should return 200 with category for case-insensitive name match', async () => {
// Arrange
const mockCategory = createMockCategory({
category_id: 3,
name: 'Dairy & Eggs',
});
mockCategoryDbService.getCategoryByName.mockResolvedValue(mockCategory);
// Act
const response = await supertest(app).get(
'/api/v1/categories/lookup?name=dairy%20%26%20eggs',
);
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual(mockCategory);
// The service receives the original query, case-insensitivity is handled in the DB query
expect(mockCategoryDbService.getCategoryByName).toHaveBeenCalledWith(
'dairy & eggs',
expectLogger,
);
});
it('should return 404 for non-existent category name', async () => {
// Arrange
mockCategoryDbService.getCategoryByName.mockResolvedValue(null);
// Act
const response = await supertest(app).get(
'/api/v1/categories/lookup?name=NonExistentCategory',
);
// Assert
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain("Category 'NonExistentCategory' not found");
});
it('should return 400 when name query parameter is missing', async () => {
// Act
const response = await supertest(app).get('/api/v1/categories/lookup');
// Assert
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
expect(mockCategoryDbService.getCategoryByName).not.toHaveBeenCalled();
});
it('should return 400 for empty name query parameter', async () => {
// Act
const response = await supertest(app).get('/api/v1/categories/lookup?name=');
// Assert
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
expect(mockCategoryDbService.getCategoryByName).not.toHaveBeenCalled();
});
it('should return 400 for whitespace-only name query parameter', async () => {
// Act
const response = await supertest(app).get('/api/v1/categories/lookup?name=%20%20%20');
// Assert
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
expect(mockCategoryDbService.getCategoryByName).not.toHaveBeenCalled();
});
it('should handle URL-encoded special characters in name', async () => {
// Arrange
const mockCategory = createMockCategory({
category_id: 1,
name: 'Meat & Seafood',
});
mockCategoryDbService.getCategoryByName.mockResolvedValue(mockCategory);
// Act - URL encoded & is %26
const response = await supertest(app).get(
'/api/v1/categories/lookup?name=Meat%20%26%20Seafood',
);
// Assert
expect(response.status).toBe(200);
expect(response.body.data.name).toBe('Meat & Seafood');
expect(mockCategoryDbService.getCategoryByName).toHaveBeenCalledWith(
'Meat & Seafood',
expectLogger,
);
});
it('should handle names with only special characters', async () => {
// Arrange
mockCategoryDbService.getCategoryByName.mockResolvedValue(null);
// Act
const response = await supertest(app).get('/api/v1/categories/lookup?name=%26%26%26');
// Assert
expect(response.status).toBe(404);
expect(mockCategoryDbService.getCategoryByName).toHaveBeenCalledWith('&&&', expectLogger);
});
it('should return 500 if the database call fails', async () => {
// Arrange
const dbError = new Error('Database unavailable');
mockCategoryDbService.getCategoryByName.mockRejectedValue(dbError);
// Act
const response = await supertest(app).get('/api/v1/categories/lookup?name=TestCategory');
// Assert
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('Database unavailable');
});
it('should handle very long category names', async () => {
// Arrange
const longName = 'A'.repeat(500);
mockCategoryDbService.getCategoryByName.mockResolvedValue(null);
// Act
const response = await supertest(app).get(
`/api/v1/categories/lookup?name=${encodeURIComponent(longName)}`,
);
// Assert
expect(response.status).toBe(404);
expect(mockCategoryDbService.getCategoryByName).toHaveBeenCalledWith(longName, expectLogger);
});
it('should handle names with unicode characters', async () => {
// Arrange
const unicodeName = 'Fruits et Legumes';
const mockCategory = createMockCategory({
category_id: 10,
name: unicodeName,
});
mockCategoryDbService.getCategoryByName.mockResolvedValue(mockCategory);
// Act
const response = await supertest(app).get(
`/api/v1/categories/lookup?name=${encodeURIComponent(unicodeName)}`,
);
// Assert
expect(response.status).toBe(200);
expect(response.body.data.name).toBe(unicodeName);
});
it('should handle names with leading/trailing spaces (which get trimmed)', async () => {
// Act - note: the trim check happens before calling the service
// A name like " Dairy " will fail the trim() === '' check
const _response = await supertest(app).get('/api/v1/categories/lookup?name=%20Dairy%20');
// Assert - The name ' Dairy ' has non-whitespace content after trim, so it passes validation
expect(mockCategoryDbService.getCategoryByName).toHaveBeenCalledWith(' Dairy ', expectLogger);
});
});
// ===========================================================================
// Edge Cases and Error Handling
// ===========================================================================
describe('Edge Cases', () => {
it('should not require authentication for GET /', async () => {
// Arrange - no authentication setup needed for public routes
mockCategoryDbService.getAllCategories.mockResolvedValue([]);
// Act
const response = await supertest(app).get('/api/v1/categories');
// Assert
expect(response.status).toBe(200);
});
it('should not require authentication for GET /:id', async () => {
// Arrange
mockCategoryDbService.getCategoryById.mockResolvedValue(
createMockCategory({ category_id: 1, name: 'Test' }),
);
// Act
const response = await supertest(app).get('/api/v1/categories/1');
// Assert
expect(response.status).toBe(200);
});
it('should not require authentication for GET /lookup', async () => {
// Arrange
mockCategoryDbService.getCategoryByName.mockResolvedValue(
createMockCategory({ category_id: 1, name: 'Test' }),
);
// Act
const response = await supertest(app).get('/api/v1/categories/lookup?name=Test');
// Assert
expect(response.status).toBe(200);
});
it('should return consistent response format for success', async () => {
// Arrange
const mockCategories = [createMockCategory({ category_id: 1, name: 'Test' })];
mockCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
// Act
const response = await supertest(app).get('/api/v1/categories');
// Assert - verify consistent API response format
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('data');
expect(response.body.data).toEqual(mockCategories);
});
it('should return consistent response format for validation errors', async () => {
// Act
const response = await supertest(app).get('/api/v1/categories/invalid');
// Assert
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error');
expect(typeof response.body.error).toBe('string');
});
it('should return consistent response format for not found errors', async () => {
// Arrange
mockCategoryDbService.getCategoryById.mockResolvedValue(null);
// Act
const response = await supertest(app).get('/api/v1/categories/999');
// Assert
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error');
expect(typeof response.body.error).toBe('string');
});
});
// ===========================================================================
// Route Ordering Tests (ensure /lookup is matched before /:id)
// ===========================================================================
describe('Route Ordering', () => {
it('should route /lookup correctly instead of treating it as an ID', async () => {
// This tests that the router correctly matches /lookup before /:id
// If route ordering were wrong, 'lookup' would be parsed as a category ID
mockCategoryDbService.getCategoryByName.mockResolvedValue(
createMockCategory({ category_id: 1, name: 'Test' }),
);
const _response = await supertest(app).get('/api/v1/categories/lookup?name=Test');
// Assert that getCategoryByName was called, not getCategoryById
expect(mockCategoryDbService.getCategoryByName).toHaveBeenCalled();
expect(mockCategoryDbService.getCategoryById).not.toHaveBeenCalled();
});
});
});

View File

@@ -7,7 +7,7 @@ import {
createMockRecipeComment,
createMockUserProfile,
} from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db';
import { NotFoundError, ForeignKeyConstraintError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
// 1. Mock the Service Layer directly.
@@ -18,6 +18,8 @@ vi.mock('../services/db/index.db', () => ({
getRecipeById: vi.fn(),
findRecipesByIngredientAndTag: vi.fn(),
getRecipeComments: vi.fn(),
addRecipeComment: vi.fn(),
forkRecipe: vi.fn(),
},
}));
@@ -70,7 +72,9 @@ describe('Recipe Routes (/api/v1/recipes)', () => {
const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })];
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/v1/recipes/by-sale-percentage?minPercentage=75');
const response = await supertest(app).get(
'/api/v1/recipes/by-sale-percentage?minPercentage=75',
);
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockRecipes);
@@ -268,7 +272,9 @@ describe('Recipe Routes (/api/v1/recipes)', () => {
const mockSuggestion = 'Chicken and Rice Casserole...';
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
const response = await supertest(authApp).post('/api/v1/recipes/suggest').send({ ingredients });
const response = await supertest(authApp)
.post('/api/v1/recipes/suggest')
.send({ ingredients });
expect(response.status).toBe(200);
expect(response.body.data).toEqual({ suggestion: mockSuggestion });
@@ -382,4 +388,262 @@ describe('Recipe Routes (/api/v1/recipes)', () => {
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
});
describe('POST /:recipeId/comments', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'comment-user-123' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/v1/recipes',
authenticatedUser: mockUser,
});
const unauthApp = createTestApp({ router: recipeRouter, basePath: '/api/v1/recipes' });
it('should return 401 Unauthorized if user is not authenticated', async () => {
const response = await supertest(unauthApp)
.post('/api/v1/recipes/1/comments')
.send({ content: 'Great recipe!' });
expect(response.status).toBe(401);
});
it('should successfully add a comment to a recipe', async () => {
const mockComment = createMockRecipeComment({
recipe_id: 1,
user_id: mockUser.user.user_id,
content: 'This is delicious!',
});
vi.mocked(db.recipeRepo.addRecipeComment).mockResolvedValue(mockComment);
const response = await supertest(authApp)
.post('/api/v1/recipes/1/comments')
.send({ content: 'This is delicious!' });
expect(response.status).toBe(201);
expect(response.body.data).toEqual(mockComment);
expect(db.recipeRepo.addRecipeComment).toHaveBeenCalledWith(
1,
mockUser.user.user_id,
'This is delicious!',
expectLogger,
undefined,
);
});
it('should successfully add a reply to an existing comment', async () => {
const mockComment = createMockRecipeComment({
recipe_id: 1,
user_id: mockUser.user.user_id,
content: 'I agree!',
parent_comment_id: 5,
});
vi.mocked(db.recipeRepo.addRecipeComment).mockResolvedValue(mockComment);
const response = await supertest(authApp)
.post('/api/v1/recipes/1/comments')
.send({ content: 'I agree!', parentCommentId: 5 });
expect(response.status).toBe(201);
expect(response.body.data).toEqual(mockComment);
expect(db.recipeRepo.addRecipeComment).toHaveBeenCalledWith(
1,
mockUser.user.user_id,
'I agree!',
expectLogger,
5,
);
});
it('should return 400 if content is missing', async () => {
const response = await supertest(authApp).post('/api/v1/recipes/1/comments').send({});
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toBe('Comment content is required.');
});
it('should return 400 if content is empty string', async () => {
const response = await supertest(authApp)
.post('/api/v1/recipes/1/comments')
.send({ content: '' });
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toBe('Comment content is required.');
});
it('should return 400 for an invalid recipeId', async () => {
const response = await supertest(authApp)
.post('/api/v1/recipes/abc/comments')
.send({ content: 'Test comment' });
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toContain('received NaN');
});
it('should return 400 for an invalid parentCommentId', async () => {
const response = await supertest(authApp)
.post('/api/v1/recipes/1/comments')
.send({ content: 'Test comment', parentCommentId: 'invalid' });
expect(response.status).toBe(400);
});
it('should return 400 if recipe or parent comment does not exist (foreign key violation)', async () => {
const fkError = new ForeignKeyConstraintError(
'The specified recipe, user, or parent comment does not exist.',
);
vi.mocked(db.recipeRepo.addRecipeComment).mockRejectedValue(fkError);
const response = await supertest(authApp)
.post('/api/v1/recipes/999/comments')
.send({ content: 'Comment on non-existent recipe' });
expect(response.status).toBe(400);
expect(response.body.error.message).toContain('does not exist');
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(db.recipeRepo.addRecipeComment).mockRejectedValue(dbError);
const response = await supertest(authApp)
.post('/api/v1/recipes/1/comments')
.send({ content: 'Test comment' });
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error adding comment to recipe ID 1:',
);
});
});
describe('POST /:recipeId/fork', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'fork-user-456' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/v1/recipes',
authenticatedUser: mockUser,
});
const unauthApp = createTestApp({ router: recipeRouter, basePath: '/api/v1/recipes' });
it('should return 401 Unauthorized if user is not authenticated', async () => {
const response = await supertest(unauthApp).post('/api/v1/recipes/1/fork');
expect(response.status).toBe(401);
});
it('should successfully fork a recipe', async () => {
const forkedRecipe = createMockRecipe({
recipe_id: 20,
name: 'Original Recipe (Fork)',
user_id: mockUser.user.user_id,
original_recipe_id: 10,
});
vi.mocked(db.recipeRepo.forkRecipe).mockResolvedValue(forkedRecipe);
const response = await supertest(authApp).post('/api/v1/recipes/10/fork');
expect(response.status).toBe(201);
expect(response.body.data).toEqual(forkedRecipe);
expect(db.recipeRepo.forkRecipe).toHaveBeenCalledWith(
mockUser.user.user_id,
10,
expectLogger,
);
});
it('should return 400 for an invalid recipeId', async () => {
const response = await supertest(authApp).post('/api/v1/recipes/abc/fork');
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toContain('received NaN');
});
it('should return 400 if recipe does not exist (foreign key violation)', async () => {
const fkError = new ForeignKeyConstraintError(
'The specified user or original recipe does not exist.',
);
vi.mocked(db.recipeRepo.forkRecipe).mockRejectedValue(fkError);
const response = await supertest(authApp).post('/api/v1/recipes/999/fork');
expect(response.status).toBe(400);
expect(response.body.error.message).toContain('does not exist');
});
it('should return 500 if database function raises an error (e.g., recipe not public)', async () => {
const dbFunctionError = new Error('Cannot fork a private recipe.');
vi.mocked(db.recipeRepo.forkRecipe).mockRejectedValue(dbFunctionError);
const response = await supertest(authApp).post('/api/v1/recipes/5/fork');
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('Cannot fork a private recipe.');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbFunctionError },
'Error forking recipe ID 5:',
);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(db.recipeRepo.forkRecipe).mockRejectedValue(dbError);
const response = await supertest(authApp).post('/api/v1/recipes/1/fork');
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error forking recipe ID 1:',
);
});
});
describe('Rate Limiting on POST /:recipeId/comments', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'rate-limit-comment-user' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/v1/recipes',
authenticatedUser: mockUser,
});
it('should apply userUpdateLimiter to POST /:recipeId/comments', async () => {
const mockComment = createMockRecipeComment({});
vi.mocked(db.recipeRepo.addRecipeComment).mockResolvedValue(mockComment);
const response = await supertest(authApp)
.post('/api/v1/recipes/1/comments')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ content: 'Test comment' });
expect(response.status).toBe(201);
expect(response.headers).toHaveProperty('ratelimit-limit');
// userUpdateLimiter has limit of 100 per 15 minutes
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
});
describe('Rate Limiting on POST /:recipeId/fork', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'rate-limit-fork-user' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/v1/recipes',
authenticatedUser: mockUser,
});
it('should apply userUpdateLimiter to POST /:recipeId/fork', async () => {
const forkedRecipe = createMockRecipe({});
vi.mocked(db.recipeRepo.forkRecipe).mockResolvedValue(forkedRecipe);
const response = await supertest(authApp)
.post('/api/v1/recipes/1/fork')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(201);
expect(response.headers).toHaveProperty('ratelimit-limit');
// userUpdateLimiter has limit of 100 per 15 minutes
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
});
});

View File

@@ -2,7 +2,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AddressRepository } from './address.db';
import type { Address } from '../../types';
import { UniqueConstraintError, NotFoundError } from './errors.db';
import {
UniqueConstraintError,
NotFoundError,
ForeignKeyConstraintError,
NotNullConstraintError,
CheckConstraintError,
InvalidTextRepresentationError,
NumericValueOutOfRangeError,
} from './errors.db';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./address.db');
// Mock dependencies
vi.mock('../logger.server', () => ({
@@ -16,6 +27,23 @@ describe('Address DB Service', () => {
query: vi.fn(),
};
// Helper function to create a mock address with default values
const createMockAddress = (overrides: Partial<Address> = {}): Address => ({
address_id: 1,
address_line_1: '123 Main St',
address_line_2: null,
city: 'Anytown',
province_state: 'CA',
postal_code: '12345',
country: 'USA',
latitude: null,
longitude: null,
location: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.mockReset();
@@ -24,16 +52,7 @@ describe('Address DB Service', () => {
describe('getAddressById', () => {
it('should return an address if found', async () => {
const mockAddress: Address = {
address_id: 1,
address_line_1: '123 Main St',
city: 'Anytown',
province_state: 'CA',
postal_code: '12345',
country: 'USA',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const mockAddress = createMockAddress();
mockDb.query.mockResolvedValue({ rows: [mockAddress], rowCount: 1 });
const result = await addressRepo.getAddressById(1, mockLogger);
@@ -65,6 +84,51 @@ describe('Address DB Service', () => {
'Database error in getAddressById',
);
});
it('should handle InvalidTextRepresentationError for invalid ID format', async () => {
const dbError = new Error('invalid input syntax for type integer');
(dbError as any).code = '22P02';
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.getAddressById(NaN, mockLogger)).rejects.toThrow(
InvalidTextRepresentationError,
);
});
it('should handle edge case with address_id of 0', async () => {
mockDb.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(addressRepo.getAddressById(0, mockLogger)).rejects.toThrow(NotFoundError);
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT * FROM public.addresses WHERE address_id = $1',
[0],
);
});
it('should handle edge case with negative address_id', async () => {
mockDb.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(addressRepo.getAddressById(-1, mockLogger)).rejects.toThrow(NotFoundError);
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT * FROM public.addresses WHERE address_id = $1',
[-1],
);
});
it('should return address with all optional fields populated', async () => {
const mockAddress = createMockAddress({
address_line_2: 'Suite 100',
latitude: 37.7749,
longitude: -122.4194,
location: { type: 'Point', coordinates: [-122.4194, 37.7749] },
});
mockDb.query.mockResolvedValue({ rows: [mockAddress], rowCount: 1 });
const result = await addressRepo.getAddressById(1, mockLogger);
expect(result.address_line_2).toBe('Suite 100');
expect(result.latitude).toBe(37.7749);
expect(result.longitude).toBe(-122.4194);
expect(result.location).toEqual({ type: 'Point', coordinates: [-122.4194, 37.7749] });
});
});
describe('upsertAddress', () => {
@@ -131,5 +195,400 @@ describe('Address DB Service', () => {
'Database error in upsertAddress',
);
});
it('should handle NotNullConstraintError when required field is null', async () => {
const addressData = { address_line_1: null as unknown as string };
const dbError = new Error('null value in column violates not-null constraint');
(dbError as any).code = '23502';
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
NotNullConstraintError,
);
});
it('should handle CheckConstraintError when check constraint is violated', async () => {
const addressData = { address_line_1: '123 Test St', postal_code: '' };
const dbError = new Error('check constraint violation');
(dbError as any).code = '23514';
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
CheckConstraintError,
);
});
it('should handle upsert with all address fields', async () => {
const fullAddressData = {
address_line_1: '100 Complete St',
address_line_2: 'Apt 1',
city: 'Fullville',
province_state: 'NY',
postal_code: '10001',
country: 'USA',
latitude: 40.7128,
longitude: -74.006,
};
mockDb.query.mockResolvedValue({ rows: [{ address_id: 5 }] });
const result = await addressRepo.upsertAddress(fullAddressData, mockLogger);
expect(result).toBe(5);
const [query, values] = mockDb.query.mock.calls[0];
expect(query).toContain('INSERT INTO public.addresses');
expect(values).toContain('100 Complete St');
expect(values).toContain('Apt 1');
expect(values).toContain('Fullville');
expect(values).toContain('NY');
expect(values).toContain('10001');
expect(values).toContain('USA');
expect(values).toContain(40.7128);
expect(values).toContain(-74.006);
});
it('should handle update with partial fields', async () => {
const partialUpdate = { address_id: 1, city: 'UpdatedCity' };
mockDb.query.mockResolvedValue({ rows: [{ address_id: 1 }] });
const result = await addressRepo.upsertAddress(partialUpdate, mockLogger);
expect(result).toBe(1);
const [query, values] = mockDb.query.mock.calls[0];
expect(query).toContain('ON CONFLICT (address_id) DO UPDATE');
expect(query).toContain('city = EXCLUDED.city');
expect(values).toEqual([1, 'UpdatedCity']);
});
it('should handle NumericValueOutOfRangeError for invalid latitude/longitude', async () => {
const addressData = { address_line_1: '123 Test St', latitude: 999999 };
const dbError = new Error('numeric value out of range');
(dbError as any).code = '22003';
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
NumericValueOutOfRangeError,
);
});
it('should handle ForeignKeyConstraintError if a FK is violated', async () => {
const addressData = { address_line_1: '123 FK St' };
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
ForeignKeyConstraintError,
);
});
});
});
describe('searchAddressesByText', () => {
it('should execute the correct query and return matching addresses', async () => {
const mockAddresses = [
createMockAddress({ address_id: 1, city: 'Toronto' }),
createMockAddress({ address_id: 2, city: 'Toronto East' }),
];
mockDb.query.mockResolvedValue({ rows: mockAddresses });
const result = await addressRepo.searchAddressesByText('Toronto', mockLogger);
expect(result).toEqual(mockAddresses);
expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('WHERE'), [
'%Toronto%',
10,
]);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('address_line_1 ILIKE $1'),
['%Toronto%', 10],
);
expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('city ILIKE $1'), [
'%Toronto%',
10,
]);
expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('postal_code ILIKE $1'), [
'%Toronto%',
10,
]);
});
it('should return an empty array if no addresses match', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await addressRepo.searchAddressesByText('NonexistentCity', mockLogger);
expect(result).toEqual([]);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ['%NonexistentCity%', 10]);
});
it('should use custom limit when provided', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
await addressRepo.searchAddressesByText('Test', mockLogger, 5);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ['%Test%', 5]);
});
it('should use default limit of 10 when not provided', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
await addressRepo.searchAddressesByText('Test', mockLogger);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ['%Test%', 10]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.searchAddressesByText('Toronto', mockLogger)).rejects.toThrow(
'Failed to search addresses.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, query: 'Toronto', limit: 10 },
'Database error in searchAddressesByText',
);
});
it('should throw error with correct context when custom limit is used', async () => {
const dbError = new Error('DB Error');
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.searchAddressesByText('Test', mockLogger, 25)).rejects.toThrow(
'Failed to search addresses.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, query: 'Test', limit: 25 },
'Database error in searchAddressesByText',
);
});
it('should handle searching by postal code', async () => {
const mockAddresses = [createMockAddress({ address_id: 1, postal_code: 'M5V 3A1' })];
mockDb.query.mockResolvedValue({ rows: mockAddresses });
const result = await addressRepo.searchAddressesByText('M5V', mockLogger);
expect(result).toEqual(mockAddresses);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ['%M5V%', 10]);
});
it('should handle searching by street address', async () => {
const mockAddresses = [createMockAddress({ address_id: 1, address_line_1: '100 King St W' })];
mockDb.query.mockResolvedValue({ rows: mockAddresses });
const result = await addressRepo.searchAddressesByText('King St', mockLogger);
expect(result).toEqual(mockAddresses);
});
it('should handle empty search string', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await addressRepo.searchAddressesByText('', mockLogger);
expect(result).toEqual([]);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ['%%', 10]);
});
it('should handle special characters in search query', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
await addressRepo.searchAddressesByText("O'Brien", mockLogger);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ["%O'Brien%", 10]);
});
it('should return results ordered by city and address_line_1', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
await addressRepo.searchAddressesByText('Test', mockLogger);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY city ASC, address_line_1 ASC'),
expect.any(Array),
);
});
it('should handle limit of 0', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
await addressRepo.searchAddressesByText('Test', mockLogger, 0);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ['%Test%', 0]);
});
it('should handle large limit values', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
await addressRepo.searchAddressesByText('Test', mockLogger, 1000);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), ['%Test%', 1000]);
});
});
describe('getAddressesByStoreId', () => {
it('should execute the correct query and return addresses for a store', async () => {
const mockAddresses = [
createMockAddress({ address_id: 1 }),
createMockAddress({ address_id: 2 }),
];
mockDb.query.mockResolvedValue({ rows: mockAddresses });
const result = await addressRepo.getAddressesByStoreId(1, mockLogger);
expect(result).toEqual(mockAddresses);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('FROM public.addresses a'),
[1],
);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining(
'INNER JOIN public.store_locations sl ON a.address_id = sl.address_id',
),
[1],
);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE sl.store_id = $1'),
[1],
);
});
it('should return an empty array if the store has no addresses', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await addressRepo.getAddressesByStoreId(999, mockLogger);
expect(result).toEqual([]);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), [999]);
});
it('should return an empty array for a non-existent store', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await addressRepo.getAddressesByStoreId(0, mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.getAddressesByStoreId(1, mockLogger)).rejects.toThrow(
'Failed to retrieve addresses for store.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, storeId: 1 },
'Database error in getAddressesByStoreId',
);
});
it('should handle store_id of 0', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await addressRepo.getAddressesByStoreId(0, mockLogger);
expect(result).toEqual([]);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), [0]);
});
it('should handle negative store_id', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await addressRepo.getAddressesByStoreId(-1, mockLogger);
expect(result).toEqual([]);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), [-1]);
});
it('should handle InvalidTextRepresentationError for invalid store ID format', async () => {
const dbError = new Error('invalid input syntax for type integer');
(dbError as any).code = '22P02';
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.getAddressesByStoreId(NaN, mockLogger)).rejects.toThrow(
InvalidTextRepresentationError,
);
});
it('should return results ordered by store_location created_at ASC', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
await addressRepo.getAddressesByStoreId(1, mockLogger);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY sl.created_at ASC'),
expect.any(Array),
);
});
it('should return multiple addresses when store has multiple locations', async () => {
const mockAddresses = [
createMockAddress({ address_id: 1, city: 'Toronto' }),
createMockAddress({ address_id: 2, city: 'Vancouver' }),
createMockAddress({ address_id: 3, city: 'Montreal' }),
];
mockDb.query.mockResolvedValue({ rows: mockAddresses });
const result = await addressRepo.getAddressesByStoreId(1, mockLogger);
expect(result).toHaveLength(3);
expect(result[0].city).toBe('Toronto');
expect(result[1].city).toBe('Vancouver');
expect(result[2].city).toBe('Montreal');
});
});
describe('Repository instantiation', () => {
it('should use provided db connection', () => {
const customDb = { query: vi.fn() };
const repo = new AddressRepository(customDb);
expect(repo).toBeDefined();
});
it('should work with default pool when no db provided', () => {
// This tests that the constructor can be called without arguments
// The default getPool() will be used - we don't test the actual pool here
// as that would require mocking the connection module
expect(() => new AddressRepository(mockDb)).not.toThrow();
});
});
describe('Error handling edge cases', () => {
it('should rethrow NotFoundError without wrapping', async () => {
mockDb.query.mockResolvedValue({ rowCount: 0, rows: [] });
try {
await addressRepo.getAddressById(999, mockLogger);
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(NotFoundError);
expect((error as NotFoundError).status).toBe(404);
}
});
it('should handle PostgreSQL error with constraint and detail properties', async () => {
const dbError = new Error('duplicate key');
(dbError as any).code = '23505';
(dbError as any).constraint = 'addresses_pkey';
(dbError as any).detail = 'Key (address_id)=(1) already exists.';
mockDb.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress({ address_id: 1 }, mockLogger)).rejects.toThrow(
UniqueConstraintError,
);
expect(mockLogger.error).toHaveBeenCalledWith(
{
err: dbError,
address: { address_id: 1 },
code: '23505',
constraint: 'addresses_pkey',
detail: 'Key (address_id)=(1) already exists.',
},
'Database error in upsertAddress',
);
});
});
});

View File

@@ -0,0 +1,579 @@
// src/services/db/category.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Pool } from 'pg';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./category.db');
// Mock the logger to prevent console output during tests
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
// Mock the connection module to control getPool
vi.mock('./connection.db', () => ({
getPool: vi.fn(),
}));
import { getPool } from './connection.db';
import { CategoryDbService, type Category } from './category.db';
import { DatabaseError } from '../processingErrors';
describe('Category DB Service', () => {
// Create a mock pool instance with a query method
const mockPool = {
query: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockPool.query.mockReset();
// Mock getPool to return our mock pool
vi.mocked(getPool).mockReturnValue(mockPool as unknown as Pool);
});
describe('getAllCategories', () => {
it('should execute the correct SELECT query and return all categories ordered by name', async () => {
// Arrange
const mockCategories: Category[] = [
{
category_id: 1,
name: 'Bakery',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
},
{
category_id: 2,
name: 'Dairy & Eggs',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
},
{
category_id: 3,
name: 'Fruits & Vegetables',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
},
];
mockPool.query.mockResolvedValue({ rows: mockCategories });
// Act
const result = await CategoryDbService.getAllCategories(mockLogger);
// Assert
expect(result).toEqual(mockCategories);
expect(mockPool.query).toHaveBeenCalledTimes(1);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT category_id, name, created_at, updated_at'),
);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('FROM public.categories'),
);
expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('ORDER BY name ASC'));
});
it('should return an empty array when no categories exist', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getAllCategories(mockLogger);
// Assert
expect(result).toEqual([]);
expect(mockPool.query).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseError and log when the database query fails', async () => {
// Arrange
const dbError = new Error('Connection refused');
mockPool.query.mockRejectedValue(dbError);
// Act & Assert
await expect(CategoryDbService.getAllCategories(mockLogger)).rejects.toThrow(DatabaseError);
// The DatabaseError from processingErrors.ts uses the default message from handleDbError
await expect(CategoryDbService.getAllCategories(mockLogger)).rejects.toThrow(
'Failed to perform operation on database.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError },
'Error fetching all categories',
);
});
it('should handle PostgreSQL specific errors correctly', async () => {
// Arrange - Simulate a PostgreSQL connection error
const pgError = new Error('Connection terminated unexpectedly');
(pgError as Error & { code: string }).code = '57P01'; // Admin shutdown
mockPool.query.mockRejectedValue(pgError);
// Act & Assert
await expect(CategoryDbService.getAllCategories(mockLogger)).rejects.toThrow(DatabaseError);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: pgError,
code: '57P01',
}),
'Error fetching all categories',
);
});
});
describe('getCategoryById', () => {
it('should execute the correct SELECT query with category ID parameter', async () => {
// Arrange
const mockCategory: Category = {
category_id: 5,
name: 'Meat & Seafood',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryById(5, mockLogger);
// Assert
expect(result).toEqual(mockCategory);
expect(mockPool.query).toHaveBeenCalledTimes(1);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT category_id, name, created_at, updated_at'),
[5],
);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE category_id = $1'),
[5],
);
});
it('should return null when category is not found', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryById(999, mockLogger);
// Assert
expect(result).toBeNull();
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), [999]);
});
it('should return null for non-existent category ID of 0', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryById(0, mockLogger);
// Assert
expect(result).toBeNull();
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), [0]);
});
it('should return null for negative category ID', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryById(-1, mockLogger);
// Assert
expect(result).toBeNull();
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), [-1]);
});
it('should throw a DatabaseError and log when the database query fails', async () => {
// Arrange
const dbError = new Error('Database timeout');
mockPool.query.mockRejectedValue(dbError);
// Act & Assert
await expect(CategoryDbService.getCategoryById(1, mockLogger)).rejects.toThrow(DatabaseError);
// The DatabaseError from processingErrors.ts uses the default message from handleDbError
await expect(CategoryDbService.getCategoryById(1, mockLogger)).rejects.toThrow(
'Failed to perform operation on database.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, categoryId: 1 },
'Error fetching category by ID',
);
});
it('should handle PostgreSQL invalid text representation error for invalid ID type', async () => {
// Arrange - Simulate PostgreSQL error for invalid ID format
const pgError = new Error('invalid input syntax for type integer');
(pgError as Error & { code: string }).code = '22P02';
mockPool.query.mockRejectedValue(pgError);
// Act & Assert
// Note: The implementation catches this and rethrows via handleDbError
// which converts it to an InvalidTextRepresentationError
const { InvalidTextRepresentationError } = await import('./errors.db');
await expect(CategoryDbService.getCategoryById(1, mockLogger)).rejects.toThrow(
InvalidTextRepresentationError,
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: pgError,
categoryId: 1,
code: '22P02',
}),
'Error fetching category by ID',
);
});
});
describe('getCategoryByName', () => {
it('should execute the correct SELECT query with case-insensitive name matching', async () => {
// Arrange
const mockCategory: Category = {
category_id: 3,
name: 'Dairy & Eggs',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryByName('Dairy & Eggs', mockLogger);
// Assert
expect(result).toEqual(mockCategory);
expect(mockPool.query).toHaveBeenCalledTimes(1);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT category_id, name, created_at, updated_at'),
['Dairy & Eggs'],
);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE LOWER(name) = LOWER($1)'),
['Dairy & Eggs'],
);
});
it('should find category with lowercase input matching uppercase stored name', async () => {
// Arrange
const mockCategory: Category = {
category_id: 3,
name: 'DAIRY & EGGS',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryByName('dairy & eggs', mockLogger);
// Assert
expect(result).toEqual(mockCategory);
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), ['dairy & eggs']);
});
it('should find category with mixed case input', async () => {
// Arrange
const mockCategory: Category = {
category_id: 4,
name: 'Frozen Foods',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryByName('fRoZeN fOoDs', mockLogger);
// Assert
expect(result).toEqual(mockCategory);
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), ['fRoZeN fOoDs']);
});
it('should return null when category name is not found', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryByName('Non-Existent Category', mockLogger);
// Assert
expect(result).toBeNull();
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), ['Non-Existent Category']);
});
it('should return null for empty string name', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryByName('', mockLogger);
// Assert
expect(result).toBeNull();
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), ['']);
});
it('should return null for whitespace-only name', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryByName(' ', mockLogger);
// Assert
expect(result).toBeNull();
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), [' ']);
});
it('should handle special characters in category name', async () => {
// Arrange
const mockCategory: Category = {
category_id: 10,
name: "Health & Beauty (Women's)",
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryByName(
"Health & Beauty (Women's)",
mockLogger,
);
// Assert
expect(result).toEqual(mockCategory);
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), [
"Health & Beauty (Women's)",
]);
});
it('should throw a DatabaseError and log when the database query fails', async () => {
// Arrange
const dbError = new Error('Query execution failed');
mockPool.query.mockRejectedValue(dbError);
// Act & Assert
await expect(CategoryDbService.getCategoryByName('Test', mockLogger)).rejects.toThrow(
DatabaseError,
);
// The DatabaseError from processingErrors.ts uses the default message from handleDbError
await expect(CategoryDbService.getCategoryByName('Test', mockLogger)).rejects.toThrow(
'Failed to perform operation on database.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, name: 'Test' },
'Error fetching category by name',
);
});
it('should handle PostgreSQL specific errors and include name in log context', async () => {
// Arrange - Simulate a PostgreSQL error
const pgError = new Error('Out of memory');
(pgError as Error & { code: string }).code = '53200'; // Out of memory
mockPool.query.mockRejectedValue(pgError);
// Act & Assert
await expect(CategoryDbService.getCategoryByName('LargeQuery', mockLogger)).rejects.toThrow(
DatabaseError,
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: pgError,
name: 'LargeQuery',
code: '53200',
}),
'Error fetching category by name',
);
});
});
describe('Integration-like scenarios', () => {
it('should handle multiple sequential calls correctly', async () => {
// Arrange
const categories: Category[] = [
{
category_id: 1,
name: 'A Category',
created_at: new Date(),
updated_at: new Date(),
},
];
const singleCategory: Category = {
category_id: 1,
name: 'A Category',
created_at: new Date(),
updated_at: new Date(),
};
mockPool.query
.mockResolvedValueOnce({ rows: categories }) // getAllCategories
.mockResolvedValueOnce({ rows: [singleCategory] }) // getCategoryById
.mockResolvedValueOnce({ rows: [singleCategory] }); // getCategoryByName
// Act
const allResult = await CategoryDbService.getAllCategories(mockLogger);
const byIdResult = await CategoryDbService.getCategoryById(1, mockLogger);
const byNameResult = await CategoryDbService.getCategoryByName('A Category', mockLogger);
// Assert
expect(allResult).toEqual(categories);
expect(byIdResult).toEqual(singleCategory);
expect(byNameResult).toEqual(singleCategory);
expect(mockPool.query).toHaveBeenCalledTimes(3);
});
it('should correctly isolate errors between calls', async () => {
// Arrange
const dbError = new Error('Transient error');
const mockCategory: Category = {
category_id: 1,
name: 'Test',
created_at: new Date(),
updated_at: new Date(),
};
mockPool.query.mockRejectedValueOnce(dbError).mockResolvedValueOnce({ rows: [mockCategory] });
// Act & Assert
// First call fails
await expect(CategoryDbService.getCategoryById(1, mockLogger)).rejects.toThrow(DatabaseError);
// Second call succeeds (simulating recovery)
const result = await CategoryDbService.getCategoryById(1, mockLogger);
expect(result).toEqual(mockCategory);
});
});
describe('Edge cases', () => {
it('should handle very large category ID', async () => {
// Arrange
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryById(Number.MAX_SAFE_INTEGER, mockLogger);
// Assert
expect(result).toBeNull();
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), [Number.MAX_SAFE_INTEGER]);
});
it('should handle category name with SQL-like content (SQL injection prevention)', async () => {
// Arrange - This tests that parameterized queries prevent SQL injection
const maliciousName = "'; DROP TABLE categories; --";
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryByName(maliciousName, mockLogger);
// Assert
expect(result).toBeNull();
// The important thing is that the name is passed as a parameter, not concatenated
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE LOWER(name) = LOWER($1)'),
[maliciousName],
);
});
it('should handle unicode characters in category name', async () => {
// Arrange
const mockCategory: Category = {
category_id: 15,
name: 'Bebidas y Jugos',
created_at: new Date(),
updated_at: new Date(),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryByName('Bebidas y Jugos', mockLogger);
// Assert
expect(result).toEqual(mockCategory);
});
it('should handle category with emoji in name', async () => {
// Arrange
const mockCategory: Category = {
category_id: 20,
name: 'Snacks',
created_at: new Date(),
updated_at: new Date(),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryByName('Snacks', mockLogger);
// Assert
expect(result).toEqual(mockCategory);
});
});
describe('Return type verification', () => {
it('getAllCategories should return Category[] type with all required fields', async () => {
// Arrange
const mockCategories: Category[] = [
{
category_id: 1,
name: 'Test Category',
created_at: new Date('2024-01-15T10:30:00Z'),
updated_at: new Date('2024-01-15T10:30:00Z'),
},
];
mockPool.query.mockResolvedValue({ rows: mockCategories });
// Act
const result = await CategoryDbService.getAllCategories(mockLogger);
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty('category_id');
expect(result[0]).toHaveProperty('name');
expect(result[0]).toHaveProperty('created_at');
expect(result[0]).toHaveProperty('updated_at');
expect(typeof result[0].category_id).toBe('number');
expect(typeof result[0].name).toBe('string');
expect(result[0].created_at).toBeInstanceOf(Date);
expect(result[0].updated_at).toBeInstanceOf(Date);
});
it('getCategoryById should return Category | null type', async () => {
// Arrange - Test non-null case
const mockCategory: Category = {
category_id: 1,
name: 'Test',
created_at: new Date(),
updated_at: new Date(),
};
mockPool.query.mockResolvedValue({ rows: [mockCategory] });
// Act
const result = await CategoryDbService.getCategoryById(1, mockLogger);
// Assert
expect(result).not.toBeNull();
expect(result).toHaveProperty('category_id', 1);
});
it('getCategoryByName should return Category | null type', async () => {
// Arrange - Test null case
mockPool.query.mockResolvedValue({ rows: [] });
// Act
const result = await CategoryDbService.getCategoryByName('Missing', mockLogger);
// Assert
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,572 @@
// src/services/db/flyerLocation.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { FlyerLocation } from '../../types';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./flyerLocation.db');
// Mock the logger to prevent console output during tests
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
// Import the repository after mocks are set up
import { FlyerLocationRepository } from './flyerLocation.db';
describe('FlyerLocation DB Service', () => {
let flyerLocationRepo: FlyerLocationRepository;
const mockDb = {
query: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.mockReset();
// Instantiate the repository with the minimal mock db for each test
flyerLocationRepo = new FlyerLocationRepository(mockDb as any);
});
describe('linkFlyerToLocations', () => {
it('should execute bulk INSERT query with multiple store location IDs', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 3 });
const storeLocationIds = [1, 2, 3];
await flyerLocationRepo.linkFlyerToLocations(100, storeLocationIds, mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.flyer_locations'),
[100, 1, 2, 3],
);
// Check the VALUES clause has multiple placeholders
expect(mockDb.query.mock.calls[0][0]).toContain('VALUES ($1, $2), ($1, $3), ($1, $4)');
expect(mockDb.query.mock.calls[0][0]).toContain(
'ON CONFLICT (flyer_id, store_location_id) DO NOTHING',
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 100, locationCount: 3 },
'Linked flyer to store locations',
);
});
it('should execute INSERT query with single store location ID', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 1 });
await flyerLocationRepo.linkFlyerToLocations(200, [5], mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('VALUES ($1, $2)'),
[200, 5],
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 200, locationCount: 1 },
'Linked flyer to store locations',
);
});
it('should log warning and not query when storeLocationIds is empty', async () => {
await flyerLocationRepo.linkFlyerToLocations(300, [], mockLogger);
expect(mockDb.query).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{ flyerId: 300 },
'No store locations provided for flyer linkage',
);
});
it('should handle ON CONFLICT silently for duplicate entries', async () => {
// ON CONFLICT DO NOTHING means duplicate rows are silently ignored
mockDb.query.mockResolvedValue({ rows: [], rowCount: 2 }); // Only 2 of 3 inserted
await flyerLocationRepo.linkFlyerToLocations(400, [10, 11, 12], mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 400, locationCount: 3 },
'Linked flyer to store locations',
);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(flyerLocationRepo.linkFlyerToLocations(500, [1, 2], mockLogger)).rejects.toThrow(
'Failed to link flyer to store locations.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, flyerId: 500, storeLocationIds: [1, 2] },
'Database error in linkFlyerToLocations',
);
});
it('should throw ForeignKeyConstraintError if flyer or store location does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as Error & { code: string }).code = '23503';
mockDb.query.mockRejectedValue(dbError);
// The handleDbError function will throw ForeignKeyConstraintError
const { ForeignKeyConstraintError } = await import('./errors.db');
await expect(flyerLocationRepo.linkFlyerToLocations(999, [1], mockLogger)).rejects.toThrow(
ForeignKeyConstraintError,
);
});
});
describe('linkFlyerToAllStoreLocations', () => {
it('should execute INSERT...SELECT query and return the count of linked locations', async () => {
mockDb.query.mockResolvedValue({
rows: [{ store_location_id: 1 }, { store_location_id: 2 }, { store_location_id: 3 }],
rowCount: 3,
});
const result = await flyerLocationRepo.linkFlyerToAllStoreLocations(100, 5, mockLogger);
expect(result).toBe(3);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.flyer_locations'),
[100, 5],
);
expect(mockDb.query.mock.calls[0][0]).toContain('SELECT $1, store_location_id');
expect(mockDb.query.mock.calls[0][0]).toContain('FROM public.store_locations');
expect(mockDb.query.mock.calls[0][0]).toContain('WHERE store_id = $2');
expect(mockDb.query.mock.calls[0][0]).toContain(
'ON CONFLICT (flyer_id, store_location_id) DO NOTHING',
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 100, storeId: 5, linkedCount: 3 },
'Linked flyer to all store locations',
);
});
it('should return 0 when no store locations exist for the store', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
const result = await flyerLocationRepo.linkFlyerToAllStoreLocations(200, 10, mockLogger);
expect(result).toBe(0);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 200, storeId: 10, linkedCount: 0 },
'Linked flyer to all store locations',
);
});
it('should return 0 when all locations are already linked (ON CONFLICT)', async () => {
// ON CONFLICT DO NOTHING means no rows returned
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
const result = await flyerLocationRepo.linkFlyerToAllStoreLocations(300, 15, mockLogger);
expect(result).toBe(0);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(
flyerLocationRepo.linkFlyerToAllStoreLocations(400, 20, mockLogger),
).rejects.toThrow('Failed to link flyer to all store locations.');
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, flyerId: 400, storeId: 20 },
'Database error in linkFlyerToAllStoreLocations',
);
});
it('should throw ForeignKeyConstraintError if flyer or store does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as Error & { code: string }).code = '23503';
mockDb.query.mockRejectedValue(dbError);
const { ForeignKeyConstraintError } = await import('./errors.db');
await expect(
flyerLocationRepo.linkFlyerToAllStoreLocations(999, 999, mockLogger),
).rejects.toThrow(ForeignKeyConstraintError);
});
});
describe('unlinkAllLocations', () => {
it('should execute DELETE query for all flyer locations', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 5 });
await flyerLocationRepo.unlinkAllLocations(100, mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockDb.query).toHaveBeenCalledWith(
'DELETE FROM public.flyer_locations WHERE flyer_id = $1',
[100],
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 100 },
'Unlinked all locations from flyer',
);
});
it('should complete successfully even if no rows are deleted', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
await flyerLocationRepo.unlinkAllLocations(200, mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 200 },
'Unlinked all locations from flyer',
);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(flyerLocationRepo.unlinkAllLocations(300, mockLogger)).rejects.toThrow(
'Failed to unlink locations from flyer.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, flyerId: 300 },
'Database error in unlinkAllLocations',
);
});
});
describe('unlinkLocation', () => {
it('should execute DELETE query for a specific flyer-location pair', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 1 });
await flyerLocationRepo.unlinkLocation(100, 50, mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockDb.query).toHaveBeenCalledWith(
'DELETE FROM public.flyer_locations WHERE flyer_id = $1 AND store_location_id = $2',
[100, 50],
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 100, storeLocationId: 50 },
'Unlinked location from flyer',
);
});
it('should complete successfully even if the link does not exist', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
await flyerLocationRepo.unlinkLocation(200, 60, mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 200, storeLocationId: 60 },
'Unlinked location from flyer',
);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(flyerLocationRepo.unlinkLocation(300, 70, mockLogger)).rejects.toThrow(
'Failed to unlink location from flyer.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, flyerId: 300, storeLocationId: 70 },
'Database error in unlinkLocation',
);
});
});
describe('getLocationIdsByFlyerId', () => {
it('should return an array of store location IDs for a flyer', async () => {
mockDb.query.mockResolvedValue({
rows: [{ store_location_id: 1 }, { store_location_id: 2 }, { store_location_id: 3 }],
});
const result = await flyerLocationRepo.getLocationIdsByFlyerId(100, mockLogger);
expect(result).toEqual([1, 2, 3]);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT store_location_id FROM public.flyer_locations WHERE flyer_id = $1',
[100],
);
});
it('should return an empty array if no locations are linked to the flyer', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await flyerLocationRepo.getLocationIdsByFlyerId(200, mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(flyerLocationRepo.getLocationIdsByFlyerId(300, mockLogger)).rejects.toThrow(
'Failed to get location IDs for flyer.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, flyerId: 300 },
'Database error in getLocationIdsByFlyerId',
);
});
});
describe('getFlyerLocationsByFlyerId', () => {
it('should return an array of FlyerLocation objects for a flyer', async () => {
const mockFlyerLocations: FlyerLocation[] = [
{
flyer_id: 100,
store_location_id: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
flyer_id: 100,
store_location_id: 2,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
mockDb.query.mockResolvedValue({ rows: mockFlyerLocations });
const result = await flyerLocationRepo.getFlyerLocationsByFlyerId(100, mockLogger);
expect(result).toEqual(mockFlyerLocations);
expect(mockDb.query).toHaveBeenCalledTimes(1);
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyer_locations WHERE flyer_id = $1',
[100],
);
});
it('should return an empty array if no flyer locations exist for the flyer', async () => {
mockDb.query.mockResolvedValue({ rows: [] });
const result = await flyerLocationRepo.getFlyerLocationsByFlyerId(200, mockLogger);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
await expect(flyerLocationRepo.getFlyerLocationsByFlyerId(300, mockLogger)).rejects.toThrow(
'Failed to get flyer locations.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, flyerId: 300 },
'Database error in getFlyerLocationsByFlyerId',
);
});
});
describe('Transaction support (optional PoolClient parameter)', () => {
it('should use provided PoolClient instead of Pool when passed to constructor', async () => {
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: [{ store_location_id: 1 }], rowCount: 1 }),
};
// Create repository with a mock PoolClient
const repoWithClient = new FlyerLocationRepository(mockClient as any);
await repoWithClient.linkFlyerToLocations(100, [1], mockLogger);
// Should use the client's query method, not the pool's
expect(mockClient.query).toHaveBeenCalledTimes(1);
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.flyer_locations'),
[100, 1],
);
});
it('should work correctly within a transaction for linkFlyerToAllStoreLocations', async () => {
const mockClient = {
query: vi.fn().mockResolvedValue({
rows: [{ store_location_id: 1 }, { store_location_id: 2 }],
rowCount: 2,
}),
};
const repoWithClient = new FlyerLocationRepository(mockClient as any);
const result = await repoWithClient.linkFlyerToAllStoreLocations(100, 5, mockLogger);
expect(result).toBe(2);
expect(mockClient.query).toHaveBeenCalledTimes(1);
});
it('should work correctly within a transaction for unlinkAllLocations', async () => {
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 3 }),
};
const repoWithClient = new FlyerLocationRepository(mockClient as any);
await repoWithClient.unlinkAllLocations(100, mockLogger);
expect(mockClient.query).toHaveBeenCalledWith(
'DELETE FROM public.flyer_locations WHERE flyer_id = $1',
[100],
);
});
it('should work correctly within a transaction for getLocationIdsByFlyerId', async () => {
const mockClient = {
query: vi.fn().mockResolvedValue({
rows: [{ store_location_id: 10 }, { store_location_id: 20 }],
}),
};
const repoWithClient = new FlyerLocationRepository(mockClient as any);
const result = await repoWithClient.getLocationIdsByFlyerId(100, mockLogger);
expect(result).toEqual([10, 20]);
expect(mockClient.query).toHaveBeenCalledWith(
'SELECT store_location_id FROM public.flyer_locations WHERE flyer_id = $1',
[100],
);
});
it('should work correctly within a transaction for getFlyerLocationsByFlyerId', async () => {
const mockFlyerLocations: FlyerLocation[] = [
{
flyer_id: 100,
store_location_id: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: mockFlyerLocations }),
};
const repoWithClient = new FlyerLocationRepository(mockClient as any);
const result = await repoWithClient.getFlyerLocationsByFlyerId(100, mockLogger);
expect(result).toEqual(mockFlyerLocations);
expect(mockClient.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyer_locations WHERE flyer_id = $1',
[100],
);
});
});
describe('Edge cases', () => {
it('should handle very large arrays of store location IDs', async () => {
// Create an array of 100 location IDs
const largeArray = Array.from({ length: 100 }, (_, i) => i + 1);
mockDb.query.mockResolvedValue({ rows: [], rowCount: 100 });
await flyerLocationRepo.linkFlyerToLocations(100, largeArray, mockLogger);
expect(mockDb.query).toHaveBeenCalledTimes(1);
// Check that all IDs are in the parameters
const queryParams = mockDb.query.mock.calls[0][1] as number[];
expect(queryParams).toHaveLength(101); // flyerId + 100 locationIds
expect(queryParams[0]).toBe(100); // flyerId
expect(queryParams[1]).toBe(1); // first locationId
expect(queryParams[100]).toBe(100); // last locationId
});
it('should handle negative IDs (database will validate constraints)', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
// The repository passes these through; the database would reject them
await flyerLocationRepo.linkFlyerToLocations(-1, [-1, -2], mockLogger);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), [-1, -1, -2]);
});
it('should handle zero as flyer ID', async () => {
mockDb.query.mockResolvedValue({ rows: [], rowCount: 1 });
await flyerLocationRepo.linkFlyerToLocations(0, [1], mockLogger);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), [0, 1]);
expect(mockLogger.info).toHaveBeenCalledWith(
{ flyerId: 0, locationCount: 1 },
'Linked flyer to store locations',
);
});
});
describe('PostgreSQL error code handling', () => {
it('should throw UniqueConstraintError for code 23505', async () => {
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as Error & { code: string }).code = '23505';
mockDb.query.mockRejectedValue(dbError);
const { UniqueConstraintError } = await import('./errors.db');
await expect(flyerLocationRepo.linkFlyerToLocations(100, [1], mockLogger)).rejects.toThrow(
UniqueConstraintError,
);
});
it('should throw ForeignKeyConstraintError for code 23503', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as Error & { code: string }).code = '23503';
mockDb.query.mockRejectedValue(dbError);
const { ForeignKeyConstraintError } = await import('./errors.db');
await expect(
flyerLocationRepo.linkFlyerToAllStoreLocations(100, 1, mockLogger),
).rejects.toThrow(ForeignKeyConstraintError);
});
it('should throw NotNullConstraintError for code 23502', async () => {
const dbError = new Error('null value in column violates not-null constraint');
(dbError as Error & { code: string }).code = '23502';
mockDb.query.mockRejectedValue(dbError);
const { NotNullConstraintError } = await import('./errors.db');
await expect(flyerLocationRepo.unlinkLocation(100, 1, mockLogger)).rejects.toThrow(
NotNullConstraintError,
);
});
it('should throw CheckConstraintError for code 23514', async () => {
const dbError = new Error('violates check constraint');
(dbError as Error & { code: string }).code = '23514';
mockDb.query.mockRejectedValue(dbError);
const { CheckConstraintError } = await import('./errors.db');
await expect(flyerLocationRepo.getLocationIdsByFlyerId(100, mockLogger)).rejects.toThrow(
CheckConstraintError,
);
});
it('should throw InvalidTextRepresentationError for code 22P02', async () => {
const dbError = new Error('invalid input syntax for type integer');
(dbError as Error & { code: string }).code = '22P02';
mockDb.query.mockRejectedValue(dbError);
const { InvalidTextRepresentationError } = await import('./errors.db');
await expect(flyerLocationRepo.getFlyerLocationsByFlyerId(100, mockLogger)).rejects.toThrow(
InvalidTextRepresentationError,
);
});
it('should throw NumericValueOutOfRangeError for code 22003', async () => {
const dbError = new Error('integer out of range');
(dbError as Error & { code: string }).code = '22003';
mockDb.query.mockRejectedValue(dbError);
const { NumericValueOutOfRangeError } = await import('./errors.db');
await expect(flyerLocationRepo.unlinkAllLocations(100, mockLogger)).rejects.toThrow(
NumericValueOutOfRangeError,
);
});
});
});

View File

@@ -0,0 +1,697 @@
// src/services/db/store.db.test.ts
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import type { PoolClient } from 'pg';
// Mock the logger to prevent stderr noise during tests
vi.mock('../logger.server', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./store.db');
import { StoreRepository } from './store.db';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { NotFoundError, UniqueConstraintError } from './errors.db';
import type { Store } from '../../types';
describe('Store DB Service', () => {
let storeRepo: StoreRepository;
beforeEach(() => {
vi.clearAllMocks();
mockPoolInstance.query.mockReset();
// Instantiate the repository with the mock pool for each test
storeRepo = new StoreRepository(mockPoolInstance as unknown as PoolClient);
});
describe('createStore', () => {
it('should execute the correct INSERT query and return the new store ID', async () => {
// Arrange
const newStoreId = 42;
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: newStoreId }] });
// Act
const result = await storeRepo.createStore(
'Test Store',
mockLogger,
'https://logo.url',
'user-123',
);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.stores'),
['Test Store', 'https://logo.url', 'user-123'],
);
expect(result).toBe(newStoreId);
});
it('should handle null values for optional parameters', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: 1 }] });
// Act
await storeRepo.createStore('Basic Store', mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.stores'),
['Basic Store', null, null],
);
});
it('should handle explicit null values for logoUrl and createdBy', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: 1 }] });
// Act
await storeRepo.createStore('Another Store', mockLogger, null, null);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.stores'),
['Another Store', null, null],
);
});
it('should throw UniqueConstraintError if store name already exists', async () => {
// Arrange
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as Error & { code: string }).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeRepo.createStore('Duplicate Store', mockLogger)).rejects.toThrow(
UniqueConstraintError,
);
await expect(storeRepo.createStore('Duplicate Store', mockLogger)).rejects.toThrow(
'A store with the name "Duplicate Store" already exists.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
code: '23505',
name: 'Duplicate Store',
}),
'Database error in createStore',
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeRepo.createStore('Fail Store', mockLogger)).rejects.toThrow(
'Failed to create store.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
name: 'Fail Store',
}),
'Database error in createStore',
);
});
});
describe('getStoreById', () => {
const mockStore: Store = {
store_id: 1,
name: 'Test Store',
logo_url: 'https://logo.url',
created_by: 'user-123',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
it('should execute the correct SELECT query and return the store', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [mockStore], rowCount: 1 });
// Act
const result = await storeRepo.getStoreById(1, mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM public.stores WHERE store_id = $1'),
[1],
);
expect(result).toEqual(mockStore);
});
it('should throw NotFoundError if store is not found', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
// Act & Assert
await expect(storeRepo.getStoreById(999, mockLogger)).rejects.toThrow(NotFoundError);
await expect(storeRepo.getStoreById(999, mockLogger)).rejects.toThrow(
'Store with ID 999 not found.',
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeRepo.getStoreById(1, mockLogger)).rejects.toThrow(
'Failed to retrieve store.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
storeId: 1,
}),
'Database error in getStoreById',
);
});
});
describe('getAllStores', () => {
it('should execute the correct SELECT query and return all stores', async () => {
// Arrange
const mockStores: Store[] = [
{
store_id: 1,
name: 'Alpha Store',
logo_url: null,
created_by: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
store_id: 2,
name: 'Beta Store',
logo_url: 'https://beta.logo.url',
created_by: 'user-456',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
mockPoolInstance.query.mockResolvedValue({ rows: mockStores });
// Act
const result = await storeRepo.getAllStores(mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM public.stores ORDER BY name ASC'),
);
expect(result).toEqual(mockStores);
expect(result).toHaveLength(2);
});
it('should return an empty array if no stores exist', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [] });
// Act
const result = await storeRepo.getAllStores(mockLogger);
// Assert
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeRepo.getAllStores(mockLogger)).rejects.toThrow(
'Failed to retrieve stores.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
}),
'Database error in getAllStores',
);
});
});
describe('updateStore', () => {
it('should execute the correct UPDATE query when updating name only', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [] });
// Act
await storeRepo.updateStore(1, { name: 'Updated Store' }, mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.stores'),
expect.arrayContaining(['Updated Store', 1]),
);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('name = $1'),
expect.any(Array),
);
});
it('should execute the correct UPDATE query when updating logo_url only', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [] });
// Act
await storeRepo.updateStore(1, { logo_url: 'https://new.logo.url' }, mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('logo_url = $1'),
expect.arrayContaining(['https://new.logo.url', 1]),
);
});
it('should execute the correct UPDATE query when updating both name and logo_url', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [] });
// Act
await storeRepo.updateStore(
1,
{ name: 'Updated Store', logo_url: 'https://new.logo.url' },
mockLogger,
);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.stores'),
['Updated Store', 'https://new.logo.url', 1],
);
});
it('should allow setting logo_url to null', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [] });
// Act
await storeRepo.updateStore(1, { logo_url: null }, mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('logo_url = $1'),
[null, 1],
);
});
it('should throw a generic error if no fields are provided for update', async () => {
// Note: The 'No fields provided for update' error is caught by handleDbError
// and wrapped in the default message 'Failed to update store.'
// Act & Assert
await expect(storeRepo.updateStore(1, {}, mockLogger)).rejects.toThrow(
'Failed to update store.',
);
// Verify the original error was logged
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
storeId: 1,
updates: {},
}),
'Database error in updateStore',
);
});
it('should throw NotFoundError if store is not found', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
// Act & Assert
await expect(
storeRepo.updateStore(999, { name: 'Updated Store' }, mockLogger),
).rejects.toThrow(NotFoundError);
await expect(
storeRepo.updateStore(999, { name: 'Updated Store' }, mockLogger),
).rejects.toThrow('Store with ID 999 not found.');
});
it('should throw UniqueConstraintError if updated name already exists', async () => {
// Arrange
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as Error & { code: string }).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(
storeRepo.updateStore(1, { name: 'Duplicate Store' }, mockLogger),
).rejects.toThrow(UniqueConstraintError);
await expect(
storeRepo.updateStore(1, { name: 'Duplicate Store' }, mockLogger),
).rejects.toThrow('A store with the name "Duplicate Store" already exists.');
});
it('should not set custom uniqueMessage when only logo_url is updated', async () => {
// Arrange
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as Error & { code: string }).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
// When only logo_url is updated, uniqueMessage should be undefined (use default)
await expect(
storeRepo.updateStore(1, { logo_url: 'https://duplicate.url' }, mockLogger),
).rejects.toThrow(UniqueConstraintError);
// The default UniqueConstraintError message should be used
await expect(
storeRepo.updateStore(1, { logo_url: 'https://duplicate.url' }, mockLogger),
).rejects.toThrow('The record already exists.');
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeRepo.updateStore(1, { name: 'Fail Store' }, mockLogger)).rejects.toThrow(
'Failed to update store.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
storeId: 1,
updates: { name: 'Fail Store' },
}),
'Database error in updateStore',
);
});
});
describe('deleteStore', () => {
it('should execute the correct DELETE query', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [] });
// Act
await storeRepo.deleteStore(1, mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM public.stores WHERE store_id = $1'),
[1],
);
});
it('should throw NotFoundError if store is not found', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
// Act & Assert
await expect(storeRepo.deleteStore(999, mockLogger)).rejects.toThrow(NotFoundError);
await expect(storeRepo.deleteStore(999, mockLogger)).rejects.toThrow(
'Store with ID 999 not found.',
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeRepo.deleteStore(1, mockLogger)).rejects.toThrow('Failed to delete store.');
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
storeId: 1,
}),
'Database error in deleteStore',
);
});
});
describe('searchStoresByName', () => {
const mockStores: Store[] = [
{
store_id: 1,
name: 'Fresh Mart',
logo_url: null,
created_by: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
store_id: 2,
name: 'Fresh Foods',
logo_url: null,
created_by: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
it('should execute the correct SELECT query with ILIKE pattern', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: mockStores });
// Act
const result = await storeRepo.searchStoresByName('Fresh', mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE name ILIKE $1'),
['%Fresh%', 10], // Default limit is 10
);
expect(result).toEqual(mockStores);
});
it('should use the provided limit parameter', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [mockStores[0]] });
// Act
await storeRepo.searchStoresByName('Fresh', mockLogger, 5);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('LIMIT $2'), [
'%Fresh%',
5,
]);
});
it('should return an empty array if no stores match', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [] });
// Act
const result = await storeRepo.searchStoresByName('Nonexistent', mockLogger);
// Assert
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should handle special characters in search query', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [] });
// Act
await storeRepo.searchStoresByName("Store's %Special_Name", mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE name ILIKE $1'),
["%Store's %Special_Name%", 10],
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeRepo.searchStoresByName('Fail', mockLogger)).rejects.toThrow(
'Failed to search stores.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
query: 'Fail',
limit: 10,
}),
'Database error in searchStoresByName',
);
});
});
describe('transaction support (PoolClient injection)', () => {
it('should use the injected PoolClient for queries', async () => {
// Arrange - Create a separate mock client to simulate transaction usage
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: [{ store_id: 100 }] }),
};
const transactionRepo = new StoreRepository(mockClient as unknown as PoolClient);
// Act
const result = await transactionRepo.createStore('Transaction Store', mockLogger);
// Assert - Verify the injected client was used, not the global pool
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.stores'),
['Transaction Store', null, null],
);
expect(mockPoolInstance.query).not.toHaveBeenCalled();
expect(result).toBe(100);
});
it('should allow different operations within the same PoolClient', async () => {
// Arrange
const mockClient = {
query: vi.fn(),
};
(mockClient.query as Mock)
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // createStore
.mockResolvedValueOnce({
rows: [
{
store_id: 1,
name: 'Test',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
rowCount: 1,
}); // getStoreById
const transactionRepo = new StoreRepository(mockClient as unknown as PoolClient);
// Act
await transactionRepo.createStore('Test', mockLogger);
await transactionRepo.getStoreById(1, mockLogger);
// Assert
expect(mockClient.query).toHaveBeenCalledTimes(2);
});
});
describe('constructor with default pool', () => {
it('should use getPool() when no db is provided', async () => {
// Note: This test verifies the default constructor behavior.
// In the test environment, getPool() returns the mocked pool from tests-setup-unit.ts.
// We can verify this by creating a repository without parameters and checking if queries work.
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [] });
// Act - Create repository without explicit db parameter
const defaultRepo = new StoreRepository();
await defaultRepo.getAllStores(mockLogger);
// Assert - The mock pool should have been called since getPool() returns our mock
expect(mockPoolInstance.query).toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle empty string for store name', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: 1 }] });
// Act
await storeRepo.createStore('', mockLogger);
// Assert - Empty string should be passed as-is
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.stores'),
['', null, null],
);
});
it('should handle very long store names', async () => {
// Arrange
const longName = 'A'.repeat(1000);
mockPoolInstance.query.mockResolvedValue({ rows: [{ store_id: 1 }] });
// Act
await storeRepo.createStore(longName, mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.stores'),
[longName, null, null],
);
});
it('should handle numeric store IDs as expected', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({
rows: [
{
store_id: 0,
name: 'Zero ID Store',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
rowCount: 1,
});
// Act
const result = await storeRepo.getStoreById(0, mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE store_id = $1'),
[0],
);
expect(result.store_id).toBe(0);
});
it('should handle search with empty query string', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [] });
// Act
const result = await storeRepo.searchStoresByName('', mockLogger);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE name ILIKE $1'),
['%%', 10], // Empty string wrapped with wildcards
);
expect(result).toEqual([]);
});
it('should handle negative limit in search (database will handle validation)', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [] });
// Act
await storeRepo.searchStoresByName('Test', mockLogger, -1);
// Assert - The value is passed through; the database handles validation
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('LIMIT $2'), [
'%Test%',
-1,
]);
});
it('should handle zero limit in search', async () => {
// Arrange
mockPoolInstance.query.mockResolvedValue({ rows: [] });
// Act
await storeRepo.searchStoresByName('Test', mockLogger, 0);
// Assert
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('LIMIT $2'), [
'%Test%',
0,
]);
});
});
});

View File

@@ -0,0 +1,629 @@
// src/services/db/storeLocation.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StoreLocationRepository } from './storeLocation.db';
import type { StoreLocationWithAddress, StoreWithLocations } from './storeLocation.db';
import { UniqueConstraintError, NotFoundError, ForeignKeyConstraintError } from './errors.db';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./storeLocation.db');
// Mock the logger to prevent console output during tests
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
import { logger as mockLogger } from '../logger.server';
describe('StoreLocation DB Service', () => {
let storeLocationRepo: StoreLocationRepository;
const mockDb = {
query: vi.fn(),
};
// Test fixtures
const mockAddress = {
address_id: 1,
address_line_1: '123 Main St',
address_line_2: null,
city: 'Anytown',
province_state: 'CA',
postal_code: '12345',
country: 'USA',
latitude: 37.7749,
longitude: -122.4194,
created_at: '2025-01-01T00:00:00.000Z',
updated_at: '2025-01-01T00:00:00.000Z',
};
const mockStoreLocation: StoreLocationWithAddress = {
store_location_id: 1,
store_id: 1,
address_id: 1,
created_at: '2025-01-01T00:00:00.000Z',
updated_at: '2025-01-01T00:00:00.000Z',
address: mockAddress,
};
const mockStore = {
store_id: 1,
name: 'Test Store',
logo_url: 'https://example.com/logo.png',
created_by: 'user-123',
created_at: '2025-01-01T00:00:00.000Z',
updated_at: '2025-01-01T00:00:00.000Z',
};
const mockStoreWithLocations: StoreWithLocations = {
...mockStore,
locations: [mockStoreLocation],
};
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.mockReset();
storeLocationRepo = new StoreLocationRepository(mockDb);
});
describe('createStoreLocation', () => {
it('should create a store location and return the store_location_id', async () => {
// Arrange
mockDb.query.mockResolvedValue({
rows: [{ store_location_id: 1 }],
rowCount: 1,
});
// Act
const result = await storeLocationRepo.createStoreLocation(1, 1, mockLogger);
// Assert
expect(result).toBe(1);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.store_locations'),
[1, 1],
);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('RETURNING store_location_id'),
expect.any(Array),
);
});
it('should throw UniqueConstraintError when store-address link already exists', async () => {
// Arrange
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as any).code = '23505';
(dbError as any).constraint = 'store_locations_store_id_address_id_key';
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.createStoreLocation(1, 1, mockLogger)).rejects.toThrow(
UniqueConstraintError,
);
await expect(storeLocationRepo.createStoreLocation(1, 1, mockLogger)).rejects.toThrow(
'This store is already linked to this address.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
storeId: 1,
addressId: 1,
code: '23505',
}),
'Database error in createStoreLocation',
);
});
it('should throw ForeignKeyConstraintError when store does not exist', async () => {
// Arrange
const dbError = new Error('insert or update on table violates foreign key constraint');
(dbError as any).code = '23503';
(dbError as any).constraint = 'store_locations_store_id_fkey';
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.createStoreLocation(999, 1, mockLogger)).rejects.toThrow(
ForeignKeyConstraintError,
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
storeId: 999,
addressId: 1,
code: '23503',
}),
'Database error in createStoreLocation',
);
});
it('should throw ForeignKeyConstraintError when address does not exist', async () => {
// Arrange
const dbError = new Error('insert or update on table violates foreign key constraint');
(dbError as any).code = '23503';
(dbError as any).constraint = 'store_locations_address_id_fkey';
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.createStoreLocation(1, 999, mockLogger)).rejects.toThrow(
ForeignKeyConstraintError,
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
storeId: 1,
addressId: 999,
code: '23503',
}),
'Database error in createStoreLocation',
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.createStoreLocation(1, 1, mockLogger)).rejects.toThrow(
'Failed to create store location link.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, storeId: 1, addressId: 1 },
'Database error in createStoreLocation',
);
});
});
describe('getLocationsByStoreId', () => {
it('should return locations for a store with address data', async () => {
// Arrange
mockDb.query.mockResolvedValue({
rows: [mockStoreLocation],
rowCount: 1,
});
// Act
const result = await storeLocationRepo.getLocationsByStoreId(1, mockLogger);
// Assert
expect(result).toEqual([mockStoreLocation]);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('FROM public.store_locations sl'),
[1],
);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INNER JOIN public.addresses a'),
expect.any(Array),
);
});
it('should return multiple locations when store has multiple addresses', async () => {
// Arrange
const secondLocation: StoreLocationWithAddress = {
store_location_id: 2,
store_id: 1,
address_id: 2,
created_at: '2025-01-02T00:00:00.000Z',
updated_at: '2025-01-02T00:00:00.000Z',
address: {
...mockAddress,
address_id: 2,
address_line_1: '456 Other St',
},
};
mockDb.query.mockResolvedValue({
rows: [mockStoreLocation, secondLocation],
rowCount: 2,
});
// Act
const result = await storeLocationRepo.getLocationsByStoreId(1, mockLogger);
// Assert
expect(result).toHaveLength(2);
expect(result[0].store_location_id).toBe(1);
expect(result[1].store_location_id).toBe(2);
});
it('should return an empty array when store has no locations', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
// Act
const result = await storeLocationRepo.getLocationsByStoreId(999, mockLogger);
// Assert
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.getLocationsByStoreId(1, mockLogger)).rejects.toThrow(
'Failed to retrieve store locations.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, storeId: 1 },
'Database error in getLocationsByStoreId',
);
});
});
describe('getStoreWithLocations', () => {
it('should return a store with all its locations', async () => {
// Arrange
mockDb.query.mockResolvedValue({
rows: [mockStoreWithLocations],
rowCount: 1,
});
// Act
const result = await storeLocationRepo.getStoreWithLocations(1, mockLogger);
// Assert
expect(result).toEqual(mockStoreWithLocations);
expect(result.store_id).toBe(1);
expect(result.locations).toHaveLength(1);
expect(result.locations[0].address.address_line_1).toBe('123 Main St');
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('FROM public.stores s'),
[1],
);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('LEFT JOIN public.store_locations sl'),
expect.any(Array),
);
});
it('should return a store with empty locations array when store has no locations', async () => {
// Arrange
const storeWithNoLocations: StoreWithLocations = {
...mockStore,
locations: [],
};
mockDb.query.mockResolvedValue({
rows: [storeWithNoLocations],
rowCount: 1,
});
// Act
const result = await storeLocationRepo.getStoreWithLocations(1, mockLogger);
// Assert
expect(result.locations).toEqual([]);
});
it('should throw NotFoundError when store does not exist', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
// Act & Assert
await expect(storeLocationRepo.getStoreWithLocations(999, mockLogger)).rejects.toThrow(
NotFoundError,
);
await expect(storeLocationRepo.getStoreWithLocations(999, mockLogger)).rejects.toThrow(
'Store with ID 999 not found.',
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.getStoreWithLocations(1, mockLogger)).rejects.toThrow(
'Failed to retrieve store with locations.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, storeId: 1 },
'Database error in getStoreWithLocations',
);
});
});
describe('getAllStoresWithLocations', () => {
it('should return all stores with their locations', async () => {
// Arrange
const secondStore: StoreWithLocations = {
store_id: 2,
name: 'Another Store',
logo_url: null,
created_by: null,
created_at: '2025-01-02T00:00:00.000Z',
updated_at: '2025-01-02T00:00:00.000Z',
locations: [],
};
mockDb.query.mockResolvedValue({
rows: [mockStoreWithLocations, secondStore],
rowCount: 2,
});
// Act
const result = await storeLocationRepo.getAllStoresWithLocations(mockLogger);
// Assert
expect(result).toHaveLength(2);
expect(result[0].store_id).toBe(1);
expect(result[0].locations).toHaveLength(1);
expect(result[1].store_id).toBe(2);
expect(result[1].locations).toEqual([]);
});
it('should return an empty array when no stores exist', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
// Act
const result = await storeLocationRepo.getAllStoresWithLocations(mockLogger);
// Assert
expect(result).toEqual([]);
});
it('should return stores ordered by name ASC', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
// Act
await storeLocationRepo.getAllStoresWithLocations(mockLogger);
// Assert
expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('ORDER BY s.name ASC'));
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.getAllStoresWithLocations(mockLogger)).rejects.toThrow(
'Failed to retrieve stores with locations.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError },
'Database error in getAllStoresWithLocations',
);
});
});
describe('deleteStoreLocation', () => {
it('should delete a store location successfully', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rowCount: 1 });
// Act
await storeLocationRepo.deleteStoreLocation(1, mockLogger);
// Assert
expect(mockDb.query).toHaveBeenCalledWith(
'DELETE FROM public.store_locations WHERE store_location_id = $1',
[1],
);
});
it('should throw NotFoundError when store location does not exist', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rowCount: 0 });
// Act & Assert
await expect(storeLocationRepo.deleteStoreLocation(999, mockLogger)).rejects.toThrow(
NotFoundError,
);
await expect(storeLocationRepo.deleteStoreLocation(999, mockLogger)).rejects.toThrow(
'Store location with ID 999 not found.',
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.deleteStoreLocation(1, mockLogger)).rejects.toThrow(
'Failed to delete store location.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, storeLocationId: 1 },
'Database error in deleteStoreLocation',
);
});
});
describe('updateStoreLocation', () => {
it('should update a store location to point to a new address', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rowCount: 1 });
// Act
await storeLocationRepo.updateStoreLocation(1, 2, mockLogger);
// Assert
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.store_locations'),
[2, 1],
);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('SET address_id = $1'),
expect.any(Array),
);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE store_location_id = $2'),
expect.any(Array),
);
});
it('should throw NotFoundError when store location does not exist', async () => {
// Arrange
mockDb.query.mockResolvedValue({ rowCount: 0 });
// Act & Assert
await expect(storeLocationRepo.updateStoreLocation(999, 1, mockLogger)).rejects.toThrow(
NotFoundError,
);
await expect(storeLocationRepo.updateStoreLocation(999, 1, mockLogger)).rejects.toThrow(
'Store location with ID 999 not found.',
);
});
it('should throw ForeignKeyConstraintError when new address does not exist', async () => {
// Arrange
const dbError = new Error('insert or update on table violates foreign key constraint');
(dbError as any).code = '23503';
(dbError as any).constraint = 'store_locations_address_id_fkey';
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.updateStoreLocation(1, 999, mockLogger)).rejects.toThrow(
ForeignKeyConstraintError,
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: dbError,
storeLocationId: 1,
newAddressId: 999,
code: '23503',
}),
'Database error in updateStoreLocation',
);
});
it('should throw a generic error if the database query fails', async () => {
// Arrange
const dbError = new Error('DB Connection Error');
mockDb.query.mockRejectedValue(dbError);
// Act & Assert
await expect(storeLocationRepo.updateStoreLocation(1, 2, mockLogger)).rejects.toThrow(
'Failed to update store location.',
);
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, storeLocationId: 1, newAddressId: 2 },
'Database error in updateStoreLocation',
);
});
});
describe('Transaction Support', () => {
it('should use provided pool client for transaction when passed to constructor', async () => {
// Arrange
const mockPoolClient = {
query: vi.fn().mockResolvedValue({
rows: [{ store_location_id: 1 }],
rowCount: 1,
}),
};
const transactionRepo = new StoreLocationRepository(mockPoolClient);
// Act
await transactionRepo.createStoreLocation(1, 1, mockLogger);
// Assert
expect(mockPoolClient.query).toHaveBeenCalled();
expect(mockDb.query).not.toHaveBeenCalled();
});
it('should allow multiple operations within the same transaction context', async () => {
// Arrange
const mockPoolClient = {
query: vi.fn(),
};
mockPoolClient.query
.mockResolvedValueOnce({ rows: [{ store_location_id: 1 }], rowCount: 1 }) // create
.mockResolvedValueOnce({ rows: [mockStoreLocation], rowCount: 1 }) // get
.mockResolvedValueOnce({ rowCount: 1 }); // delete
const transactionRepo = new StoreLocationRepository(mockPoolClient);
// Act - simulating a transaction with multiple operations
const locationId = await transactionRepo.createStoreLocation(1, 1, mockLogger);
const locations = await transactionRepo.getLocationsByStoreId(1, mockLogger);
await transactionRepo.deleteStoreLocation(locationId, mockLogger);
// Assert
expect(mockPoolClient.query).toHaveBeenCalledTimes(3);
expect(locationId).toBe(1);
expect(locations).toEqual([mockStoreLocation]);
});
});
describe('Edge Cases', () => {
it('should handle store with many locations', async () => {
// Arrange
const manyLocations = Array.from({ length: 100 }, (_, i) => ({
...mockStoreLocation,
store_location_id: i + 1,
address: {
...mockAddress,
address_id: i + 1,
address_line_1: `${i + 1} Test St`,
},
}));
mockDb.query.mockResolvedValue({
rows: [{ ...mockStore, locations: manyLocations }],
rowCount: 1,
});
// Act
const result = await storeLocationRepo.getStoreWithLocations(1, mockLogger);
// Assert
expect(result.locations).toHaveLength(100);
});
it('should handle null values in optional address fields', async () => {
// Arrange
const locationWithNullFields: StoreLocationWithAddress = {
...mockStoreLocation,
address: {
...mockAddress,
address_line_2: null,
latitude: null,
longitude: null,
},
};
mockDb.query.mockResolvedValue({
rows: [locationWithNullFields],
rowCount: 1,
});
// Act
const result = await storeLocationRepo.getLocationsByStoreId(1, mockLogger);
// Assert
expect(result[0].address.address_line_2).toBeNull();
expect(result[0].address.latitude).toBeNull();
expect(result[0].address.longitude).toBeNull();
});
it('should handle zero as a valid store_location_id (edge case for invalid ID)', async () => {
// Note: While 0 is technically invalid in PostgreSQL serial columns,
// the database should reject it, not the application layer
mockDb.query.mockResolvedValue({ rowCount: 0 });
await expect(storeLocationRepo.deleteStoreLocation(0, mockLogger)).rejects.toThrow(
NotFoundError,
);
});
it('should handle large store IDs near integer limits', async () => {
// Arrange
const largeId = 2147483647; // Max 32-bit signed integer
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
// Act
const result = await storeLocationRepo.getLocationsByStoreId(largeId, mockLogger);
// Assert
expect(result).toEqual([]);
expect(mockDb.query).toHaveBeenCalledWith(expect.any(String), [largeId]);
});
});
});

View File

@@ -331,7 +331,9 @@ describe('FlyerAiProcessor', () => {
expect(result.needsReview).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ qualityIssues: ['Missing both valid_from and valid_to dates'] }),
expect.objectContaining({
qualityIssues: ['Missing validity dates (valid_from or valid_to)'],
}),
expect.stringContaining('AI response has quality issues.'),
);
});
@@ -358,10 +360,10 @@ describe('FlyerAiProcessor', () => {
qualityIssues: [
'Missing store name',
'No items were extracted',
'Missing both valid_from and valid_to dates',
'Missing validity dates (valid_from or valid_to)',
],
},
'AI response has quality issues. Flagging for review. Issues: Missing store name, No items were extracted, Missing both valid_from and valid_to dates',
'AI response has quality issues. Flagging for review. Issues: Missing store name, No items were extracted, Missing validity dates (valid_from or valid_to)',
);
});
});

View File

@@ -99,8 +99,8 @@ export class FlyerAiProcessor {
}
// 4. Check for flyer validity dates.
if (!valid_from && !valid_to) {
qualityIssues.push('Missing both valid_from and valid_to dates');
if (!valid_from || !valid_to) {
qualityIssues.push('Missing validity dates (valid_from or valid_to)');
}
const needsReview = qualityIssues.length > 0;

View File

@@ -1,4 +1,18 @@
// src/services/receiptService.server.test.ts
/**
* @file Comprehensive unit tests for the Receipt Service
* Tests receipt processing logic, OCR extraction, text parsing, and error handling.
*
* Coverage includes:
* - Receipt CRUD operations (create, read, delete)
* - Receipt processing workflow (OCR extraction, store detection, item parsing)
* - Receipt item management
* - Processing logs and statistics
* - Store pattern management
* - Job processing via BullMQ
* - Edge cases and error handling
* - Internal parsing logic patterns
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Logger } from 'pino';
import type { Job } from 'bullmq';
@@ -11,6 +25,8 @@ import type {
ReceiptProcessingStatus,
OcrProvider,
ReceiptProcessingLogRecord,
ReceiptScan,
ReceiptItem,
} from '../types/expiry';
// Mock dependencies
@@ -1035,4 +1051,391 @@ describe('receiptService.server', () => {
expect(textLines).toEqual(['MILK 2% - $4.99', 'BREAD - $2.99']);
});
});
// ==========================================================================
// ADDITIONAL EDGE CASES AND ERROR HANDLING TESTS
// ==========================================================================
describe('Receipt Processing Edge Cases', () => {
it('should handle empty receipt items array from AI', async () => {
const mockReceipt = createMockReceiptScan({ receipt_id: 10 });
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
const result = await processReceipt(10, mockLogger);
expect(result.items).toHaveLength(0);
expect(result.receipt.status).toBe('completed');
});
it('should handle receipts with discount items (negative prices)', async () => {
const mockReceipt = createMockReceiptScan({ receipt_id: 11 });
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
// Mock items including a discount
const mockItems: ReceiptItem[] = [
createMockReceiptItem({ receipt_item_id: 1, price_paid_cents: 500 }),
createMockReceiptItem({
receipt_item_id: 2,
raw_item_description: 'COUPON DISCOUNT',
price_paid_cents: -100,
is_discount: true,
}),
];
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce(mockItems);
const result = await processReceipt(11, mockLogger);
expect(result.items).toHaveLength(2);
expect(result.items.find((i) => i.is_discount)).toBeTruthy();
});
it('should handle receipts with maximum retry count', async () => {
const _mockReceipt = createMockReceiptScan({
receipt_id: 12,
retry_count: 2, // Already at 2 retries (one more allowed before MAX_RETRY_ATTEMPTS=3)
});
vi.mocked(receiptRepo.updateReceipt).mockRejectedValue(new Error('Persistent failure'));
vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(3); // Now at max
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
await expect(processReceipt(12, mockLogger)).rejects.toThrow('Persistent failure');
expect(receiptRepo.incrementRetryCount).toHaveBeenCalledWith(12, expect.any(Object));
});
});
describe('Receipt Item Validation', () => {
it('should handle items with zero quantity', () => {
// Test the quantity extraction logic
const item = {
raw_item_description: 'FREE SAMPLE',
quantity: 0,
price_paid_cents: 0,
};
expect(item.quantity).toBe(0);
expect(item.price_paid_cents).toBe(0);
});
it('should handle items with very long descriptions', () => {
const longDescription =
'ORGANIC FREE-RANGE CHICKEN BREAST BONELESS SKINLESS FAMILY PACK 3.5LB AVG';
expect(longDescription.length).toBeGreaterThan(50);
// The service should handle long descriptions without truncation at this level
expect(longDescription).toContain('CHICKEN');
});
it('should handle items with special characters in description', () => {
const specialChars = ['BREAD & BUTTER', "ANNIE'S MAC", 'ITEM #1234', 'PRICE: $5.99'];
specialChars.forEach((desc) => {
// These should all be valid descriptions
expect(desc.length).toBeGreaterThan(0);
});
});
it('should calculate discount status correctly for negative prices', () => {
const isDiscount = (priceCents: number) => priceCents < 0;
expect(isDiscount(-100)).toBe(true);
expect(isDiscount(-1)).toBe(true);
expect(isDiscount(0)).toBe(false);
expect(isDiscount(100)).toBe(false);
});
});
describe('Store Detection Logic', () => {
it('should skip store detection when store_location_id is already set', async () => {
const mockReceipt = createMockReceiptScan({
receipt_id: 13,
store_location_id: 5, // Already has a store
});
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
await processReceipt(13, mockLogger);
// Store detection should not be attempted
expect(receiptRepo.detectStoreFromText).not.toHaveBeenCalled();
});
it('should handle store detection returning null', async () => {
const mockReceipt = createMockReceiptScan({
receipt_id: 14,
store_location_id: null,
});
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
const result = await processReceipt(14, mockLogger);
expect(result.receipt.status).toBe('completed');
// Should log that no store was found
expect(receiptRepo.logProcessingStep).toHaveBeenCalledWith(
14,
'store_detection',
'completed',
expect.any(Object),
expect.objectContaining({
outputData: expect.objectContaining({ storeId: null }),
}),
);
});
});
describe('Processing Log Consistency', () => {
it('should log all processing steps in correct order', async () => {
const mockReceipt = createMockReceiptScan({ receipt_id: 15 });
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
await processReceipt(15, mockLogger);
// Verify processing steps are logged
const logCalls = vi.mocked(receiptRepo.logProcessingStep).mock.calls;
const loggedSteps = logCalls.map((call) => call[1]);
expect(loggedSteps).toContain('ocr_extraction');
expect(loggedSteps).toContain('text_parsing');
expect(loggedSteps).toContain('item_extraction');
expect(loggedSteps).toContain('finalization');
});
it('should log duration for finalization step', async () => {
const mockReceipt = createMockReceiptScan({ receipt_id: 16 });
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
await processReceipt(16, mockLogger);
// Find the finalization log call
const logCalls = vi.mocked(receiptRepo.logProcessingStep).mock.calls;
const finalizationCall = logCalls.find((call) => call[1] === 'finalization');
expect(finalizationCall).toBeDefined();
expect(finalizationCall?.[4]).toHaveProperty('durationMs');
});
});
describe('Job Processing Context Propagation', () => {
it('should propagate request ID from job metadata', async () => {
const mockReceipt = createMockReceiptScan();
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
const mockJob = {
id: 'job-ctx-1',
data: {
receiptId: 1,
userId: 'user-1',
meta: { requestId: 'req-context-test', userId: 'user-1', origin: 'api' },
},
attemptsMade: 0,
} as Job<ReceiptJobData>;
await processReceiptJob(mockJob, mockLogger);
// Verify logger.child was called with context
expect(mockLogger.child).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'req-context-test',
}),
);
});
it('should handle missing job metadata gracefully', async () => {
const mockReceipt = createMockReceiptScan();
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
const mockJob = {
id: 'job-no-meta',
data: {
receiptId: 1,
userId: 'user-1',
// No meta property
},
attemptsMade: 0,
} as Job<ReceiptJobData>;
// Should not throw
const result = await processReceiptJob(mockJob, mockLogger);
expect(result.success).toBe(true);
});
});
describe('Currency and Monetary Value Handling', () => {
it('should correctly convert cents to dollars for display', () => {
const formatCents = (cents: number) => `$${(cents / 100).toFixed(2)}`;
expect(formatCents(499)).toBe('$4.99');
expect(formatCents(100)).toBe('$1.00');
expect(formatCents(1)).toBe('$0.01');
expect(formatCents(0)).toBe('$0.00');
expect(formatCents(12345)).toBe('$123.45');
});
it('should handle negative values for discounts', () => {
const formatCents = (cents: number) => {
const absValue = Math.abs(cents);
const prefix = cents < 0 ? '-' : '';
return `${prefix}$${(absValue / 100).toFixed(2)}`;
};
expect(formatCents(-200)).toBe('-$2.00');
expect(formatCents(-1)).toBe('-$0.01');
});
});
describe('Date Parsing Edge Cases', () => {
it('should handle various date formats', () => {
const parseDate = (dateStr: string): Date | null => {
// MM/DD/YYYY pattern
const mdyPattern = /(\d{1,2})\/(\d{1,2})\/(\d{2,4})/;
// YYYY-MM-DD pattern
const isoPattern = /(\d{4})-(\d{2})-(\d{2})/;
let match = dateStr.match(isoPattern);
if (match) {
return new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]));
}
match = dateStr.match(mdyPattern);
if (match) {
let year = parseInt(match[3]);
if (year < 100) year += 2000;
return new Date(year, parseInt(match[1]) - 1, parseInt(match[2]));
}
return null;
};
// ISO format
const isoDate = parseDate('2024-01-15');
expect(isoDate?.getFullYear()).toBe(2024);
expect(isoDate?.getMonth()).toBe(0); // January
expect(isoDate?.getDate()).toBe(15);
// US format with 4-digit year
const usDate = parseDate('01/15/2024');
expect(usDate?.getFullYear()).toBe(2024);
// US format with 2-digit year
const shortYear = parseDate('1/5/24');
expect(shortYear?.getFullYear()).toBe(2024);
// Invalid format
expect(parseDate('invalid')).toBeNull();
});
});
});
// ==========================================================================
// HELPER FUNCTIONS FOR ADDITIONAL TESTS
// ==========================================================================
/**
* Creates a mock receipt scan for testing.
*/
function createMockReceiptScan(overrides: Partial<ReceiptScan> = {}): ReceiptScan {
return {
receipt_id: 1,
user_id: 'user-1',
store_location_id: null,
receipt_image_url: '/uploads/receipt.jpg',
transaction_date: null,
total_amount_cents: null,
status: 'pending' as ReceiptStatus,
raw_text: null,
store_confidence: null,
ocr_provider: null,
error_details: null,
retry_count: 0,
ocr_confidence: null,
currency: 'CAD',
created_at: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
...overrides,
};
}
/**
* Creates a mock receipt item for testing.
*/
function createMockReceiptItem(overrides: Partial<ReceiptItem> = {}): ReceiptItem {
return {
receipt_item_id: 1,
receipt_id: 1,
raw_item_description: 'TEST ITEM',
quantity: 1,
price_paid_cents: 299,
master_item_id: null,
product_id: null,
status: 'unmatched' as ReceiptItemStatus,
line_number: 1,
match_confidence: null,
is_discount: false,
unit_price_cents: null,
unit_type: null,
added_to_pantry: false,
pantry_item_id: null,
upc_code: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}

View File

@@ -1,8 +1,28 @@
// src/services/sentry.client.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* Comprehensive unit tests for the Sentry client initialization and configuration.
*
* IMPORTANT: This test file needs to unmock the global sentry.client and config mocks
* from tests-setup-unit.ts to test the actual implementation.
*
* Tests cover:
* - Sentry.init() configuration with correct parameters
* - Environment-based configuration (development vs production)
* - DSN configuration handling
* - Integration setup (breadcrumbs)
* - Error handling when Sentry is not configured
* - All exported functions (captureException, captureMessage, setUser, addBreadcrumb)
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
// Remove global mocks to test actual implementation
vi.unmock('../../services/sentry.client');
vi.unmock('../../config');
vi.unmock('./sentry.client');
vi.unmock('../config');
// Use vi.hoisted to define mocks that need to be available before vi.mock runs
const { mockSentry, mockLogger } = vi.hoisted(() => ({
const { mockSentry, mockLogger, mockConfig } = vi.hoisted(() => ({
mockSentry: {
init: vi.fn(),
captureException: vi.fn(() => 'mock-event-id'),
@@ -10,7 +30,7 @@ const { mockSentry, mockLogger } = vi.hoisted(() => ({
setContext: vi.fn(),
setUser: vi.fn(),
addBreadcrumb: vi.fn(),
breadcrumbsIntegration: vi.fn(() => ({})),
breadcrumbsIntegration: vi.fn(() => ({ name: 'Breadcrumbs' })),
ErrorBoundary: vi.fn(),
},
mockLogger: {
@@ -19,6 +39,28 @@ const { mockSentry, mockLogger } = vi.hoisted(() => ({
warn: vi.fn(),
error: vi.fn(),
},
mockConfig: {
app: {
version: '1.0.0-test',
commitMessage: 'test commit',
commitUrl: 'https://example.com',
},
google: {
mapsEmbedApiKey: '',
},
sentry: {
dsn: '',
environment: 'test',
debug: false,
enabled: false,
},
featureFlags: {
newDashboard: false,
betaRecipes: false,
experimentalAi: false,
debugMode: false,
},
},
}));
vi.mock('@sentry/react', () => mockSentry);
@@ -28,40 +70,69 @@ vi.mock('./logger.client', () => ({
default: mockLogger,
}));
// Mock the config module with our mutable config object
vi.mock('../config', () => ({
default: mockConfig,
}));
describe('sentry.client', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
// Reset config to default disabled state
mockConfig.sentry = {
dsn: '',
environment: 'test',
debug: false,
enabled: false,
};
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('with Sentry disabled (default test environment)', () => {
// The test environment has Sentry disabled by default (VITE_SENTRY_DSN not set)
// Import the module fresh for each test
beforeEach(() => {
vi.resetModules();
});
it('should have isSentryConfigured as false in test environment', async () => {
// ============================================================================
// Section 1: Tests with Sentry DISABLED (default test environment)
// ============================================================================
describe('with Sentry disabled (no DSN configured)', () => {
it('should have isSentryConfigured as false when DSN is empty', async () => {
const { isSentryConfigured } = await import('./sentry.client');
expect(isSentryConfigured).toBe(false);
});
it('should not initialize Sentry when not configured', async () => {
const { initSentry, isSentryConfigured } = await import('./sentry.client');
it('should have isSentryConfigured as false when enabled is false', async () => {
mockConfig.sentry = {
dsn: 'https://test@sentry.io/123',
environment: 'test',
debug: false,
enabled: false,
};
vi.resetModules();
const { isSentryConfigured } = await import('./sentry.client');
expect(isSentryConfigured).toBe(false);
});
it('should not call Sentry.init when not configured', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
// When Sentry is not configured, Sentry.init should NOT be called
if (!isSentryConfigured) {
expect(mockSentry.init).not.toHaveBeenCalled();
}
expect(mockSentry.init).not.toHaveBeenCalled();
});
it('should return undefined from captureException when not configured', async () => {
it('should log info message about Sentry being disabled', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockLogger.info).toHaveBeenCalledWith(
'[Sentry] Error tracking disabled (VITE_SENTRY_DSN not configured)',
);
});
it('should return undefined from captureException', async () => {
const { captureException } = await import('./sentry.client');
const result = captureException(new Error('test error'));
@@ -70,7 +141,17 @@ describe('sentry.client', () => {
expect(mockSentry.captureException).not.toHaveBeenCalled();
});
it('should return undefined from captureMessage when not configured', async () => {
it('should return undefined from captureException even with context', async () => {
const { captureException } = await import('./sentry.client');
const result = captureException(new Error('test error'), { userId: '123' });
expect(result).toBeUndefined();
expect(mockSentry.setContext).not.toHaveBeenCalled();
expect(mockSentry.captureException).not.toHaveBeenCalled();
});
it('should return undefined from captureMessage', async () => {
const { captureMessage } = await import('./sentry.client');
const result = captureMessage('test message');
@@ -79,7 +160,16 @@ describe('sentry.client', () => {
expect(mockSentry.captureMessage).not.toHaveBeenCalled();
});
it('should not set user when not configured', async () => {
it('should return undefined from captureMessage with custom level', async () => {
const { captureMessage } = await import('./sentry.client');
const result = captureMessage('warning message', 'warning');
expect(result).toBeUndefined();
expect(mockSentry.captureMessage).not.toHaveBeenCalled();
});
it('should not call setUser when not configured', async () => {
const { setUser } = await import('./sentry.client');
setUser({ id: '123', email: 'test@example.com' });
@@ -87,7 +177,15 @@ describe('sentry.client', () => {
expect(mockSentry.setUser).not.toHaveBeenCalled();
});
it('should not add breadcrumb when not configured', async () => {
it('should not call setUser with null when not configured', async () => {
const { setUser } = await import('./sentry.client');
setUser(null);
expect(mockSentry.setUser).not.toHaveBeenCalled();
});
it('should not call addBreadcrumb when not configured', async () => {
const { addBreadcrumb } = await import('./sentry.client');
addBreadcrumb({ message: 'test breadcrumb', category: 'test' });
@@ -96,40 +194,374 @@ describe('sentry.client', () => {
});
});
describe('Sentry re-export', () => {
it('should re-export Sentry object', async () => {
const { Sentry } = await import('./sentry.client');
// ============================================================================
// Section 2: Tests with Sentry ENABLED
// ============================================================================
describe('with Sentry enabled (DSN configured)', () => {
beforeEach(async () => {
mockConfig.sentry = {
dsn: 'https://abc123@bugsink.projectium.com/1',
environment: 'development',
debug: true,
enabled: true,
};
vi.resetModules();
// Clear mocks after resetModules to ensure clean state
vi.clearAllMocks();
});
expect(Sentry).toBeDefined();
expect(Sentry.init).toBeDefined();
expect(Sentry.captureException).toBeDefined();
it('should have isSentryConfigured as true when DSN is set and enabled', async () => {
const { isSentryConfigured } = await import('./sentry.client');
expect(isSentryConfigured).toBe(true);
});
it('should call Sentry.init with correct DSN', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockSentry.init).toHaveBeenCalledTimes(1);
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.dsn).toBe('https://abc123@bugsink.projectium.com/1');
});
it('should call Sentry.init with correct environment', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.environment).toBe('development');
});
it('should call Sentry.init with debug flag', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.debug).toBe(true);
});
it('should call Sentry.init with tracesSampleRate of 0', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.tracesSampleRate).toBe(0);
});
it('should configure breadcrumbs integration with all options', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockSentry.breadcrumbsIntegration).toHaveBeenCalledWith({
console: true,
dom: true,
fetch: true,
history: true,
xhr: true,
});
});
it('should include breadcrumbs integration in init config', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.integrations).toBeDefined();
expect(initConfig.integrations).toHaveLength(1);
expect(initConfig.integrations[0]).toEqual({ name: 'Breadcrumbs' });
});
it('should configure beforeSend filter function', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(typeof initConfig.beforeSend).toBe('function');
});
it('should log success message with environment', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockLogger.info).toHaveBeenCalledWith(
'[Sentry] Error tracking initialized (development)',
);
});
describe('captureException', () => {
it('should return event ID when capturing exception', async () => {
const { captureException } = await import('./sentry.client');
const result = captureException(new Error('test error'));
expect(result).toBe('mock-event-id');
});
it('should call Sentry.captureException with the error', async () => {
const { captureException } = await import('./sentry.client');
const error = new Error('test error');
captureException(error);
expect(mockSentry.captureException).toHaveBeenCalledWith(error);
});
it('should set context when provided', async () => {
const { captureException } = await import('./sentry.client');
const error = new Error('test error');
const context = { userId: '123', action: 'upload' };
captureException(error, context);
expect(mockSentry.setContext).toHaveBeenCalledWith('additional', context);
expect(mockSentry.captureException).toHaveBeenCalledWith(error);
});
it('should not set context when not provided', async () => {
const { captureException } = await import('./sentry.client');
captureException(new Error('test error'));
expect(mockSentry.setContext).not.toHaveBeenCalled();
});
it('should handle complex context objects', async () => {
const { captureException } = await import('./sentry.client');
const context = {
userId: '123',
nested: { data: [1, 2, 3] },
timestamp: new Date().toISOString(),
};
captureException(new Error('test'), context);
expect(mockSentry.setContext).toHaveBeenCalledWith('additional', context);
});
});
describe('captureMessage', () => {
it('should return message ID when capturing message', async () => {
const { captureMessage } = await import('./sentry.client');
const result = captureMessage('test message');
expect(result).toBe('mock-message-id');
});
it('should call Sentry.captureMessage with message and default level', async () => {
const { captureMessage } = await import('./sentry.client');
captureMessage('test message');
expect(mockSentry.captureMessage).toHaveBeenCalledWith('test message', 'info');
});
it('should call Sentry.captureMessage with warning level', async () => {
const { captureMessage } = await import('./sentry.client');
captureMessage('warning message', 'warning');
expect(mockSentry.captureMessage).toHaveBeenCalledWith('warning message', 'warning');
});
it('should call Sentry.captureMessage with error level', async () => {
const { captureMessage } = await import('./sentry.client');
captureMessage('error message', 'error');
expect(mockSentry.captureMessage).toHaveBeenCalledWith('error message', 'error');
});
it('should call Sentry.captureMessage with debug level', async () => {
const { captureMessage } = await import('./sentry.client');
captureMessage('debug message', 'debug');
expect(mockSentry.captureMessage).toHaveBeenCalledWith('debug message', 'debug');
});
it('should call Sentry.captureMessage with fatal level', async () => {
const { captureMessage } = await import('./sentry.client');
captureMessage('fatal message', 'fatal');
expect(mockSentry.captureMessage).toHaveBeenCalledWith('fatal message', 'fatal');
});
});
describe('setUser', () => {
it('should call Sentry.setUser with user object', async () => {
const { setUser } = await import('./sentry.client');
const user = { id: '123', email: 'test@example.com', username: 'testuser' };
setUser(user);
expect(mockSentry.setUser).toHaveBeenCalledWith(user);
});
it('should call Sentry.setUser with minimal user object (id only)', async () => {
const { setUser } = await import('./sentry.client');
const user = { id: '456' };
setUser(user);
expect(mockSentry.setUser).toHaveBeenCalledWith(user);
});
it('should call Sentry.setUser with null to clear user', async () => {
const { setUser } = await import('./sentry.client');
setUser(null);
expect(mockSentry.setUser).toHaveBeenCalledWith(null);
});
it('should call Sentry.setUser with user having optional fields', async () => {
const { setUser } = await import('./sentry.client');
const user = { id: '789', email: 'user@example.com' };
setUser(user);
expect(mockSentry.setUser).toHaveBeenCalledWith(user);
});
});
describe('addBreadcrumb', () => {
it('should call Sentry.addBreadcrumb with breadcrumb object', async () => {
const { addBreadcrumb } = await import('./sentry.client');
const breadcrumb = { message: 'User clicked button', category: 'ui' };
addBreadcrumb(breadcrumb);
expect(mockSentry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb);
});
it('should handle breadcrumb with all optional fields', async () => {
const { addBreadcrumb } = await import('./sentry.client');
const breadcrumb = {
message: 'API request completed',
category: 'http',
type: 'http',
level: 'info' as const,
data: { url: '/api/flyers', status: 200 },
timestamp: Date.now() / 1000,
};
addBreadcrumb(breadcrumb);
expect(mockSentry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb);
});
it('should handle navigation breadcrumb', async () => {
const { addBreadcrumb } = await import('./sentry.client');
const breadcrumb = {
message: 'Navigation',
category: 'navigation',
data: { from: '/home', to: '/flyers' },
};
addBreadcrumb(breadcrumb);
expect(mockSentry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb);
});
});
});
describe('initSentry beforeSend filter logic', () => {
// Test the beforeSend filter function logic in isolation
// This tests the filter that's passed to Sentry.init
// ============================================================================
// Section 3: Environment-specific configuration tests
// ============================================================================
describe('environment-specific configuration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should filter out browser extension errors', () => {
// Simulate the beforeSend logic from the implementation
const filterExtensionErrors = (event: {
exception?: {
values?: Array<{
stacktrace?: {
frames?: Array<{ filename?: string }>;
};
}>;
};
}) => {
if (
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
frame.filename?.includes('extension://'),
)
) {
return null;
}
return event;
it('should use production environment', async () => {
mockConfig.sentry = {
dsn: 'https://prod@bugsink.projectium.com/1',
environment: 'production',
debug: false,
enabled: true,
};
vi.resetModules();
vi.clearAllMocks();
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockSentry.init).toHaveBeenCalledTimes(1);
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.environment).toBe('production');
expect(initConfig.debug).toBe(false);
});
it('should use development environment with debug enabled', async () => {
mockConfig.sentry = {
dsn: 'https://dev@bugsink.projectium.com/2',
environment: 'development',
debug: true,
enabled: true,
};
vi.resetModules();
vi.clearAllMocks();
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockSentry.init).toHaveBeenCalledTimes(1);
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.environment).toBe('development');
expect(initConfig.debug).toBe(true);
});
it('should use staging environment', async () => {
mockConfig.sentry = {
dsn: 'https://staging@bugsink.projectium.com/3',
environment: 'staging',
debug: false,
enabled: true,
};
vi.resetModules();
vi.clearAllMocks();
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockSentry.init).toHaveBeenCalledTimes(1);
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
expect(initConfig.environment).toBe('staging');
});
});
// ============================================================================
// Section 4: beforeSend filter logic tests
// ============================================================================
describe('beforeSend filter logic', () => {
beforeEach(async () => {
mockConfig.sentry = {
dsn: 'https://test@bugsink.projectium.com/1',
environment: 'test',
debug: false,
enabled: true,
};
vi.resetModules();
vi.clearAllMocks();
});
it('should filter out Chrome extension errors', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
expect(mockSentry.init).toHaveBeenCalledTimes(1);
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
const extensionError = {
exception: {
@@ -143,158 +575,274 @@ describe('sentry.client', () => {
},
};
expect(filterExtensionErrors(extensionError)).toBeNull();
expect(beforeSend(extensionError)).toBeNull();
});
it('should allow normal errors through', () => {
const filterExtensionErrors = (event: {
exception?: {
values?: Array<{
stacktrace?: {
frames?: Array<{ filename?: string }>;
};
}>;
};
}) => {
if (
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
frame.filename?.includes('extension://'),
)
) {
return null;
}
return event;
};
it('should filter out Firefox extension errors', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const normalError = {
exception: {
values: [
{
stacktrace: {
frames: [{ filename: '/app/src/index.js' }],
},
},
],
},
};
expect(filterExtensionErrors(normalError)).toBe(normalError);
});
it('should handle events without exception property', () => {
const filterExtensionErrors = (event: {
exception?: {
values?: Array<{
stacktrace?: {
frames?: Array<{ filename?: string }>;
};
}>;
};
}) => {
if (
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
frame.filename?.includes('extension://'),
)
) {
return null;
}
return event;
};
const eventWithoutException = { message: 'test' };
expect(filterExtensionErrors(eventWithoutException as any)).toBe(eventWithoutException);
});
it('should handle firefox extension URLs', () => {
const filterExtensionErrors = (event: {
exception?: {
values?: Array<{
stacktrace?: {
frames?: Array<{ filename?: string }>;
};
}>;
};
}) => {
if (
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
frame.filename?.includes('extension://'),
)
) {
return null;
}
return event;
};
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
const firefoxExtensionError = {
exception: {
values: [
{
stacktrace: {
frames: [{ filename: 'moz-extension://abc123/script.js' }],
frames: [{ filename: 'moz-extension://def456/background.js' }],
},
},
],
},
};
expect(filterExtensionErrors(firefoxExtensionError)).toBeNull();
expect(beforeSend(firefoxExtensionError)).toBeNull();
});
it('should allow normal application errors through', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
const normalError = {
exception: {
values: [
{
stacktrace: {
frames: [
{ filename: '/app/src/index.js' },
{ filename: '/app/src/components/Button.js' },
],
},
},
],
},
};
expect(beforeSend(normalError)).toBe(normalError);
});
it('should handle events without exception property', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
const messageEvent = { message: 'test message', level: 'info' };
expect(beforeSend(messageEvent)).toBe(messageEvent);
});
it('should handle events without stacktrace', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
const errorWithoutStacktrace = {
exception: {
values: [{ value: 'Error message' }],
},
};
expect(beforeSend(errorWithoutStacktrace)).toBe(errorWithoutStacktrace);
});
it('should handle events with empty frames array', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
const errorWithEmptyFrames = {
exception: {
values: [
{
stacktrace: {
frames: [],
},
},
],
},
};
expect(beforeSend(errorWithEmptyFrames)).toBe(errorWithEmptyFrames);
});
it('should handle mixed frames with extension in later frame', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
// Note: Implementation uses .some() so it filters if ANY frame is extension
const mixedError = {
exception: {
values: [
{
stacktrace: {
frames: [
{ filename: '/app/src/index.js' },
{ filename: 'chrome-extension://abc123/inject.js' },
],
},
},
],
},
};
// The implementation uses .some() so it will filter if ANY frame is extension
expect(beforeSend(mixedError)).toBeNull();
});
it('should handle frames with undefined filename', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
const beforeSend = initConfig.beforeSend;
const errorWithUndefinedFilename = {
exception: {
values: [
{
stacktrace: {
frames: [{ filename: undefined }, { filename: '/app/src/index.js' }],
},
},
],
},
};
expect(beforeSend(errorWithUndefinedFilename)).toBe(errorWithUndefinedFilename);
});
});
describe('isSentryConfigured logic', () => {
// Test the logic that determines if Sentry is configured
// This mirrors the implementation: !!config.sentry.dsn && config.sentry.enabled
// ============================================================================
// Section 5: Sentry re-export tests
// ============================================================================
describe('Sentry re-export', () => {
it('should re-export Sentry object', async () => {
const { Sentry } = await import('./sentry.client');
it('should return false when DSN is empty', () => {
const dsn = '';
const enabled = true;
const result = !!dsn && enabled;
expect(result).toBe(false);
expect(Sentry).toBeDefined();
});
it('should return false when enabled is false', () => {
const dsn = 'https://test@sentry.io/123';
const enabled = false;
const result = !!dsn && enabled;
expect(result).toBe(false);
it('should have init method on re-exported Sentry', async () => {
const { Sentry } = await import('./sentry.client');
expect(Sentry.init).toBeDefined();
expect(typeof Sentry.init).toBe('function');
});
it('should return true when DSN is set and enabled is true', () => {
const dsn = 'https://test@sentry.io/123';
const enabled = true;
const result = !!dsn && enabled;
expect(result).toBe(true);
it('should have captureException method on re-exported Sentry', async () => {
const { Sentry } = await import('./sentry.client');
expect(Sentry.captureException).toBeDefined();
expect(typeof Sentry.captureException).toBe('function');
});
it('should return false when DSN is undefined', () => {
const dsn = undefined;
const enabled = true;
const result = !!dsn && enabled;
expect(result).toBe(false);
it('should have captureMessage method on re-exported Sentry', async () => {
const { Sentry } = await import('./sentry.client');
expect(Sentry.captureMessage).toBeDefined();
expect(typeof Sentry.captureMessage).toBe('function');
});
it('should have setUser method on re-exported Sentry', async () => {
const { Sentry } = await import('./sentry.client');
expect(Sentry.setUser).toBeDefined();
expect(typeof Sentry.setUser).toBe('function');
});
it('should have addBreadcrumb method on re-exported Sentry', async () => {
const { Sentry } = await import('./sentry.client');
expect(Sentry.addBreadcrumb).toBeDefined();
expect(typeof Sentry.addBreadcrumb).toBe('function');
});
});
describe('captureException logic', () => {
it('should set context before capturing when context is provided', () => {
// This tests the conditional context setting logic
const context = { userId: '123' };
const shouldSetContext = !!context;
expect(shouldSetContext).toBe(true);
// ============================================================================
// Section 6: isSentryConfigured logic edge cases
// ============================================================================
describe('isSentryConfigured logic edge cases', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should not set context when not provided', () => {
const context = undefined;
const shouldSetContext = !!context;
expect(shouldSetContext).toBe(false);
it('should return false when DSN is null-ish', async () => {
mockConfig.sentry = {
dsn: null as unknown as string,
environment: 'test',
debug: false,
enabled: true,
};
vi.resetModules();
const { isSentryConfigured } = await import('./sentry.client');
expect(isSentryConfigured).toBe(false);
});
it('should return false when DSN is undefined', async () => {
mockConfig.sentry = {
dsn: undefined as unknown as string,
environment: 'test',
debug: false,
enabled: true,
};
vi.resetModules();
const { isSentryConfigured } = await import('./sentry.client');
expect(isSentryConfigured).toBe(false);
});
it('should return true only when both DSN is truthy AND enabled is true', async () => {
mockConfig.sentry = {
dsn: 'https://valid@sentry.io/123',
environment: 'test',
debug: false,
enabled: true,
};
vi.resetModules();
const { isSentryConfigured } = await import('./sentry.client');
expect(isSentryConfigured).toBe(true);
});
});
describe('captureMessage default level', () => {
it('should default to info level', () => {
// Test the default parameter behavior
const defaultLevel = 'info';
expect(defaultLevel).toBe('info');
// ============================================================================
// Section 7: Multiple initialization calls
// ============================================================================
describe('multiple initialization handling', () => {
beforeEach(async () => {
mockConfig.sentry = {
dsn: 'https://test@bugsink.projectium.com/1',
environment: 'test',
debug: false,
enabled: true,
};
vi.resetModules();
vi.clearAllMocks();
});
it('should call Sentry.init each time initSentry is called', async () => {
const { initSentry } = await import('./sentry.client');
initSentry();
initSentry();
// The implementation does not guard against multiple calls
// This test documents current behavior - Sentry.init may be called multiple times
expect(mockSentry.init).toHaveBeenCalledTimes(2);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -296,6 +296,23 @@ describe('E2E Receipt Processing Journey', () => {
expect(reprocessResponse.status).toBe(200);
expect(reprocessResponse.body.data.message).toContain('reprocessing');
// Wait for the reprocess job to complete before deleting
await poll(
async () => {
const statusResponse = await getRequest()
.get(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}`)
.set('Authorization', `Bearer ${authToken}`);
return statusResponse.status === 200
? statusResponse.body
: { data: { receipt: { status: 'pending' } } };
},
(result) => {
const status = result.data?.receipt?.status;
return status === 'completed' || status === 'failed';
},
{ timeout: 15000, interval: 1000, description: 'receipt reprocessing' },
);
// Step 17: Delete the failed receipt
const deleteResponse = await getRequest()
.delete(`/api/v1/receipts/${receipt2Result.rows[0].receipt_id}`)

View File

@@ -107,7 +107,10 @@ export const MockMainLayout: React.FC<Partial<MainLayoutProps>> = () => (
<Outlet />
</div>
);
export const MockHomePage: React.FC<Partial<HomePageProps>> = ({ selectedFlyer, onOpenCorrectionTool }) => (
export const MockHomePage: React.FC<Partial<HomePageProps>> = ({
selectedFlyer,
onOpenCorrectionTool,
}) => (
<div data-testid="home-page-mock" data-selected-flyer-id={selectedFlyer?.flyer_id}>
Mock Home Page
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
@@ -192,3 +195,59 @@ export const MockBookOpenIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props)
);
export const MockFooter: React.FC = () => <footer data-testid="footer-mock">Mock Footer</footer>;
// --- FlyerList and FlyerUploader Mocks ---
import type { Flyer, UserProfile } from '../../types';
interface MockFlyerListProps {
flyers: Flyer[];
onFlyerSelect: (flyer: Flyer) => void;
selectedFlyerId: number | null;
profile: UserProfile | null;
}
export const MockFlyerList: React.FC<MockFlyerListProps> = ({
flyers,
onFlyerSelect,
selectedFlyerId,
profile,
}) => (
<div
data-testid="flyer-list"
data-selected-id={selectedFlyerId ?? 'none'}
data-flyer-count={flyers.length}
data-profile-role={profile?.role ?? 'none'}
>
<h3>Mock Flyer List</h3>
{flyers.length === 0 ? (
<p data-testid="no-flyers-message">No flyers available</p>
) : (
<ul>
{flyers.map((flyer) => (
<li
key={flyer.flyer_id}
data-testid={`flyer-item-${flyer.flyer_id}`}
data-selected={selectedFlyerId === flyer.flyer_id}
>
<button onClick={() => onFlyerSelect(flyer)}>
{flyer.store?.name ?? 'Unknown Store'} - {flyer.item_count} items
</button>
</li>
))}
</ul>
)}
</div>
);
interface MockFlyerUploaderProps {
onProcessingComplete: () => void;
}
export const MockFlyerUploader: React.FC<MockFlyerUploaderProps> = ({ onProcessingComplete }) => (
<div data-testid="flyer-uploader">
<h3>Mock Flyer Uploader</h3>
<button data-testid="mock-upload-complete-btn" onClick={onProcessingComplete}>
Simulate Upload Complete
</button>
</div>
);