Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc8f6a42b | ||
| 4fd5e900af | |||
|
|
39ab773b82 | ||
| 75406cd924 | |||
|
|
8fb0a57f02 | ||
| c78323275b |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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\"",
|
||||
|
||||
126
src/hooks/queries/useBestSalePricesQuery.test.tsx
Normal file
126
src/hooks/queries/useBestSalePricesQuery.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
175
src/hooks/queries/useBrandsQuery.test.tsx
Normal file
175
src/hooks/queries/useBrandsQuery.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
235
src/hooks/queries/useFlyerItemCountQuery.test.tsx
Normal file
235
src/hooks/queries/useFlyerItemCountQuery.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
310
src/hooks/queries/useFlyerItemsForFlyersQuery.test.tsx
Normal file
310
src/hooks/queries/useFlyerItemsForFlyersQuery.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
193
src/hooks/queries/useLeaderboardQuery.test.tsx
Normal file
193
src/hooks/queries/useLeaderboardQuery.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
216
src/hooks/queries/usePriceHistoryQuery.test.tsx
Normal file
216
src/hooks/queries/usePriceHistoryQuery.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
413
src/hooks/queries/useUserProfileDataQuery.test.tsx
Normal file
413
src/hooks/queries/useUserProfileDataQuery.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
161
src/hooks/useUserProfileData.test.ts
Normal file
161
src/hooks/useUserProfileData.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
304
src/hooks/useWebSocket.test.ts
Normal file
304
src/hooks/useWebSocket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
478
src/pages/DealsPage.test.tsx
Normal file
478
src/pages/DealsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
564
src/pages/FlyersPage.test.tsx
Normal file
564
src/pages/FlyersPage.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
469
src/pages/ShoppingListsPage.test.tsx
Normal file
469
src/pages/ShoppingListsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
78
src/pages/admin/AdminStoresPage.test.tsx
Normal file
78
src/pages/admin/AdminStoresPage.test.tsx
Normal 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 ←
|
||||
});
|
||||
});
|
||||
672
src/pages/admin/components/AdminStoreManager.test.tsx
Normal file
672
src/pages/admin/components/AdminStoreManager.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
893
src/pages/admin/components/StoreForm.test.tsx
Normal file
893
src/pages/admin/components/StoreForm.test.tsx
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
506
src/routes/category.routes.test.ts
Normal file
506
src/routes/category.routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
579
src/services/db/category.db.test.ts
Normal file
579
src/services/db/category.db.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
572
src/services/db/flyerLocation.db.test.ts
Normal file
572
src/services/db/flyerLocation.db.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
697
src/services/db/store.db.test.ts
Normal file
697
src/services/db/store.db.test.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
629
src/services/db/storeLocation.db.test.ts
Normal file
629
src/services/db/storeLocation.db.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user