Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39ab773b82 | ||
| 75406cd924 | |||
|
|
8fb0a57f02 | ||
| c78323275b |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.12.22",
|
"version": "0.12.24",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.12.22",
|
"version": "0.12.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.14.2",
|
"@bull-board/api": "^6.14.2",
|
||||||
"@bull-board/express": "^6.14.2",
|
"@bull-board/express": "^6.14.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.22",
|
"version": "0.12.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"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,
|
createMockRecipeComment,
|
||||||
createMockUserProfile,
|
createMockUserProfile,
|
||||||
} from '../tests/utils/mockFactories';
|
} 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';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
|
|
||||||
// 1. Mock the Service Layer directly.
|
// 1. Mock the Service Layer directly.
|
||||||
@@ -18,6 +18,8 @@ vi.mock('../services/db/index.db', () => ({
|
|||||||
getRecipeById: vi.fn(),
|
getRecipeById: vi.fn(),
|
||||||
findRecipesByIngredientAndTag: vi.fn(),
|
findRecipesByIngredientAndTag: vi.fn(),
|
||||||
getRecipeComments: 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' })];
|
const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })];
|
||||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
|
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.status).toBe(200);
|
||||||
expect(response.body.data).toEqual(mockRecipes);
|
expect(response.body.data).toEqual(mockRecipes);
|
||||||
@@ -268,7 +272,9 @@ describe('Recipe Routes (/api/v1/recipes)', () => {
|
|||||||
const mockSuggestion = 'Chicken and Rice Casserole...';
|
const mockSuggestion = 'Chicken and Rice Casserole...';
|
||||||
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
|
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.status).toBe(200);
|
||||||
expect(response.body.data).toEqual({ suggestion: mockSuggestion });
|
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);
|
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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { AddressRepository } from './address.db';
|
import { AddressRepository } from './address.db';
|
||||||
import type { Address } from '../../types';
|
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
|
// Mock dependencies
|
||||||
vi.mock('../logger.server', () => ({
|
vi.mock('../logger.server', () => ({
|
||||||
@@ -16,6 +27,23 @@ describe('Address DB Service', () => {
|
|||||||
query: vi.fn(),
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockDb.query.mockReset();
|
mockDb.query.mockReset();
|
||||||
@@ -24,16 +52,7 @@ describe('Address DB Service', () => {
|
|||||||
|
|
||||||
describe('getAddressById', () => {
|
describe('getAddressById', () => {
|
||||||
it('should return an address if found', async () => {
|
it('should return an address if found', async () => {
|
||||||
const mockAddress: Address = {
|
const mockAddress = createMockAddress();
|
||||||
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(),
|
|
||||||
};
|
|
||||||
mockDb.query.mockResolvedValue({ rows: [mockAddress], rowCount: 1 });
|
mockDb.query.mockResolvedValue({ rows: [mockAddress], rowCount: 1 });
|
||||||
|
|
||||||
const result = await addressRepo.getAddressById(1, mockLogger);
|
const result = await addressRepo.getAddressById(1, mockLogger);
|
||||||
@@ -65,6 +84,51 @@ describe('Address DB Service', () => {
|
|||||||
'Database error in getAddressById',
|
'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', () => {
|
describe('upsertAddress', () => {
|
||||||
@@ -131,5 +195,400 @@ describe('Address DB Service', () => {
|
|||||||
'Database error in upsertAddress',
|
'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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
// src/services/receiptService.server.test.ts
|
// 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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
@@ -11,6 +25,8 @@ import type {
|
|||||||
ReceiptProcessingStatus,
|
ReceiptProcessingStatus,
|
||||||
OcrProvider,
|
OcrProvider,
|
||||||
ReceiptProcessingLogRecord,
|
ReceiptProcessingLogRecord,
|
||||||
|
ReceiptScan,
|
||||||
|
ReceiptItem,
|
||||||
} from '../types/expiry';
|
} from '../types/expiry';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
@@ -1035,4 +1051,391 @@ describe('receiptService.server', () => {
|
|||||||
expect(textLines).toEqual(['MILK 2% - $4.99', 'BREAD - $2.99']);
|
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
|
// 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
|
// 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: {
|
mockSentry: {
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
captureException: vi.fn(() => 'mock-event-id'),
|
captureException: vi.fn(() => 'mock-event-id'),
|
||||||
@@ -10,7 +30,7 @@ const { mockSentry, mockLogger } = vi.hoisted(() => ({
|
|||||||
setContext: vi.fn(),
|
setContext: vi.fn(),
|
||||||
setUser: vi.fn(),
|
setUser: vi.fn(),
|
||||||
addBreadcrumb: vi.fn(),
|
addBreadcrumb: vi.fn(),
|
||||||
breadcrumbsIntegration: vi.fn(() => ({})),
|
breadcrumbsIntegration: vi.fn(() => ({ name: 'Breadcrumbs' })),
|
||||||
ErrorBoundary: vi.fn(),
|
ErrorBoundary: vi.fn(),
|
||||||
},
|
},
|
||||||
mockLogger: {
|
mockLogger: {
|
||||||
@@ -19,6 +39,28 @@ const { mockSentry, mockLogger } = vi.hoisted(() => ({
|
|||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: 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);
|
vi.mock('@sentry/react', () => mockSentry);
|
||||||
@@ -28,40 +70,69 @@ vi.mock('./logger.client', () => ({
|
|||||||
default: mockLogger,
|
default: mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the config module with our mutable config object
|
||||||
|
vi.mock('../config', () => ({
|
||||||
|
default: mockConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('sentry.client', () => {
|
describe('sentry.client', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
// Reset config to default disabled state
|
||||||
|
mockConfig.sentry = {
|
||||||
|
dsn: '',
|
||||||
|
environment: 'test',
|
||||||
|
debug: false,
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with Sentry disabled (default test environment)', () => {
|
// ============================================================================
|
||||||
// The test environment has Sentry disabled by default (VITE_SENTRY_DSN not set)
|
// Section 1: Tests with Sentry DISABLED (default test environment)
|
||||||
// Import the module fresh for each test
|
// ============================================================================
|
||||||
|
describe('with Sentry disabled (no DSN configured)', () => {
|
||||||
beforeEach(() => {
|
it('should have isSentryConfigured as false when DSN is empty', async () => {
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have isSentryConfigured as false in test environment', async () => {
|
|
||||||
const { isSentryConfigured } = await import('./sentry.client');
|
const { isSentryConfigured } = await import('./sentry.client');
|
||||||
expect(isSentryConfigured).toBe(false);
|
expect(isSentryConfigured).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not initialize Sentry when not configured', async () => {
|
it('should have isSentryConfigured as false when enabled is false', async () => {
|
||||||
const { initSentry, isSentryConfigured } = await import('./sentry.client');
|
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();
|
initSentry();
|
||||||
|
|
||||||
// When Sentry is not configured, Sentry.init should NOT be called
|
expect(mockSentry.init).not.toHaveBeenCalled();
|
||||||
if (!isSentryConfigured) {
|
|
||||||
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 { captureException } = await import('./sentry.client');
|
||||||
|
|
||||||
const result = captureException(new Error('test error'));
|
const result = captureException(new Error('test error'));
|
||||||
@@ -70,7 +141,17 @@ describe('sentry.client', () => {
|
|||||||
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
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 { captureMessage } = await import('./sentry.client');
|
||||||
|
|
||||||
const result = captureMessage('test message');
|
const result = captureMessage('test message');
|
||||||
@@ -79,7 +160,16 @@ describe('sentry.client', () => {
|
|||||||
expect(mockSentry.captureMessage).not.toHaveBeenCalled();
|
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');
|
const { setUser } = await import('./sentry.client');
|
||||||
|
|
||||||
setUser({ id: '123', email: 'test@example.com' });
|
setUser({ id: '123', email: 'test@example.com' });
|
||||||
@@ -87,7 +177,15 @@ describe('sentry.client', () => {
|
|||||||
expect(mockSentry.setUser).not.toHaveBeenCalled();
|
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');
|
const { addBreadcrumb } = await import('./sentry.client');
|
||||||
|
|
||||||
addBreadcrumb({ message: 'test breadcrumb', category: 'test' });
|
addBreadcrumb({ message: 'test breadcrumb', category: 'test' });
|
||||||
@@ -96,40 +194,374 @@ describe('sentry.client', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Sentry re-export', () => {
|
// ============================================================================
|
||||||
it('should re-export Sentry object', async () => {
|
// Section 2: Tests with Sentry ENABLED
|
||||||
const { Sentry } = await import('./sentry.client');
|
// ============================================================================
|
||||||
|
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();
|
it('should have isSentryConfigured as true when DSN is set and enabled', async () => {
|
||||||
expect(Sentry.init).toBeDefined();
|
const { isSentryConfigured } = await import('./sentry.client');
|
||||||
expect(Sentry.captureException).toBeDefined();
|
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
|
// Section 3: Environment-specific configuration tests
|
||||||
// This tests the filter that's passed to Sentry.init
|
// ============================================================================
|
||||||
|
describe('environment-specific configuration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should filter out browser extension errors', () => {
|
it('should use production environment', async () => {
|
||||||
// Simulate the beforeSend logic from the implementation
|
mockConfig.sentry = {
|
||||||
const filterExtensionErrors = (event: {
|
dsn: 'https://prod@bugsink.projectium.com/1',
|
||||||
exception?: {
|
environment: 'production',
|
||||||
values?: Array<{
|
debug: false,
|
||||||
stacktrace?: {
|
enabled: true,
|
||||||
frames?: Array<{ filename?: string }>;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
if (
|
|
||||||
event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) =>
|
|
||||||
frame.filename?.includes('extension://'),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return event;
|
|
||||||
};
|
};
|
||||||
|
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 = {
|
const extensionError = {
|
||||||
exception: {
|
exception: {
|
||||||
@@ -143,158 +575,274 @@ describe('sentry.client', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(filterExtensionErrors(extensionError)).toBeNull();
|
expect(beforeSend(extensionError)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow normal errors through', () => {
|
it('should filter out Firefox extension errors', async () => {
|
||||||
const filterExtensionErrors = (event: {
|
const { initSentry } = await import('./sentry.client');
|
||||||
exception?: {
|
initSentry();
|
||||||
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 normalError = {
|
const initConfig = (mockSentry.init as Mock).mock.calls[0][0];
|
||||||
exception: {
|
const beforeSend = initConfig.beforeSend;
|
||||||
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 firefoxExtensionError = {
|
const firefoxExtensionError = {
|
||||||
exception: {
|
exception: {
|
||||||
values: [
|
values: [
|
||||||
{
|
{
|
||||||
stacktrace: {
|
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
|
// Section 5: Sentry re-export tests
|
||||||
// This mirrors the implementation: !!config.sentry.dsn && config.sentry.enabled
|
// ============================================================================
|
||||||
|
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', () => {
|
expect(Sentry).toBeDefined();
|
||||||
const dsn = '';
|
|
||||||
const enabled = true;
|
|
||||||
const result = !!dsn && enabled;
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when enabled is false', () => {
|
it('should have init method on re-exported Sentry', async () => {
|
||||||
const dsn = 'https://test@sentry.io/123';
|
const { Sentry } = await import('./sentry.client');
|
||||||
const enabled = false;
|
|
||||||
const result = !!dsn && enabled;
|
expect(Sentry.init).toBeDefined();
|
||||||
expect(result).toBe(false);
|
expect(typeof Sentry.init).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true when DSN is set and enabled is true', () => {
|
it('should have captureException method on re-exported Sentry', async () => {
|
||||||
const dsn = 'https://test@sentry.io/123';
|
const { Sentry } = await import('./sentry.client');
|
||||||
const enabled = true;
|
|
||||||
const result = !!dsn && enabled;
|
expect(Sentry.captureException).toBeDefined();
|
||||||
expect(result).toBe(true);
|
expect(typeof Sentry.captureException).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when DSN is undefined', () => {
|
it('should have captureMessage method on re-exported Sentry', async () => {
|
||||||
const dsn = undefined;
|
const { Sentry } = await import('./sentry.client');
|
||||||
const enabled = true;
|
|
||||||
const result = !!dsn && enabled;
|
expect(Sentry.captureMessage).toBeDefined();
|
||||||
expect(result).toBe(false);
|
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', () => {
|
// Section 6: isSentryConfigured logic edge cases
|
||||||
// This tests the conditional context setting logic
|
// ============================================================================
|
||||||
const context = { userId: '123' };
|
describe('isSentryConfigured logic edge cases', () => {
|
||||||
const shouldSetContext = !!context;
|
beforeEach(() => {
|
||||||
expect(shouldSetContext).toBe(true);
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set context when not provided', () => {
|
it('should return false when DSN is null-ish', async () => {
|
||||||
const context = undefined;
|
mockConfig.sentry = {
|
||||||
const shouldSetContext = !!context;
|
dsn: null as unknown as string,
|
||||||
expect(shouldSetContext).toBe(false);
|
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', () => {
|
// Section 7: Multiple initialization calls
|
||||||
// Test the default parameter behavior
|
// ============================================================================
|
||||||
const defaultLevel = 'info';
|
describe('multiple initialization handling', () => {
|
||||||
expect(defaultLevel).toBe('info');
|
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
@@ -107,7 +107,10 @@ export const MockMainLayout: React.FC<Partial<MainLayoutProps>> = () => (
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</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}>
|
<div data-testid="home-page-mock" data-selected-flyer-id={selectedFlyer?.flyer_id}>
|
||||||
Mock Home Page
|
Mock Home Page
|
||||||
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
|
<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>;
|
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