Compare commits

...

17 Commits

Author SHA1 Message Date
Gitea Actions
65cb54500c ci: Bump version to 0.9.68 [skip ci] 2026-01-09 16:42:51 +05:00
664ad291be integration test fixes - claude for the win? try 3
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m3s
2026-01-09 03:41:57 -08:00
Gitea Actions
ff912b9055 ci: Bump version to 0.9.67 [skip ci] 2026-01-09 15:32:50 +05:00
ec32027bd4 integration test fixes - claude for the win?
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m43s
2026-01-09 02:32:16 -08:00
Gitea Actions
59f773639b ci: Bump version to 0.9.66 [skip ci] 2026-01-09 15:27:50 +05:00
dd2be5eecf integration test fixes - claude for the win?
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m0s
2026-01-09 02:27:14 -08:00
Gitea Actions
a94bfbd3e9 ci: Bump version to 0.9.65 [skip ci] 2026-01-09 14:43:36 +05:00
338bbc9440 integration test fixes - claude for the win?
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 20m4s
2026-01-09 01:42:51 -08:00
Gitea Actions
60aad04642 ci: Bump version to 0.9.64 [skip ci] 2026-01-09 13:57:52 +05:00
7f2aff9a24 unit test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 23m39s
2026-01-09 00:57:12 -08:00
Gitea Actions
689320e7d2 ci: Bump version to 0.9.63 [skip ci] 2026-01-09 13:19:09 +05:00
e457bbf046 more req work
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m51s
2026-01-09 00:18:09 -08:00
68cdbb6066 progress enforcing adr-0005 2026-01-09 00:18:09 -08:00
Gitea Actions
cea6be7145 ci: Bump version to 0.9.62 [skip ci] 2026-01-09 11:31:00 +05:00
74a5ca6331 claude 1 - fixes : -/
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 24m33s
2026-01-08 22:30:21 -08:00
Gitea Actions
62470e7661 ci: Bump version to 0.9.61 [skip ci] 2026-01-09 10:50:57 +05:00
2b517683fd progress enforcing adr-0005
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m2s
2026-01-08 21:50:21 -08:00
61 changed files with 3326 additions and 840 deletions

View File

@@ -45,7 +45,19 @@
"Bash(powershell.exe -Command \"echo ''List all podman containers'' | claude --print\")",
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print\")",
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print --allowedTools ''mcp__gitea-projectium__*''\")",
"Bash(powershell.exe -Command \"echo ''Fetch the homepage of https://gitea.projectium.com and summarize it'' | claude --print --allowedTools ''mcp__fetch__*''\")"
"Bash(powershell.exe -Command \"echo ''Fetch the homepage of https://gitea.projectium.com and summarize it'' | claude --print --allowedTools ''mcp__fetch__*''\")",
"Bash(dir \"C:\\\\Users\\\\games3\\\\.claude\")",
"Bash(dir:*)",
"Bash(D:nodejsnpx.cmd -y @modelcontextprotocol/server-fetch --help)",
"Bash(cmd /c \"dir /o-d C:\\\\Users\\\\games3\\\\.claude\\\\debug 2>nul | head -10\")",
"mcp__memory__read_graph",
"mcp__memory__create_entities",
"mcp__memory__search_nodes",
"mcp__memory__delete_entities",
"mcp__sequential-thinking__sequentialthinking",
"mcp__filesystem__list_directory",
"mcp__filesystem__read_multiple_files",
"mcp__filesystem__directory_tree"
]
}
}

View File

@@ -96,6 +96,23 @@ jobs:
# It prevents the accumulation of duplicate processes from previous test runs.
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.name && p.name.endsWith('-test')) { console.log('Deleting test process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id, e.message); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { if (e.stdout.toString().includes('No process found')) { console.log('No PM2 processes running, cleanup not needed.'); } else { console.error('Error cleaning up test processes:', e.message); } }" || true
- name: Flush Redis Before Tests
# CRITICAL: Clear all Redis data to remove stale BullMQ jobs from previous test runs.
# This prevents old jobs with outdated error messages from polluting test results.
env:
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
run: |
echo "--- Flushing Redis database to remove stale jobs ---"
if [ -z "$REDIS_PASSWORD" ]; then
echo "⚠️ REDIS_PASSWORD_TEST not set, attempting flush without password..."
redis-cli FLUSHDB || echo "Redis flush failed (no password)"
else
redis-cli -a "$REDIS_PASSWORD" FLUSHDB 2>/dev/null && echo "✅ Redis database flushed successfully." || echo "⚠️ Redis flush failed"
fi
# Verify the flush worked by checking key count
KEY_COUNT=$(redis-cli -a "$REDIS_PASSWORD" DBSIZE 2>/dev/null | grep -oE '[0-9]+' || echo "unknown")
echo "Redis key count after flush: $KEY_COUNT"
- name: Run All Tests and Generate Merged Coverage Report
# This single step runs both unit and integration tests, then merges their
# coverage data into a single report. It combines the environment variables

View File

@@ -2,7 +2,7 @@
**Date**: 2025-12-12
**Status**: Proposed
**Status**: Accepted
## Context
@@ -16,3 +16,82 @@ We will implement a dedicated background job processing system using a task queu
**Positive**: Decouples the API from heavy processing, allows for retries on failure, and enables scaling the processing workers independently. Increases application reliability and resilience.
**Negative**: Introduces a new dependency (Redis) into the infrastructure. Requires refactoring of the flyer processing logic to work within a job queue structure.
## Implementation Details
### Queue Infrastructure
The implementation uses **BullMQ v5.65.1** with **ioredis v5.8.2** for Redis connectivity. Six distinct queues handle different job types:
| Queue Name | Purpose | Retry Attempts | Backoff Strategy |
| ---------------------------- | --------------------------- | -------------- | ---------------------- |
| `flyer-processing` | OCR/AI processing of flyers | 3 | Exponential (5s base) |
| `email-sending` | Email delivery | 5 | Exponential (10s base) |
| `analytics-reporting` | Daily report generation | 2 | Exponential (60s base) |
| `weekly-analytics-reporting` | Weekly report generation | 2 | Exponential (1h base) |
| `file-cleanup` | Temporary file cleanup | 3 | Exponential (30s base) |
| `token-cleanup` | Expired token removal | 2 | Exponential (1h base) |
### Key Files
- `src/services/queues.server.ts` - Queue definitions and configuration
- `src/services/workers.server.ts` - Worker implementations with configurable concurrency
- `src/services/redis.server.ts` - Redis connection management
- `src/services/queueService.server.ts` - Queue lifecycle and graceful shutdown
- `src/services/flyerProcessingService.server.ts` - 5-stage flyer processing pipeline
- `src/types/job-data.ts` - TypeScript interfaces for all job data types
### API Design
Endpoints for long-running tasks return **202 Accepted** immediately with a job ID:
```text
POST /api/ai/upload-and-process → 202 { jobId: "..." }
GET /api/ai/jobs/:jobId/status → { state: "...", progress: ... }
```
### Worker Configuration
Workers are configured via environment variables:
- `WORKER_CONCURRENCY` - Flyer processing parallelism (default: 1)
- `EMAIL_WORKER_CONCURRENCY` - Email worker parallelism (default: 10)
- `ANALYTICS_WORKER_CONCURRENCY` - Analytics worker parallelism (default: 1)
- `CLEANUP_WORKER_CONCURRENCY` - Cleanup worker parallelism (default: 10)
### Monitoring
- **Bull Board UI** available at `/api/admin/jobs` for admin users
- Worker status endpoint: `GET /api/admin/workers/status`
- Queue status endpoint: `GET /api/admin/queues/status`
### Graceful Shutdown
Both API and worker processes implement graceful shutdown with a 30-second timeout, ensuring in-flight jobs complete before process termination.
## Compliance Notes
### Deprecated Synchronous Endpoints
The following endpoints process flyers synchronously and are **deprecated**:
- `POST /api/ai/upload-legacy` - For integration testing only
- `POST /api/ai/flyers/process` - Legacy workflow, should migrate to queue-based approach
New integrations MUST use `POST /api/ai/upload-and-process` for queue-based processing.
### Email Handling
- **Bulk emails** (deal notifications): Enqueued via `emailQueue`
- **Transactional emails** (password reset): Sent synchronously for immediate user feedback
## Future Enhancements
Potential improvements for consideration:
1. **Dead Letter Queue (DLQ)**: Move permanently failed jobs to a dedicated queue for analysis
2. **Job Priority Levels**: Allow priority-based processing for different job types
3. **Real-time Progress**: WebSocket/SSE for live job progress updates to clients
4. **Per-Queue Rate Limiting**: Throttle job processing based on external API limits
5. **Job Dependencies**: Support for jobs that depend on completion of other jobs
6. **Prometheus Metrics**: Export queue metrics for observability dashboards

View File

@@ -2,7 +2,7 @@
**Date**: 2025-12-12
**Status**: Proposed
**Status**: Accepted
## Context
@@ -20,3 +20,107 @@ We will implement a multi-layered caching strategy using an in-memory data store
**Positive**: Directly addresses application performance and scalability. Reduces database load and improves API response times for common requests.
**Negative**: Introduces Redis as a dependency if not already used. Adds complexity to the data-fetching logic and requires careful management of cache invalidation to prevent stale data.
## Implementation Details
### Cache Service
A centralized cache service (`src/services/cacheService.server.ts`) provides reusable caching functionality:
- **`getOrSet<T>(key, fetcher, options)`**: Cache-aside pattern implementation
- **`get<T>(key)`**: Retrieve cached value
- **`set<T>(key, value, ttl)`**: Store value with TTL
- **`del(key)`**: Delete specific key
- **`invalidatePattern(pattern)`**: Delete keys matching a pattern
All cache operations are fail-safe - cache failures do not break the application.
### TTL Configuration
Different data types use different TTL values based on volatility:
| Data Type | TTL | Rationale |
| ------------------- | --------- | -------------------------------------- |
| Brands/Stores | 1 hour | Rarely changes, safe to cache longer |
| Flyer lists | 5 minutes | Changes when new flyers are added |
| Individual flyers | 10 minutes| Stable once created |
| Flyer items | 10 minutes| Stable once created |
| Statistics | 5 minutes | Can be slightly stale |
| Frequent sales | 15 minutes| Aggregated data, updated periodically |
| Categories | 1 hour | Rarely changes |
### Cache Key Strategy
Cache keys follow a consistent prefix pattern for pattern-based invalidation:
- `cache:brands` - All brands list
- `cache:flyers:{limit}:{offset}` - Paginated flyer lists
- `cache:flyer:{id}` - Individual flyer data
- `cache:flyer-items:{flyerId}` - Items for a specific flyer
- `cache:stats:*` - Statistics data
- `geocode:{address}` - Geocoding results (30-day TTL)
### Cached Endpoints
The following repository methods implement server-side caching:
| Method | Cache Key Pattern | TTL |
| ------ | ----------------- | --- |
| `FlyerRepository.getAllBrands()` | `cache:brands` | 1 hour |
| `FlyerRepository.getFlyers()` | `cache:flyers:{limit}:{offset}` | 5 minutes |
| `FlyerRepository.getFlyerItems()` | `cache:flyer-items:{flyerId}` | 10 minutes |
### Cache Invalidation
**Event-based invalidation** is triggered on write operations:
- **Flyer creation** (`FlyerPersistenceService.saveFlyer`): Invalidates all `cache:flyers*` keys
- **Flyer deletion** (`FlyerRepository.deleteFlyer`): Invalidates specific flyer and flyer items cache, plus flyer lists
**Manual invalidation** via admin endpoints:
- `POST /api/admin/system/clear-cache` - Clears all application cache (flyers, brands, stats)
- `POST /api/admin/system/clear-geocode-cache` - Clears geocoding cache
### Client-Side Caching
TanStack React Query provides client-side caching with configurable stale times:
| Query Type | Stale Time |
| ----------------- | ----------- |
| Categories | 1 hour |
| Master Items | 10 minutes |
| Flyer Items | 5 minutes |
| Flyers | 2 minutes |
| Shopping Lists | 1 minute |
| Activity Log | 30 seconds |
### Multi-Layer Cache Architecture
```text
Client Request
[TanStack React Query] ← Client-side cache (staleTime-based)
[Express API]
[CacheService.getOrSet()] ← Server-side Redis cache (TTL-based)
[PostgreSQL Database]
```
## Key Files
- `src/services/cacheService.server.ts` - Centralized cache service
- `src/services/db/flyer.db.ts` - Repository with caching for brands, flyers, flyer items
- `src/services/flyerPersistenceService.server.ts` - Cache invalidation on flyer creation
- `src/routes/admin.routes.ts` - Admin cache management endpoints
- `src/config/queryClient.ts` - Client-side query cache configuration
## Future Enhancements
1. **Recipe caching**: Add caching to expensive recipe queries (by-sale-percentage, etc.)
2. **Cache warming**: Pre-populate cache on startup for frequently accessed static data
3. **Cache metrics**: Add hit/miss rate monitoring for observability
4. **Conditional caching**: Skip cache for authenticated user-specific data
5. **Cache compression**: Compress large cached payloads to reduce Redis memory usage

48
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.60",
"version": "0.9.68",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.60",
"version": "0.9.68",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",
@@ -50,6 +50,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.17",
"@tanstack/react-query-devtools": "^5.91.2",
"@testcontainers/postgresql": "^11.8.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -4887,9 +4888,20 @@
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
"integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
@@ -4897,12 +4909,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
"@tanstack/query-core": "5.90.16"
},
"funding": {
"type": "github",
@@ -4912,6 +4924,24 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.92.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.14",
"react": "^18 || ^19"
}
},
"node_modules/@testcontainers/postgresql": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.10.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.9.60",
"version": "0.9.68",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",
@@ -69,6 +69,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.17",
"@tanstack/react-query-devtools": "^5.91.2",
"@testcontainers/postgresql": "^11.8.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",

View File

@@ -157,8 +157,6 @@ describe('ExtractedDataTable', () => {
vi.mocked(useUserData).mockReturnValue({
watchedItems: [],
shoppingLists: mockShoppingLists,
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
@@ -222,8 +220,6 @@ describe('ExtractedDataTable', () => {
vi.mocked(useUserData).mockReturnValue({
watchedItems: [mockMasterItems[0]], // 'Apples' is watched
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
@@ -355,8 +351,6 @@ describe('ExtractedDataTable', () => {
vi.mocked(useUserData).mockReturnValue({
watchedItems: [mockMasterItems[2], mockMasterItems[0]],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
@@ -456,8 +450,6 @@ describe('ExtractedDataTable', () => {
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Apple' }),
],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});

View File

@@ -0,0 +1,128 @@
// src/hooks/mutations/useAddShoppingListItemMutation.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 { useAddShoppingListItemMutation } from './useAddShoppingListItemMutation';
import * as apiClient from '../../services/apiClient';
import * as notificationService from '../../services/notificationService';
vi.mock('../../services/apiClient');
vi.mock('../../services/notificationService');
const mockedApiClient = vi.mocked(apiClient);
const mockedNotifications = vi.mocked(notificationService);
describe('useAddShoppingListItemMutation', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should add a master item to shopping list successfully', async () => {
const mockResponse = { shopping_list_item_id: 1, master_item_id: 42 };
mockedApiClient.addShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useAddShoppingListItemMutation(), { wrapper });
result.current.mutate({ listId: 1, item: { masterItemId: 42 } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addShoppingListItem).toHaveBeenCalledWith(1, { masterItemId: 42 });
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item added to shopping list');
});
it('should add a custom item to shopping list successfully', async () => {
const mockResponse = { shopping_list_item_id: 2, custom_item_name: 'Special Milk' };
mockedApiClient.addShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useAddShoppingListItemMutation(), { wrapper });
result.current.mutate({ listId: 1, item: { customItemName: 'Special Milk' } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addShoppingListItem).toHaveBeenCalledWith(1, { customItemName: 'Special Milk' });
});
it('should invalidate shopping-lists query on success', async () => {
mockedApiClient.addShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
} as Response);
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useAddShoppingListItemMutation(), { wrapper });
result.current.mutate({ listId: 1, item: { masterItemId: 42 } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['shopping-lists'] });
});
it('should handle API error with error message', async () => {
mockedApiClient.addShoppingListItem.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: 'Item already exists' }),
} as Response);
const { result } = renderHook(() => useAddShoppingListItemMutation(), { wrapper });
result.current.mutate({ listId: 1, item: { masterItemId: 42 } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Item already exists');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item already exists');
});
it('should handle API error without message', async () => {
mockedApiClient.addShoppingListItem.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useAddShoppingListItemMutation(), { wrapper });
result.current.mutate({ listId: 1, item: { masterItemId: 42 } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Request failed with status 500');
});
it('should handle network error', async () => {
mockedApiClient.addShoppingListItem.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useAddShoppingListItemMutation(), { wrapper });
result.current.mutate({ listId: 1, item: { masterItemId: 42 } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Network error');
});
});

View File

@@ -0,0 +1,115 @@
// src/hooks/mutations/useAddWatchedItemMutation.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 { useAddWatchedItemMutation } from './useAddWatchedItemMutation';
import * as apiClient from '../../services/apiClient';
import * as notificationService from '../../services/notificationService';
vi.mock('../../services/apiClient');
vi.mock('../../services/notificationService');
const mockedApiClient = vi.mocked(apiClient);
const mockedNotifications = vi.mocked(notificationService);
describe('useAddWatchedItemMutation', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should add a watched item successfully with category', async () => {
const mockResponse = { id: 1, item_name: 'Milk', category: 'Dairy' };
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Milk', category: 'Dairy' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Milk', 'Dairy');
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item added to watched list');
});
it('should add a watched item without category', async () => {
const mockResponse = { id: 1, item_name: 'Bread' };
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Bread' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Bread', '');
});
it('should invalidate watched-items query on success', async () => {
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1 }),
} as Response);
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Eggs' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['watched-items'] });
});
it('should handle API error with error message', async () => {
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: false,
status: 409,
json: () => Promise.resolve({ message: 'Item already watched' }),
} as Response);
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Milk' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Item already watched');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item already watched');
});
it('should handle API error without message', async () => {
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Cheese' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
});

View File

@@ -37,7 +37,7 @@ export const useAddWatchedItemMutation = () => {
return useMutation({
mutationFn: async ({ itemName, category }: AddWatchedItemParams) => {
const response = await apiClient.addWatchedItem(itemName, category);
const response = await apiClient.addWatchedItem(itemName, category ?? '');
if (!response.ok) {
const error = await response.json().catch(() => ({

View File

@@ -0,0 +1,99 @@
// src/hooks/mutations/useCreateShoppingListMutation.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 { useCreateShoppingListMutation } from './useCreateShoppingListMutation';
import * as apiClient from '../../services/apiClient';
import * as notificationService from '../../services/notificationService';
vi.mock('../../services/apiClient');
vi.mock('../../services/notificationService');
const mockedApiClient = vi.mocked(apiClient);
const mockedNotifications = vi.mocked(notificationService);
describe('useCreateShoppingListMutation', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should create a shopping list successfully', async () => {
const mockResponse = { shopping_list_id: 1, name: 'Weekly Groceries' };
mockedApiClient.createShoppingList.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useCreateShoppingListMutation(), { wrapper });
result.current.mutate({ name: 'Weekly Groceries' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.createShoppingList).toHaveBeenCalledWith('Weekly Groceries');
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Shopping list created');
});
it('should invalidate shopping-lists query on success', async () => {
mockedApiClient.createShoppingList.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ shopping_list_id: 1 }),
} as Response);
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useCreateShoppingListMutation(), { wrapper });
result.current.mutate({ name: 'Test List' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['shopping-lists'] });
});
it('should handle API error with error message', async () => {
mockedApiClient.createShoppingList.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: 'List name already exists' }),
} as Response);
const { result } = renderHook(() => useCreateShoppingListMutation(), { wrapper });
result.current.mutate({ name: 'Duplicate List' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('List name already exists');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('List name already exists');
});
it('should handle API error without message', async () => {
mockedApiClient.createShoppingList.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useCreateShoppingListMutation(), { wrapper });
result.current.mutate({ name: 'Test' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
});

View File

@@ -0,0 +1,99 @@
// src/hooks/mutations/useDeleteShoppingListMutation.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 { useDeleteShoppingListMutation } from './useDeleteShoppingListMutation';
import * as apiClient from '../../services/apiClient';
import * as notificationService from '../../services/notificationService';
vi.mock('../../services/apiClient');
vi.mock('../../services/notificationService');
const mockedApiClient = vi.mocked(apiClient);
const mockedNotifications = vi.mocked(notificationService);
describe('useDeleteShoppingListMutation', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should delete a shopping list successfully', async () => {
const mockResponse = { success: true };
mockedApiClient.deleteShoppingList.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useDeleteShoppingListMutation(), { wrapper });
result.current.mutate({ listId: 123 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.deleteShoppingList).toHaveBeenCalledWith(123);
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Shopping list deleted');
});
it('should invalidate shopping-lists query on success', async () => {
mockedApiClient.deleteShoppingList.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
} as Response);
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useDeleteShoppingListMutation(), { wrapper });
result.current.mutate({ listId: 456 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['shopping-lists'] });
});
it('should handle API error with error message', async () => {
mockedApiClient.deleteShoppingList.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'Shopping list not found' }),
} as Response);
const { result } = renderHook(() => useDeleteShoppingListMutation(), { wrapper });
result.current.mutate({ listId: 999 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Shopping list not found');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Shopping list not found');
});
it('should handle API error without message', async () => {
mockedApiClient.deleteShoppingList.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useDeleteShoppingListMutation(), { wrapper });
result.current.mutate({ listId: 123 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
});

View File

@@ -0,0 +1,99 @@
// src/hooks/mutations/useRemoveShoppingListItemMutation.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 { useRemoveShoppingListItemMutation } from './useRemoveShoppingListItemMutation';
import * as apiClient from '../../services/apiClient';
import * as notificationService from '../../services/notificationService';
vi.mock('../../services/apiClient');
vi.mock('../../services/notificationService');
const mockedApiClient = vi.mocked(apiClient);
const mockedNotifications = vi.mocked(notificationService);
describe('useRemoveShoppingListItemMutation', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should remove an item from shopping list successfully', async () => {
const mockResponse = { success: true };
mockedApiClient.removeShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useRemoveShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.removeShoppingListItem).toHaveBeenCalledWith(42);
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item removed from shopping list');
});
it('should invalidate shopping-lists query on success', async () => {
mockedApiClient.removeShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
} as Response);
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useRemoveShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 100 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['shopping-lists'] });
});
it('should handle API error with error message', async () => {
mockedApiClient.removeShoppingListItem.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'Item not found' }),
} as Response);
const { result } = renderHook(() => useRemoveShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 999 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Item not found');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item not found');
});
it('should handle API error without message', async () => {
mockedApiClient.removeShoppingListItem.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useRemoveShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
});

View File

@@ -0,0 +1,99 @@
// src/hooks/mutations/useRemoveWatchedItemMutation.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 { useRemoveWatchedItemMutation } from './useRemoveWatchedItemMutation';
import * as apiClient from '../../services/apiClient';
import * as notificationService from '../../services/notificationService';
vi.mock('../../services/apiClient');
vi.mock('../../services/notificationService');
const mockedApiClient = vi.mocked(apiClient);
const mockedNotifications = vi.mocked(notificationService);
describe('useRemoveWatchedItemMutation', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should remove a watched item successfully', async () => {
const mockResponse = { success: true };
mockedApiClient.removeWatchedItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useRemoveWatchedItemMutation(), { wrapper });
result.current.mutate({ masterItemId: 123 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.removeWatchedItem).toHaveBeenCalledWith(123);
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item removed from watched list');
});
it('should invalidate watched-items query on success', async () => {
mockedApiClient.removeWatchedItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
} as Response);
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useRemoveWatchedItemMutation(), { wrapper });
result.current.mutate({ masterItemId: 456 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['watched-items'] });
});
it('should handle API error with error message', async () => {
mockedApiClient.removeWatchedItem.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'Watched item not found' }),
} as Response);
const { result } = renderHook(() => useRemoveWatchedItemMutation(), { wrapper });
result.current.mutate({ masterItemId: 999 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Watched item not found');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Watched item not found');
});
it('should handle API error without message', async () => {
mockedApiClient.removeWatchedItem.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useRemoveWatchedItemMutation(), { wrapper });
result.current.mutate({ masterItemId: 123 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
});

View File

@@ -0,0 +1,159 @@
// src/hooks/mutations/useUpdateShoppingListItemMutation.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 { useUpdateShoppingListItemMutation } from './useUpdateShoppingListItemMutation';
import * as apiClient from '../../services/apiClient';
import * as notificationService from '../../services/notificationService';
vi.mock('../../services/apiClient');
vi.mock('../../services/notificationService');
const mockedApiClient = vi.mocked(apiClient);
const mockedNotifications = vi.mocked(notificationService);
describe('useUpdateShoppingListItemMutation', () => {
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should update a shopping list item successfully', async () => {
const mockResponse = { id: 42, quantity: 3 };
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42, updates: { quantity: 3 } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { quantity: 3 });
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Shopping list item updated');
});
it('should update is_purchased status', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 42, is_purchased: true }),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42, updates: { is_purchased: true } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { is_purchased: true });
});
it('should update custom_item_name', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 42, custom_item_name: 'Organic Milk' }),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42, updates: { custom_item_name: 'Organic Milk' } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { custom_item_name: 'Organic Milk' });
});
it('should update notes', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 42, notes: 'Get the 2% variety' }),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42, updates: { notes: 'Get the 2% variety' } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { notes: 'Get the 2% variety' });
});
it('should update multiple fields at once', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 42, quantity: 2, notes: 'Important' }),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42, updates: { quantity: 2, notes: 'Important' } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { quantity: 2, notes: 'Important' });
});
it('should invalidate shopping-lists query on success', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 42 }),
} as Response);
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42, updates: { quantity: 5 } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['shopping-lists'] });
});
it('should handle API error with error message', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'Item not found' }),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 999, updates: { quantity: 1 } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Item not found');
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item not found');
});
it('should handle API error without message', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 42, updates: { quantity: 1 } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
});

View File

@@ -0,0 +1,102 @@
// src/hooks/queries/useActivityLogQuery.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 { useActivityLogQuery } from './useActivityLogQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useActivityLogQuery', () => {
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 activity log with default params', async () => {
const mockActivityLog = [
{ id: 1, action: 'user_login', timestamp: '2024-01-01T10:00:00Z' },
{ id: 2, action: 'flyer_uploaded', timestamp: '2024-01-01T11:00:00Z' },
];
mockedApiClient.fetchActivityLog.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockActivityLog),
} as Response);
const { result } = renderHook(() => useActivityLogQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchActivityLog).toHaveBeenCalledWith(20, 0);
expect(result.current.data).toEqual(mockActivityLog);
});
it('should fetch activity log with custom limit and offset', async () => {
const mockActivityLog = [{ id: 3, action: 'item_added', timestamp: '2024-01-01T12:00:00Z' }];
mockedApiClient.fetchActivityLog.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockActivityLog),
} as Response);
const { result } = renderHook(() => useActivityLogQuery(10, 5), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchActivityLog).toHaveBeenCalledWith(10, 5);
expect(result.current.data).toEqual(mockActivityLog);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ message: 'Admin access required' }),
} as Response);
const { result } = renderHook(() => useActivityLogQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Admin access required');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useActivityLogQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array for no activity log entries', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useActivityLogQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -1,18 +1,7 @@
// src/hooks/queries/useActivityLogQuery.ts
import { useQuery } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
interface ActivityLogEntry {
activity_log_id: number;
user_id: string;
action: string;
entity_type: string | null;
entity_id: number | null;
details: any;
ip_address: string | null;
user_agent: string | null;
created_at: string;
}
import { fetchActivityLog } from '../../services/apiClient';
import type { ActivityLogItem } from '../../types';
/**
* Query hook for fetching the admin activity log.
@@ -33,8 +22,8 @@ interface ActivityLogEntry {
export const useActivityLogQuery = (limit: number = 20, offset: number = 0) => {
return useQuery({
queryKey: ['activity-log', { limit, offset }],
queryFn: async (): Promise<ActivityLogEntry[]> => {
const response = await apiClient.fetchActivityLog(limit, offset);
queryFn: async (): Promise<ActivityLogItem[]> => {
const response = await fetchActivityLog(limit, offset);
if (!response.ok) {
const error = await response.json().catch(() => ({

View File

@@ -0,0 +1,78 @@
// src/hooks/queries/useApplicationStatsQuery.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 { useApplicationStatsQuery } from './useApplicationStatsQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useApplicationStatsQuery', () => {
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 application stats successfully', async () => {
const mockStats = {
flyerCount: 150,
userCount: 500,
flyerItemCount: 5000,
storeCount: 25,
pendingCorrectionsCount: 10,
recipeCount: 75,
};
mockedApiClient.getApplicationStats.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockStats),
} as Response);
const { result } = renderHook(() => useApplicationStatsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.getApplicationStats).toHaveBeenCalled();
expect(result.current.data).toEqual(mockStats);
});
it('should handle API error with error message', async () => {
mockedApiClient.getApplicationStats.mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ message: 'Admin access required' }),
} as Response);
const { result } = renderHook(() => useApplicationStatsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Admin access required');
});
it('should handle API error without message', async () => {
mockedApiClient.getApplicationStats.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useApplicationStatsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
});

View File

@@ -1,6 +1,6 @@
// src/hooks/queries/useApplicationStatsQuery.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient, AppStats } from '../../services/apiClient';
import { getApplicationStats, AppStats } from '../../services/apiClient';
/**
* Query hook for fetching application-wide statistics (admin feature).
@@ -21,7 +21,7 @@ export const useApplicationStatsQuery = () => {
return useQuery({
queryKey: ['application-stats'],
queryFn: async (): Promise<AppStats> => {
const response = await apiClient.getApplicationStats();
const response = await getApplicationStats();
if (!response.ok) {
const error = await response.json().catch(() => ({

View File

@@ -0,0 +1,88 @@
// src/hooks/queries/useCategoriesQuery.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 { useCategoriesQuery } from './useCategoriesQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useCategoriesQuery', () => {
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 categories successfully', async () => {
const mockCategories = [
{ category_id: 1, name: 'Dairy' },
{ category_id: 2, name: 'Bakery' },
{ category_id: 3, name: 'Produce' },
];
mockedApiClient.fetchCategories.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockCategories),
} as Response);
const { result } = renderHook(() => useCategoriesQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchCategories).toHaveBeenCalled();
expect(result.current.data).toEqual(mockCategories);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchCategories.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: 'Database error' }),
} as Response);
const { result } = renderHook(() => useCategoriesQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Database error');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchCategories.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useCategoriesQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array for no categories', async () => {
mockedApiClient.fetchCategories.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useCategoriesQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
// src/hooks/queries/useCategoriesQuery.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../services/apiClient';
import { fetchCategories } from '../../services/apiClient';
import type { Category } from '../../types';
/**
@@ -16,7 +16,7 @@ export const useCategoriesQuery = () => {
return useQuery({
queryKey: ['categories'],
queryFn: async (): Promise<Category[]> => {
const response = await apiClient.fetchCategories();
const response = await fetchCategories();
if (!response.ok) {
const error = await response.json().catch(() => ({

View File

@@ -0,0 +1,111 @@
// src/hooks/queries/useFlyerItemsQuery.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 { useFlyerItemsQuery } from './useFlyerItemsQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useFlyerItemsQuery', () => {
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 when flyerId is provided', async () => {
const mockFlyerItems = [
{ item_id: 1, name: 'Milk', price: 3.99, flyer_id: 42 },
{ item_id: 2, name: 'Bread', price: 2.49, flyer_id: 42 },
];
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockFlyerItems }),
} as Response);
const { result } = renderHook(() => useFlyerItemsQuery(42), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchFlyerItems).toHaveBeenCalledWith(42);
expect(result.current.data).toEqual(mockFlyerItems);
});
it('should not fetch when flyerId is undefined', async () => {
const { result } = renderHook(() => useFlyerItemsQuery(undefined), { wrapper });
// Wait a bit to ensure the query doesn't run
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockedApiClient.fetchFlyerItems).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'Flyer not found' }),
} as Response);
const { result } = renderHook(() => useFlyerItemsQuery(999), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Flyer not found');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useFlyerItemsQuery(42), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array when API returns no items', async () => {
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [] }),
} as Response);
const { result } = renderHook(() => useFlyerItemsQuery(42), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should handle response without items property', async () => {
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
} as Response);
const { result } = renderHook(() => useFlyerItemsQuery(42), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -0,0 +1,102 @@
// src/hooks/queries/useFlyersQuery.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 { useFlyersQuery } from './useFlyersQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useFlyersQuery', () => {
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 flyers successfully with default params', async () => {
const mockFlyers = [
{ flyer_id: 1, store_name: 'Store A', valid_from: '2024-01-01', valid_to: '2024-01-07' },
{ flyer_id: 2, store_name: 'Store B', valid_from: '2024-01-01', valid_to: '2024-01-07' },
];
mockedApiClient.fetchFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockFlyers),
} as Response);
const { result } = renderHook(() => useFlyersQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchFlyers).toHaveBeenCalledWith(20, 0);
expect(result.current.data).toEqual(mockFlyers);
});
it('should fetch flyers with custom limit and offset', async () => {
const mockFlyers = [{ flyer_id: 3, store_name: 'Store C' }];
mockedApiClient.fetchFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockFlyers),
} as Response);
const { result } = renderHook(() => useFlyersQuery(10, 5), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchFlyers).toHaveBeenCalledWith(10, 5);
expect(result.current.data).toEqual(mockFlyers);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchFlyers.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: 'Server error' }),
} as Response);
const { result } = renderHook(() => useFlyersQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Server error');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchFlyers.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useFlyersQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array for no flyers', async () => {
mockedApiClient.fetchFlyers.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useFlyersQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -0,0 +1,88 @@
// src/hooks/queries/useMasterItemsQuery.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 { useMasterItemsQuery } from './useMasterItemsQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useMasterItemsQuery', () => {
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 master items successfully', async () => {
const mockMasterItems = [
{ master_item_id: 1, name: 'Milk', category: 'Dairy' },
{ master_item_id: 2, name: 'Bread', category: 'Bakery' },
{ master_item_id: 3, name: 'Eggs', category: 'Dairy' },
];
mockedApiClient.fetchMasterItems.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockMasterItems),
} as Response);
const { result } = renderHook(() => useMasterItemsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchMasterItems).toHaveBeenCalled();
expect(result.current.data).toEqual(mockMasterItems);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchMasterItems.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: 'Database error' }),
} as Response);
const { result } = renderHook(() => useMasterItemsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Database error');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchMasterItems.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useMasterItemsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array for no master items', async () => {
mockedApiClient.fetchMasterItems.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useMasterItemsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -0,0 +1,98 @@
// src/hooks/queries/useShoppingListsQuery.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 { useShoppingListsQuery } from './useShoppingListsQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useShoppingListsQuery', () => {
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 shopping lists when enabled', async () => {
const mockShoppingLists = [
{ shopping_list_id: 1, name: 'Weekly Groceries', items: [] },
{ shopping_list_id: 2, name: 'Party Supplies', items: [] },
];
mockedApiClient.fetchShoppingLists.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockShoppingLists),
} as Response);
const { result } = renderHook(() => useShoppingListsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchShoppingLists).toHaveBeenCalled();
expect(result.current.data).toEqual(mockShoppingLists);
});
it('should not fetch shopping lists when disabled', async () => {
const { result } = renderHook(() => useShoppingListsQuery(false), { wrapper });
// Wait a bit to ensure the query doesn't run
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockedApiClient.fetchShoppingLists).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchShoppingLists.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Unauthorized' }),
} as Response);
const { result } = renderHook(() => useShoppingListsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Unauthorized');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchShoppingLists.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useShoppingListsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array for no shopping lists', async () => {
mockedApiClient.fetchShoppingLists.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useShoppingListsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -1,5 +1,5 @@
// src/hooks/queries/useShoppingListsQuery.ts
import { useQuery } from '@tantml:parameter>
import { useQuery } from '@tanstack/react-query';
import * as apiClient from '../../services/apiClient';
import type { ShoppingList } from '../../types';

View File

@@ -0,0 +1,87 @@
// src/hooks/queries/useSuggestedCorrectionsQuery.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 { useSuggestedCorrectionsQuery } from './useSuggestedCorrectionsQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useSuggestedCorrectionsQuery', () => {
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 suggested corrections successfully', async () => {
const mockCorrections = [
{ correction_id: 1, item_name: 'Milk', suggested_name: 'Whole Milk', status: 'pending' },
{ correction_id: 2, item_name: 'Bread', suggested_name: 'White Bread', status: 'pending' },
];
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockCorrections),
} as Response);
const { result } = renderHook(() => useSuggestedCorrectionsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.getSuggestedCorrections).toHaveBeenCalled();
expect(result.current.data).toEqual(mockCorrections);
});
it('should handle API error with error message', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ message: 'Admin access required' }),
} as Response);
const { result } = renderHook(() => useSuggestedCorrectionsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Admin access required');
});
it('should handle API error without message', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useSuggestedCorrectionsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array for no corrections', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useSuggestedCorrectionsQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
// src/hooks/queries/useSuggestedCorrectionsQuery.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../services/apiClient';
import { getSuggestedCorrections } from '../../services/apiClient';
import type { SuggestedCorrection } from '../../types';
/**
@@ -16,7 +16,7 @@ export const useSuggestedCorrectionsQuery = () => {
return useQuery({
queryKey: ['suggested-corrections'],
queryFn: async (): Promise<SuggestedCorrection[]> => {
const response = await apiClient.getSuggestedCorrections();
const response = await getSuggestedCorrections();
if (!response.ok) {
const error = await response.json().catch(() => ({

View File

@@ -0,0 +1,98 @@
// src/hooks/queries/useWatchedItemsQuery.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 { useWatchedItemsQuery } from './useWatchedItemsQuery';
import * as apiClient from '../../services/apiClient';
vi.mock('../../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
describe('useWatchedItemsQuery', () => {
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 watched items when enabled', async () => {
const mockWatchedItems = [
{ master_item_id: 1, name: 'Milk', category: 'Dairy' },
{ master_item_id: 2, name: 'Bread', category: 'Bakery' },
];
mockedApiClient.fetchWatchedItems.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockWatchedItems),
} as Response);
const { result } = renderHook(() => useWatchedItemsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.fetchWatchedItems).toHaveBeenCalled();
expect(result.current.data).toEqual(mockWatchedItems);
});
it('should not fetch watched items when disabled', async () => {
const { result } = renderHook(() => useWatchedItemsQuery(false), { wrapper });
// Wait a bit to ensure the query doesn't run
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockedApiClient.fetchWatchedItems).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
});
it('should handle API error with error message', async () => {
mockedApiClient.fetchWatchedItems.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Unauthorized' }),
} as Response);
const { result } = renderHook(() => useWatchedItemsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Unauthorized');
});
it('should handle API error without message', async () => {
mockedApiClient.fetchWatchedItems.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Parse error')),
} as Response);
const { result } = renderHook(() => useWatchedItemsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should return empty array for no watched items', async () => {
mockedApiClient.fetchWatchedItems.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const { result } = renderHook(() => useWatchedItemsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

View File

@@ -2,14 +2,13 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useFlyerItems } from './useFlyerItems';
import { useApiOnMount } from './useApiOnMount';
import * as apiClient from '../services/apiClient';
import * as useFlyerItemsQueryModule from './queries/useFlyerItemsQuery';
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
vi.mock('./useApiOnMount');
// Mock the underlying query hook to isolate the useFlyerItems hook's logic.
vi.mock('./queries/useFlyerItemsQuery');
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
const mockedUseFlyerItemsQuery = vi.mocked(useFlyerItemsQueryModule.useFlyerItemsQuery);
describe('useFlyerItems Hook', () => {
const mockFlyer = createMockFlyer({
@@ -39,19 +38,16 @@ describe('useFlyerItems Hook', () => {
];
beforeEach(() => {
// Clear mock history before each test
vi.clearAllMocks();
});
it('should return initial state and not call useApiOnMount when flyer is null', () => {
// Arrange: Mock the return value of the inner hook.
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
it('should return initial state when flyer is null', () => {
// Arrange: Mock the return value of the query hook.
mockedUseFlyerItemsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
// Act: Render the hook with a null flyer.
const { result } = renderHook(() => useFlyerItems(null));
@@ -60,57 +56,41 @@ describe('useFlyerItems Hook', () => {
expect(result.current.flyerItems).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
// Assert: Check that useApiOnMount was called with `enabled: false`.
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
expect.any(Function), // the wrapped fetcher function
[null], // dependencies array
{ enabled: false }, // options object
undefined, // flyer_id
);
// Assert: Check that useFlyerItemsQuery was called with undefined flyerId.
expect(mockedUseFlyerItemsQuery).toHaveBeenCalledWith(undefined);
});
it('should call useApiOnMount with enabled: true when a flyer is provided', () => {
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: true,
it('should call useFlyerItemsQuery with flyerId when a flyer is provided', () => {
mockedUseFlyerItemsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
isRefetching: false,
reset: vi.fn(),
});
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
renderHook(() => useFlyerItems(mockFlyer));
// Assert: Check that useApiOnMount was called with the correct parameters.
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
expect.any(Function),
[mockFlyer],
{ enabled: true },
mockFlyer.flyer_id,
);
// Assert: Check that useFlyerItemsQuery was called with the correct flyerId.
expect(mockedUseFlyerItemsQuery).toHaveBeenCalledWith(123);
});
it('should return isLoading: true when the inner hook is loading', () => {
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: true,
it('should return isLoading: true when the query is loading', () => {
mockedUseFlyerItemsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
isRefetching: false,
reset: vi.fn(),
});
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
const { result } = renderHook(() => useFlyerItems(mockFlyer));
expect(result.current.isLoading).toBe(true);
});
it('should return flyerItems when the inner hook provides data', () => {
mockedUseApiOnMount.mockReturnValue({
data: { items: mockFlyerItems },
loading: false,
it('should return flyerItems when the query provides data', () => {
mockedUseFlyerItemsQuery.mockReturnValue({
data: mockFlyerItems,
isLoading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
const { result } = renderHook(() => useFlyerItems(mockFlyer));
@@ -119,15 +99,13 @@ describe('useFlyerItems Hook', () => {
expect(result.current.error).toBeNull();
});
it('should return an error when the inner hook returns an error', () => {
it('should return an error when the query returns an error', () => {
const mockError = new Error('Failed to fetch');
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
mockedUseFlyerItemsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: mockError,
isRefetching: false,
reset: vi.fn(),
});
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
const { result } = renderHook(() => useFlyerItems(mockFlyer));
@@ -135,46 +113,4 @@ describe('useFlyerItems Hook', () => {
expect(result.current.flyerItems).toEqual([]);
expect(result.current.error).toEqual(mockError);
});
describe('wrappedFetcher behavior', () => {
it('should reject if called with undefined flyerId', async () => {
// We need to trigger the hook to get access to the internal wrappedFetcher
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
renderHook(() => useFlyerItems(mockFlyer));
// The first argument passed to useApiOnMount is the wrappedFetcher function
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
// Verify the fetcher rejects when no ID is passed (which shouldn't happen in normal flow due to 'enabled')
await expect(wrappedFetcher(undefined)).rejects.toThrow(
'Cannot fetch items for an undefined flyer ID.',
);
});
it('should call apiClient.fetchFlyerItems when called with a valid ID', async () => {
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
renderHook(() => useFlyerItems(mockFlyer));
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
const mockResponse = new Response();
const mockedApiClient = vi.mocked(apiClient);
mockedApiClient.fetchFlyerItems.mockResolvedValue(mockResponse);
const response = await wrappedFetcher(123);
expect(mockedApiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
expect(response).toBe(mockResponse);
});
});
});

View File

@@ -4,15 +4,15 @@ import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useFlyers } from './useFlyers';
import { FlyersProvider } from '../providers/FlyersProvider';
import { useInfiniteQuery } from './useInfiniteQuery';
import { useFlyersQuery } from './queries/useFlyersQuery';
import type { Flyer } from '../types';
import { createMockFlyer } from '../tests/utils/mockFactories';
// 1. Mock the useInfiniteQuery hook, which is the dependency of our FlyersProvider.
vi.mock('./useInfiniteQuery');
// 1. Mock the useFlyersQuery hook, which is the dependency of our FlyersProvider.
vi.mock('./queries/useFlyersQuery');
// 2. Create a typed mock of the hook for type safety and autocompletion.
const mockedUseInfiniteQuery = vi.mocked(useInfiniteQuery);
const mockedUseFlyersQuery = vi.mocked(useFlyersQuery);
// 3. A simple wrapper component that renders our provider.
// This is necessary because the useFlyers hook needs to be a child of FlyersProvider.
@@ -22,7 +22,6 @@ const wrapper = ({ children }: { children: ReactNode }) => (
describe('useFlyers Hook and FlyersProvider', () => {
// Create mock functions that we can spy on to see if they are called.
const mockFetchNextPage = vi.fn();
const mockRefetch = vi.fn();
beforeEach(() => {
@@ -46,16 +45,32 @@ describe('useFlyers Hook and FlyersProvider', () => {
it('should return the initial loading state correctly', () => {
// Arrange: Configure the mocked hook to return a loading state.
mockedUseInfiniteQuery.mockReturnValue({
data: [],
mockedUseFlyersQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
refetch: mockRefetch,
isRefetching: false,
isFetchingNextPage: false,
});
// TanStack Query properties (partial mock)
status: 'pending',
fetchStatus: 'fetching',
isPending: true,
isSuccess: false,
isError: false,
isFetched: false,
isFetchedAfterMount: false,
isStale: false,
isPlaceholderData: false,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isInitialLoading: true,
isLoadingError: false,
isRefetchError: false,
promise: Promise.resolve([]),
} as any);
// Act: Render the hook within the provider wrapper.
const { result } = renderHook(() => useFlyers(), { wrapper });
@@ -66,7 +81,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
expect(result.current.flyersError).toBeNull();
});
it('should return flyers data and hasNextPage on successful fetch', () => {
it('should return flyers data on successful fetch', () => {
// Arrange: Mock a successful data fetch.
const mockFlyers: Flyer[] = [
createMockFlyer({
@@ -77,16 +92,31 @@ describe('useFlyers Hook and FlyersProvider', () => {
created_at: '2024-01-01',
}),
];
mockedUseInfiniteQuery.mockReturnValue({
mockedUseFlyersQuery.mockReturnValue({
data: mockFlyers,
isLoading: false,
error: null,
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
refetch: mockRefetch,
isRefetching: false,
isFetchingNextPage: false,
});
status: 'success',
fetchStatus: 'idle',
isPending: false,
isSuccess: true,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isStale: false,
isPlaceholderData: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isInitialLoading: false,
isLoadingError: false,
isRefetchError: false,
promise: Promise.resolve(mockFlyers),
} as any);
// Act
const { result } = renderHook(() => useFlyers(), { wrapper });
@@ -94,22 +124,38 @@ describe('useFlyers Hook and FlyersProvider', () => {
// Assert
expect(result.current.isLoadingFlyers).toBe(false);
expect(result.current.flyers).toEqual(mockFlyers);
expect(result.current.hasNextFlyersPage).toBe(true);
// Note: hasNextFlyersPage is always false now since we're not using infinite query
expect(result.current.hasNextFlyersPage).toBe(false);
});
it('should return an error state if the fetch fails', () => {
// Arrange: Mock a failed data fetch.
const mockError = new Error('Failed to fetch');
mockedUseInfiniteQuery.mockReturnValue({
data: [],
mockedUseFlyersQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: mockError,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
refetch: mockRefetch,
isRefetching: false,
isFetchingNextPage: false,
});
status: 'error',
fetchStatus: 'idle',
isPending: false,
isSuccess: false,
isError: true,
isFetched: true,
isFetchedAfterMount: true,
isStale: false,
isPlaceholderData: false,
dataUpdatedAt: 0,
errorUpdatedAt: Date.now(),
failureCount: 1,
failureReason: mockError,
errorUpdateCount: 1,
isInitialLoading: false,
isLoadingError: true,
isRefetchError: false,
promise: Promise.resolve(undefined),
} as any);
// Act
const { result } = renderHook(() => useFlyers(), { wrapper });
@@ -120,41 +166,33 @@ describe('useFlyers Hook and FlyersProvider', () => {
expect(result.current.flyersError).toBe(mockError);
});
it('should call fetchNextFlyersPage when the context function is invoked', () => {
// Arrange
mockedUseInfiniteQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
hasNextPage: true,
isRefetching: false,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage, // Pass the mock function
refetch: mockRefetch,
});
const { result } = renderHook(() => useFlyers(), { wrapper });
// Act: Use `act` to wrap state updates.
act(() => {
result.current.fetchNextFlyersPage();
});
// Assert
expect(mockFetchNextPage).toHaveBeenCalledTimes(1);
});
it('should call refetchFlyers when the context function is invoked', () => {
// Arrange
mockedUseInfiniteQuery.mockReturnValue({
mockedUseFlyersQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
hasNextPage: false,
isRefetching: false,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
refetch: mockRefetch,
});
isRefetching: false,
status: 'success',
fetchStatus: 'idle',
isPending: false,
isSuccess: true,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isStale: false,
isPlaceholderData: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isInitialLoading: false,
isLoadingError: false,
isRefetchError: false,
promise: Promise.resolve([]),
} as any);
const { result } = renderHook(() => useFlyers(), { wrapper });
// Act
@@ -165,4 +203,40 @@ describe('useFlyers Hook and FlyersProvider', () => {
// Assert
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
it('should have fetchNextFlyersPage as a no-op (infinite scroll not implemented)', () => {
// Arrange
mockedUseFlyersQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: mockRefetch,
isRefetching: false,
status: 'success',
fetchStatus: 'idle',
isPending: false,
isSuccess: true,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isStale: false,
isPlaceholderData: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isInitialLoading: false,
isLoadingError: false,
isRefetchError: false,
promise: Promise.resolve([]),
} as any);
const { result } = renderHook(() => useFlyers(), { wrapper });
// Act & Assert: fetchNextFlyersPage should exist but be a no-op
expect(result.current.fetchNextFlyersPage).toBeDefined();
expect(typeof result.current.fetchNextFlyersPage).toBe('function');
// Calling it should not throw
expect(() => result.current.fetchNextFlyersPage()).not.toThrow();
});
});

View File

@@ -4,15 +4,15 @@ import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useMasterItems } from './useMasterItems';
import { MasterItemsProvider } from '../providers/MasterItemsProvider';
import { useApiOnMount } from './useApiOnMount';
import { useMasterItemsQuery } from './queries/useMasterItemsQuery';
import type { MasterGroceryItem } from '../types';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
// 1. Mock the useApiOnMount hook, which is the dependency of our provider.
vi.mock('./useApiOnMount');
// 1. Mock the useMasterItemsQuery hook, which is the dependency of our provider.
vi.mock('./queries/useMasterItemsQuery');
// 2. Create a typed mock for type safety and autocompletion.
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
const mockedUseMasterItemsQuery = vi.mocked(useMasterItemsQuery);
// 3. A simple wrapper component that renders our provider.
// This is necessary because the useMasterItems hook needs to be a child of MasterItemsProvider.
@@ -42,13 +42,11 @@ describe('useMasterItems Hook and MasterItemsProvider', () => {
it('should return the initial loading state correctly', () => {
// Arrange: Configure the mocked hook to return a loading state.
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: true,
mockedUseMasterItemsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
isRefetching: false,
reset: vi.fn(),
});
} as any);
// Act: Render the hook within the provider wrapper.
const { result } = renderHook(() => useMasterItems(), { wrapper });
@@ -75,13 +73,11 @@ describe('useMasterItems Hook and MasterItemsProvider', () => {
category_name: 'Bakery',
}),
];
mockedUseApiOnMount.mockReturnValue({
mockedUseMasterItemsQuery.mockReturnValue({
data: mockItems,
loading: false,
isLoading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
} as any);
// Act
const { result } = renderHook(() => useMasterItems(), { wrapper });
@@ -95,13 +91,11 @@ describe('useMasterItems Hook and MasterItemsProvider', () => {
it('should return an error state if the fetch fails', () => {
// Arrange: Mock a failed data fetch.
const mockError = new Error('Failed to fetch master items');
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
mockedUseMasterItemsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: mockError,
isRefetching: false,
reset: vi.fn(),
});
} as any);
// Act
const { result } = renderHook(() => useMasterItems(), { wrapper });

View File

@@ -5,7 +5,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useUserData } from './useUserData';
import { useAuth } from './useAuth';
import { UserDataProvider } from '../providers/UserDataProvider';
import { useApiOnMount } from './useApiOnMount';
import { useWatchedItemsQuery } from './queries/useWatchedItemsQuery';
import { useShoppingListsQuery } from './queries/useShoppingListsQuery';
import type { UserProfile } from '../types';
import {
createMockMasterGroceryItem,
@@ -15,11 +16,13 @@ import {
// 1. Mock the hook's dependencies
vi.mock('../hooks/useAuth');
vi.mock('./useApiOnMount');
vi.mock('./queries/useWatchedItemsQuery');
vi.mock('./queries/useShoppingListsQuery');
// 2. Create typed mocks for type safety and autocompletion
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
const mockedUseWatchedItemsQuery = vi.mocked(useWatchedItemsQuery);
const mockedUseShoppingListsQuery = vi.mocked(useShoppingListsQuery);
// 3. A simple wrapper component that renders our provider.
// This is necessary because the useUserData hook needs to be a child of UserDataProvider.
@@ -71,13 +74,16 @@ describe('useUserData Hook and UserDataProvider', () => {
updateProfile: vi.fn(),
});
// Arrange: Mock the return value of the inner hooks.
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
mockedUseWatchedItemsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
} as any);
mockedUseShoppingListsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
// Act: Render the hook within the provider wrapper.
const { result } = renderHook(() => useUserData(), { wrapper });
@@ -87,10 +93,9 @@ describe('useUserData Hook and UserDataProvider', () => {
expect(result.current.watchedItems).toEqual([]);
expect(result.current.shoppingLists).toEqual([]);
expect(result.current.error).toBeNull();
// Assert: Check that useApiOnMount was called with `enabled: false`.
expect(mockedUseApiOnMount).toHaveBeenCalledWith(expect.any(Function), [null], {
enabled: false,
});
// Assert: Check that queries were disabled (called with false)
expect(mockedUseWatchedItemsQuery).toHaveBeenCalledWith(false);
expect(mockedUseShoppingListsQuery).toHaveBeenCalledWith(false);
});
it('should return loading state when user is authenticated and data is fetching', () => {
@@ -104,21 +109,16 @@ describe('useUserData Hook and UserDataProvider', () => {
updateProfile: vi.fn(),
});
// Arrange: Mock one of the inner hooks to be in a loading state.
mockedUseApiOnMount
.mockReturnValueOnce({
data: null,
loading: true,
error: null,
isRefetching: false,
reset: vi.fn(),
}) // watched items
.mockReturnValueOnce({
data: null,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
}); // shopping lists
mockedUseWatchedItemsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
mockedUseShoppingListsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: null,
} as any);
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
@@ -138,21 +138,16 @@ describe('useUserData Hook and UserDataProvider', () => {
updateProfile: vi.fn(),
});
// Arrange: Mock successful data fetches for both inner hooks.
mockedUseApiOnMount
.mockReturnValueOnce({
data: mockWatchedItems,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
})
.mockReturnValueOnce({
data: mockShoppingLists,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
mockedUseWatchedItemsQuery.mockReturnValue({
data: mockWatchedItems,
isLoading: false,
error: null,
} as any);
mockedUseShoppingListsQuery.mockReturnValue({
data: mockShoppingLists,
isLoading: false,
error: null,
} as any);
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
@@ -178,55 +173,16 @@ describe('useUserData Hook and UserDataProvider', () => {
});
const mockError = new Error('Failed to fetch watched items');
// Arrange: Mock the behavior persistently to handle re-renders.
// We use mockImplementation to return based on call order in a loop or similar,
// OR just use mockReturnValueOnce enough times.
// Since we don't know exact render count, mockImplementation is safer if valid.
// But simplified: assuming 2 hooks called per render.
// reset mocks to be sure
mockedUseApiOnMount.mockReset();
// Define the sequence: 1st call (Watched) -> Error, 2nd call (Shopping) -> Success
// We want this to persist for multiple renders.
mockedUseApiOnMount.mockImplementation((_fn) => {
// We can't easily distinguish based on 'fn' arg without inspecting it,
// but we know the order is Watched then Shopping in the provider.
// A simple toggle approach works if strict order is maintained.
// However, stateless mocks are better.
// Let's fallback to setting up "many" return values.
return { data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() };
});
mockedUseApiOnMount
.mockReturnValueOnce({
data: null,
loading: false,
error: mockError,
isRefetching: false,
reset: vi.fn(),
}) // 1st render: Watched
.mockReturnValueOnce({
data: mockShoppingLists,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
}) // 1st render: Shopping
.mockReturnValueOnce({
data: null,
loading: false,
error: mockError,
isRefetching: false,
reset: vi.fn(),
}) // 2nd render: Watched
.mockReturnValueOnce({
data: mockShoppingLists,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
}); // 2nd render: Shopping
mockedUseWatchedItemsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: mockError,
} as any);
mockedUseShoppingListsQuery.mockReturnValue({
data: mockShoppingLists,
isLoading: false,
error: null,
} as any);
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
@@ -252,21 +208,16 @@ describe('useUserData Hook and UserDataProvider', () => {
logout: vi.fn(),
updateProfile: vi.fn(),
});
mockedUseApiOnMount
.mockReturnValueOnce({
data: mockWatchedItems,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
})
.mockReturnValueOnce({
data: mockShoppingLists,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
mockedUseWatchedItemsQuery.mockReturnValue({
data: mockWatchedItems,
isLoading: false,
error: null,
} as any);
mockedUseShoppingListsQuery.mockReturnValue({
data: mockShoppingLists,
isLoading: false,
error: null,
} as any);
const { result, rerender } = renderHook(() => useUserData(), { wrapper });
await waitFor(() => expect(result.current.watchedItems).not.toEqual([]));
@@ -279,6 +230,18 @@ describe('useUserData Hook and UserDataProvider', () => {
logout: vi.fn(),
updateProfile: vi.fn(),
});
// Update mocks to return empty data for the logged out state
mockedUseWatchedItemsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
mockedUseShoppingListsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
rerender();
// Assert: The data should now be cleared.

View File

@@ -10,6 +10,7 @@ import {
ValidationError,
NotFoundError,
} from '../services/db/errors.db';
import { createMockRequest } from '../tests/utils/createMockRequest';
import type { Logger } from 'pino';
// Create a mock logger that we can inject into requests and assert against.
@@ -271,7 +272,7 @@ describe('errorHandler Middleware', () => {
it('should call next(err) if headers have already been sent', () => {
// Supertest doesn't easily allow simulating res.headersSent = true mid-request
// We need to mock the express response object directly for this specific test.
const mockRequestDirect: Partial<Request> = { path: '/headers-sent-error', method: 'GET' };
const mockRequestDirect = createMockRequest({ path: '/headers-sent-error', method: 'GET' });
const mockResponseDirect: Partial<Response> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Request, Response, NextFunction } from 'express';
import { requireFileUpload } from './fileUpload.middleware';
import { ValidationError } from '../services/db/errors.db';
import { createMockRequest } from '../tests/utils/createMockRequest';
describe('requireFileUpload Middleware', () => {
let mockRequest: Partial<Request>;
@@ -11,7 +12,7 @@ describe('requireFileUpload Middleware', () => {
beforeEach(() => {
// Reset mocks before each test
mockRequest = {};
mockRequest = createMockRequest();
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),

View File

@@ -5,6 +5,7 @@ import type { Request, Response, NextFunction } from 'express';
import { createUploadMiddleware, handleMulterError } from './multer.middleware';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { ValidationError } from '../services/db/errors.db';
import { createMockRequest } from '../tests/utils/createMockRequest';
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
const mocks = vi.hoisted(() => ({
@@ -125,7 +126,7 @@ describe('createUploadMiddleware', () => {
createUploadMiddleware({ storageType: 'avatar' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
const mockReq = { user: mockUser } as unknown as Request;
const mockReq = createMockRequest({ user: mockUser });
storageOptions.filename!(mockReq, mockFile, cb);
@@ -138,7 +139,7 @@ describe('createUploadMiddleware', () => {
createUploadMiddleware({ storageType: 'avatar' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
const mockReq = {} as Request; // No user on request
const mockReq = createMockRequest(); // No user on request
storageOptions.filename!(mockReq, mockFile, cb);
@@ -153,7 +154,7 @@ describe('createUploadMiddleware', () => {
createUploadMiddleware({ storageType: 'avatar' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
const mockReq = { user: mockUser } as unknown as Request;
const mockReq = createMockRequest({ user: mockUser });
storageOptions.filename!(mockReq, mockFile, cb);
@@ -171,7 +172,7 @@ describe('createUploadMiddleware', () => {
createUploadMiddleware({ storageType: 'flyer' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
const mockReq = {} as Request;
const mockReq = createMockRequest();
storageOptions.filename!(mockReq, mockFlyerFile, cb);
@@ -191,7 +192,7 @@ describe('createUploadMiddleware', () => {
createUploadMiddleware({ storageType: 'flyer' });
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
const cb = vi.fn();
const mockReq = {} as Request;
const mockReq = createMockRequest();
storageOptions.filename!(mockReq, mockFlyerFile, cb);
@@ -206,7 +207,7 @@ describe('createUploadMiddleware', () => {
const cb = vi.fn();
const mockImageFile = { mimetype: 'image/png' } as Express.Multer.File;
multerOptions!.fileFilter!({} as Request, mockImageFile, cb);
multerOptions!.fileFilter!(createMockRequest(), mockImageFile, cb);
expect(cb).toHaveBeenCalledWith(null, true);
});
@@ -217,7 +218,7 @@ describe('createUploadMiddleware', () => {
const cb = vi.fn();
const mockTextFile = { mimetype: 'text/plain' } as Express.Multer.File;
multerOptions!.fileFilter!({} as Request, { ...mockTextFile, fieldname: 'test' }, cb);
multerOptions!.fileFilter!(createMockRequest(), { ...mockTextFile, fieldname: 'test' }, cb);
const error = (cb as Mock).mock.calls[0][0];
expect(error).toBeInstanceOf(ValidationError);
@@ -232,7 +233,7 @@ describe('handleMulterError Middleware', () => {
let mockNext: NextFunction;
beforeEach(() => {
mockRequest = {};
mockRequest = createMockRequest();
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),

View File

@@ -4,6 +4,7 @@ import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { validateRequest } from './validation.middleware';
import { ValidationError } from '../services/db/errors.db';
import { createMockRequest } from '../tests/utils/createMockRequest';
describe('validateRequest Middleware', () => {
let mockRequest: Partial<Request>;
@@ -16,11 +17,11 @@ describe('validateRequest Middleware', () => {
// This more accurately mimics the behavior of Express's request objects
// and prevents issues with inherited properties when the middleware
// attempts to delete keys before merging validated data.
mockRequest = {
mockRequest = createMockRequest({
params: Object.create(null),
query: Object.create(null),
body: {},
};
});
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),

View File

@@ -1,21 +1,20 @@
// src/pages/admin/ActivityLog.test.tsx
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ActivityLog } from './ActivityLog';
import * as apiClient from '../../services/apiClient';
import { useActivityLogQuery } from '../../hooks/queries/useActivityLogQuery';
import type { ActivityLogItem, UserProfile } from '../../types';
import { createMockActivityLogItem, createMockUserProfile } from '../../tests/utils/mockFactories';
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
// We can cast it to its mocked type to get type safety and autocompletion.
const mockedApiClient = vi.mocked(apiClient);
// Mock the TanStack Query hook
vi.mock('../../hooks/queries/useActivityLogQuery');
const mockedUseActivityLogQuery = vi.mocked(useActivityLogQuery);
// Mock date-fns to return a consistent value for snapshots
vi.mock('date-fns', () => {
return {
// Only mock the specific function used in the component.
// This avoids potential issues with `importOriginal` in complex mocking scenarios.
formatDistanceToNow: vi.fn(() => 'about 5 hours ago'),
};
});
@@ -55,7 +54,7 @@ const mockLogs: ActivityLogItem[] = [
user_id: 'user-101',
action: 'user_registered',
display_text: 'New user joined',
details: { full_name: 'Newbie User' }, // No avatar provided to test fallback
details: { full_name: 'Newbie User' },
}),
createMockActivityLogItem({
activity_log_id: 5,
@@ -69,7 +68,7 @@ const mockLogs: ActivityLogItem[] = [
createMockActivityLogItem({
activity_log_id: 6,
user_id: 'user-103',
action: 'unknown_action' as any, // Force unknown action to test default case
action: 'unknown_action' as any,
display_text: 'Something happened',
details: {} as any,
}),
@@ -78,6 +77,12 @@ const mockLogs: ActivityLogItem[] = [
describe('ActivityLog', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation
mockedUseActivityLogQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
});
it('should not render if userProfile is null', () => {
@@ -86,108 +91,116 @@ describe('ActivityLog', () => {
});
it('should show a loading state initially', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>((resolve) => {
resolvePromise = resolve;
});
// Cast to any to bypass strict type checking for the mock return value vs Promise
mockedApiClient.fetchActivityLog.mockReturnValue(mockPromise as any);
mockedUseActivityLogQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
expect(screen.getByText('Loading activity...')).toBeInTheDocument();
await act(async () => {
resolvePromise!(new Response(JSON.stringify([])));
});
});
it('should display an error message if fetching logs fails', async () => {
mockedApiClient.fetchActivityLog.mockRejectedValue(new Error('API is down'));
mockedUseActivityLogQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('API is down'),
} as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('API is down')).toBeInTheDocument();
});
expect(screen.getByText('API is down')).toBeInTheDocument();
});
it('should display a message when there are no logs', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseActivityLogQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
});
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
});
it('should render a list of activities successfully covering all types', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
mockedUseActivityLogQuery.mockReturnValue({
data: mockLogs,
isLoading: false,
error: null,
} as any);
render(<ActivityLog userProfile={mockUserProfile} />);
await waitFor(() => {
// Check for specific text from different log types
expect(screen.getByText('Walmart')).toBeInTheDocument(); // From flyer_processed
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument(); // From recipe_created
expect(screen.getByText('Weekly Groceries')).toBeInTheDocument(); // From list_shared
expect(screen.getByText('Newbie User')).toBeInTheDocument(); // From user_registered
expect(screen.getByText('Best Pizza')).toBeInTheDocument(); // From recipe_favorited
expect(screen.getByText('An unknown activity occurred.')).toBeInTheDocument(); // From unknown_action
// Check for user names
expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument();
// Check for specific text from different log types
expect(screen.getByText('Walmart')).toBeInTheDocument();
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument();
expect(screen.getByText('Weekly Groceries')).toBeInTheDocument();
expect(screen.getByText('Newbie User')).toBeInTheDocument();
expect(screen.getByText('Best Pizza')).toBeInTheDocument();
expect(screen.getByText('An unknown activity occurred.')).toBeInTheDocument();
// Check for avatar
const avatar = screen.getByAltText('Test User');
expect(avatar).toBeInTheDocument();
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png');
// Check for user names
expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument();
// Check for fallback avatar (Newbie User has no avatar)
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.
// The container for fallback has specific classes.
// We can look for the container associated with the "Newbie User" item.
const newbieItem = screen.getByText('Newbie User').closest('li');
const fallbackIcon = newbieItem?.querySelector('svg');
expect(fallbackIcon).toBeInTheDocument();
// Check for avatar
const avatar = screen.getByAltText('Test User');
expect(avatar).toBeInTheDocument();
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png');
// Check for the mocked date
expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length);
});
// Check for fallback avatar (Newbie User has no avatar)
const newbieItem = screen.getByText('Newbie User').closest('li');
const fallbackIcon = newbieItem?.querySelector('svg');
expect(fallbackIcon).toBeInTheDocument();
// Check for the mocked date
expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length);
});
it('should call onLogClick when a clickable log item is clicked', async () => {
const onLogClickMock = vi.fn();
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
mockedUseActivityLogQuery.mockReturnValue({
data: mockLogs,
isLoading: false,
error: null,
} as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={onLogClickMock} />);
await waitFor(() => {
// Recipe Created
const clickableRecipe = screen.getByText('Pasta Carbonara');
fireEvent.click(clickableRecipe);
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]);
// Recipe Created
const clickableRecipe = screen.getByText('Pasta Carbonara');
fireEvent.click(clickableRecipe);
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]);
// List Shared
const clickableList = screen.getByText('Weekly Groceries');
fireEvent.click(clickableList);
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]);
// List Shared
const clickableList = screen.getByText('Weekly Groceries');
fireEvent.click(clickableList);
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]);
// Recipe Favorited
const clickableFav = screen.getByText('Best Pizza');
fireEvent.click(clickableFav);
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[4]);
});
// Recipe Favorited
const clickableFav = screen.getByText('Best Pizza');
fireEvent.click(clickableFav);
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[4]);
expect(onLogClickMock).toHaveBeenCalledTimes(3);
});
it('should not render clickable styling if onLogClick is undefined', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
render(<ActivityLog userProfile={mockUserProfile} />); // onLogClick is undefined
mockedUseActivityLogQuery.mockReturnValue({
data: mockLogs,
isLoading: false,
error: null,
} as any);
await waitFor(() => {
const recipeName = screen.getByText('Pasta Carbonara');
expect(recipeName).not.toHaveClass('cursor-pointer');
expect(recipeName).not.toHaveClass('text-blue-500');
render(<ActivityLog userProfile={mockUserProfile} />);
const listName = screen.getByText('Weekly Groceries');
expect(listName).not.toHaveClass('cursor-pointer');
});
const recipeName = screen.getByText('Pasta Carbonara');
expect(recipeName).not.toHaveClass('cursor-pointer');
expect(recipeName).not.toHaveClass('text-blue-500');
const listName = screen.getByText('Weekly Groceries');
expect(listName).not.toHaveClass('cursor-pointer');
});
it('should handle missing details in logs gracefully (fallback values)', async () => {
@@ -197,113 +210,67 @@ describe('ActivityLog', () => {
user_id: 'u1',
action: 'flyer_processed',
display_text: '...',
details: { flyer_id: 1, store_name: '' } as any, // Missing store_name, explicit empty to override mock default
details: { flyer_id: 1, store_name: '' } as any,
}),
createMockActivityLogItem({
activity_log_id: 102,
user_id: 'u2',
action: 'recipe_created',
display_text: '...',
details: { recipe_id: 1, recipe_name: '' } as any, // Missing recipe_name
details: { recipe_id: 1, recipe_name: '' } as any,
}),
createMockActivityLogItem({
activity_log_id: 103,
user_id: 'u3',
action: 'user_registered',
display_text: '...',
details: { full_name: '' } as any, // Missing full_name
details: { full_name: '' } as any,
}),
createMockActivityLogItem({
activity_log_id: 104,
user_id: 'u4',
action: 'recipe_favorited',
display_text: '...',
details: { recipe_id: 2, recipe_name: '' } as any, // Missing recipe_name
details: { recipe_id: 2, recipe_name: '' } as any,
}),
createMockActivityLogItem({
activity_log_id: 105,
user_id: 'u5',
action: 'list_shared',
display_text: '...',
details: { shopping_list_id: 1, list_name: '', shared_with_name: '' } as any, // Missing list_name and shared_with_name
details: { shopping_list_id: 1, list_name: '', shared_with_name: '' } as any,
}),
createMockActivityLogItem({
activity_log_id: 106,
user_id: 'u6',
action: 'flyer_processed',
display_text: '...',
user_avatar_url: 'http://img.com/a.png', // FIX: Moved from details
user_full_name: '', // FIX: Moved from details to test fallback alt text
user_avatar_url: 'http://img.com/a.png',
user_full_name: '',
details: { flyer_id: 2, store_name: 'Mock Store' } as any,
}),
];
mockedApiClient.fetchActivityLog.mockResolvedValue(
new Response(JSON.stringify(logsWithMissingDetails)),
);
mockedUseActivityLogQuery.mockReturnValue({
data: logsWithMissingDetails,
isLoading: false,
error: null,
} as any);
// Debug: verify structure of logs to ensure defaults are overridden
console.log(
'Testing fallback rendering with logs:',
JSON.stringify(logsWithMissingDetails, null, 2),
);
const { container } = render(<ActivityLog userProfile={mockUserProfile} />);
await waitFor(() => {
console.log('[TEST DEBUG] Waiting for UI to update...');
// Use screen.debug to log the current state of the DOM, which is invaluable for debugging.
screen.debug(undefined, 30000);
console.log('[TEST DEBUG] Checking for fallback text elements...');
expect(screen.getAllByText('a store')[0]).toBeInTheDocument();
expect(screen.getByText('Untitled Recipe')).toBeInTheDocument();
expect(screen.getByText('A new user')).toBeInTheDocument();
expect(screen.getByText('a recipe')).toBeInTheDocument();
expect(screen.getByText('a shopping list')).toBeInTheDocument();
expect(screen.getByText('another user')).toBeInTheDocument();
console.log('[TEST DEBUG] All fallback text elements found!');
console.log('[TEST DEBUG] Checking for avatar with fallback alt text...');
// Check for empty alt text on avatar (item 106)
const avatars = screen.getAllByRole('img');
console.log(
'[TEST DEBUG] Found avatars with alts:',
avatars.map((img) => img.getAttribute('alt')),
);
const avatarWithFallbackAlt = avatars.find(
(img) => img.getAttribute('alt') === 'User Avatar',
);
expect(avatarWithFallbackAlt).toBeInTheDocument();
console.log('[TEST DEBUG] Fallback avatar with correct alt text found!');
});
});
it('should display error message from API response when not OK', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue(
new Response(JSON.stringify({ message: 'Server says no' }), { status: 500 }),
);
render(<ActivityLog userProfile={mockUserProfile} />);
await waitFor(() => {
expect(screen.getByText('Server says no')).toBeInTheDocument();
});
});
it('should display default error message from API response when not OK and no message provided', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue(
new Response(JSON.stringify({}), { status: 500 }),
expect(screen.getAllByText('a store')[0]).toBeInTheDocument();
expect(screen.getByText('Untitled Recipe')).toBeInTheDocument();
expect(screen.getByText('A new user')).toBeInTheDocument();
expect(screen.getByText('a recipe')).toBeInTheDocument();
expect(screen.getByText('a shopping list')).toBeInTheDocument();
expect(screen.getByText('another user')).toBeInTheDocument();
// Check for avatar with fallback alt text
const avatars = screen.getAllByRole('img');
const avatarWithFallbackAlt = avatars.find(
(img) => img.getAttribute('alt') === 'User Avatar',
);
render(<ActivityLog userProfile={mockUserProfile} />);
await waitFor(() => {
expect(screen.getByText('Failed to fetch logs')).toBeInTheDocument();
});
});
it('should display generic error message when fetch throws non-Error object', async () => {
mockedApiClient.fetchActivityLog.mockRejectedValue('String error');
render(<ActivityLog userProfile={mockUserProfile} />);
await waitFor(() => {
expect(screen.getByText('Failed to load activity.')).toBeInTheDocument();
});
expect(avatarWithFallbackAlt).toBeInTheDocument();
});
});

View File

@@ -1,16 +1,18 @@
// src/pages/admin/AdminStatsPage.test.tsx
import React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminStatsPage } from './AdminStatsPage';
import * as apiClient from '../../services/apiClient';
import { useApplicationStatsQuery } from '../../hooks/queries/useApplicationStatsQuery';
import type { AppStats } from '../../services/apiClient';
import { createMockAppStats } from '../../tests/utils/mockFactories';
import { StatCard } from '../../components/StatCard';
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
const mockedApiClient = vi.mocked(apiClient);
// Mock the TanStack Query hook
vi.mock('../../hooks/queries/useApplicationStatsQuery');
const mockedUseApplicationStatsQuery = vi.mocked(useApplicationStatsQuery);
// Mock the child StatCard component to use the shared mock and allow spying
vi.mock('../../components/StatCard', async () => {
@@ -34,36 +36,24 @@ describe('AdminStatsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedStatCard.mockClear();
// Default mock implementation
mockedUseApplicationStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: null,
} as any);
});
it('should render a loading spinner while fetching stats', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>((resolve) => {
resolvePromise = resolve;
});
// Cast to any to bypass strict type checking for the mock return value vs Promise
mockedApiClient.getApplicationStats.mockReturnValue(mockPromise as any);
mockedUseApplicationStatsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
renderWithRouter();
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
await act(async () => {
resolvePromise!(
new Response(
JSON.stringify(
createMockAppStats({
userCount: 0,
flyerCount: 0,
flyerItemCount: 0,
storeCount: 0,
pendingCorrectionCount: 0,
recipeCount: 0,
}),
),
),
);
});
});
it('should display stats cards when data is fetched successfully', async () => {
@@ -75,29 +65,31 @@ describe('AdminStatsPage', () => {
pendingCorrectionCount: 5,
recipeCount: 150,
});
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats)));
mockedUseApplicationStatsQuery.mockReturnValue({
data: mockStats,
isLoading: false,
error: null,
} as any);
renderWithRouter();
// Wait for the stats to be displayed
await waitFor(() => {
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('Flyers Processed')).toBeInTheDocument();
expect(screen.getByText('456')).toBeInTheDocument();
expect(screen.getByText('Flyers Processed')).toBeInTheDocument();
expect(screen.getByText('456')).toBeInTheDocument();
expect(screen.getByText('Total Flyer Items')).toBeInTheDocument();
expect(screen.getByText('7,890')).toBeInTheDocument(); // Note: toLocaleString() adds a comma
expect(screen.getByText('Total Flyer Items')).toBeInTheDocument();
expect(screen.getByText('7,890')).toBeInTheDocument();
expect(screen.getByText('Stores Tracked')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.getByText('Stores Tracked')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.getByText('Pending Corrections')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('Pending Corrections')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('Total Recipes')).toBeInTheDocument();
expect(screen.getByText('150')).toBeInTheDocument();
});
expect(screen.getByText('Total Recipes')).toBeInTheDocument();
expect(screen.getByText('150')).toBeInTheDocument();
});
it('should pass the correct props to each StatCard component', async () => {
@@ -109,16 +101,15 @@ describe('AdminStatsPage', () => {
pendingCorrectionCount: 5,
recipeCount: 150,
});
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats)));
mockedUseApplicationStatsQuery.mockReturnValue({
data: mockStats,
isLoading: false,
error: null,
} as any);
renderWithRouter();
await waitFor(() => {
// Wait for the component to have been called at least once
expect(mockedStatCard).toHaveBeenCalled();
});
// Verify it was called 5 times, once for each stat
// Verify it was called 6 times, once for each stat
expect(mockedStatCard).toHaveBeenCalledTimes(6);
// Check props for each card individually for robustness
@@ -173,15 +164,18 @@ describe('AdminStatsPage', () => {
flyerItemCount: 123456789,
recipeCount: 50000,
});
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats)));
mockedUseApplicationStatsQuery.mockReturnValue({
data: mockStats,
isLoading: false,
error: null,
} as any);
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('1,234,567')).toBeInTheDocument();
expect(screen.getByText('9,876')).toBeInTheDocument();
expect(screen.getByText('123,456,789')).toBeInTheDocument();
expect(screen.getByText('50,000')).toBeInTheDocument();
});
expect(screen.getByText('1,234,567')).toBeInTheDocument();
expect(screen.getByText('9,876')).toBeInTheDocument();
expect(screen.getByText('123,456,789')).toBeInTheDocument();
expect(screen.getByText('50,000')).toBeInTheDocument();
});
it('should correctly display zero values for all stats', async () => {
@@ -193,49 +187,46 @@ describe('AdminStatsPage', () => {
pendingCorrectionCount: 0,
recipeCount: 0,
});
mockedApiClient.getApplicationStats.mockResolvedValue(
new Response(JSON.stringify(mockZeroStats)),
);
mockedUseApplicationStatsQuery.mockReturnValue({
data: mockZeroStats,
isLoading: false,
error: null,
} as any);
renderWithRouter();
await waitFor(() => {
// `getAllByText` will find all instances of '0'. There should be 5.
const zeroValueElements = screen.getAllByText('0');
expect(zeroValueElements).toHaveLength(6);
// `getAllByText` will find all instances of '0'. There should be 6.
const zeroValueElements = screen.getAllByText('0');
expect(zeroValueElements).toHaveLength(6);
// Also check that the titles are present to be sure we have the cards.
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('Pending Corrections')).toBeInTheDocument();
});
// Also check that the titles are present to be sure we have the cards.
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('Pending Corrections')).toBeInTheDocument();
});
it('should display an error message if fetching stats fails', async () => {
const errorMessage = 'Failed to connect to the database.';
mockedApiClient.getApplicationStats.mockRejectedValue(new Error(errorMessage));
mockedUseApplicationStatsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error(errorMessage),
} as any);
renderWithRouter();
// Wait for the error message to appear
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should display a generic error message for unknown errors', async () => {
mockedApiClient.getApplicationStats.mockRejectedValue('Unknown error object');
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
});
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it('should render a link back to the admin dashboard', async () => {
mockedApiClient.getApplicationStats.mockResolvedValue(
new Response(JSON.stringify(createMockAppStats())),
);
mockedUseApplicationStatsQuery.mockReturnValue({
data: createMockAppStats(),
isLoading: false,
error: null,
} as any);
renderWithRouter();
const link = await screen.findByRole('link', { name: /back to admin dashboard/i });
const link = screen.getByRole('link', { name: /back to admin dashboard/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/admin');
});

View File

@@ -1,10 +1,12 @@
// src/pages/admin/CorrectionsPage.test.tsx
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { CorrectionsPage } from './CorrectionsPage';
import * as apiClient from '../../services/apiClient';
import { useSuggestedCorrectionsQuery } from '../../hooks/queries/useSuggestedCorrectionsQuery';
import { useMasterItemsQuery } from '../../hooks/queries/useMasterItemsQuery';
import { useCategoriesQuery } from '../../hooks/queries/useCategoriesQuery';
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../types';
import {
createMockSuggestedCorrection,
@@ -12,11 +14,16 @@ import {
createMockCategory,
} from '../../tests/utils/mockFactories';
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
const mockedApiClient = vi.mocked(apiClient);
// Mock the TanStack Query hooks
vi.mock('../../hooks/queries/useSuggestedCorrectionsQuery');
vi.mock('../../hooks/queries/useMasterItemsQuery');
vi.mock('../../hooks/queries/useCategoriesQuery');
const mockedUseSuggestedCorrectionsQuery = vi.mocked(useSuggestedCorrectionsQuery);
const mockedUseMasterItemsQuery = vi.mocked(useMasterItemsQuery);
const mockedUseCategoriesQuery = vi.mocked(useCategoriesQuery);
// Mock the child CorrectionRow component to isolate the test to the page itself
// The CorrectionRow component is now located in a sub-directory.
vi.mock('./components/CorrectionRow', async () => {
const { MockCorrectionRow } = await import('../../tests/utils/componentMocks');
return { CorrectionRow: MockCorrectionRow };
@@ -61,169 +68,170 @@ describe('CorrectionsPage', () => {
}),
];
const mockCategories: Category[] = [createMockCategory({ category_id: 1, name: 'Produce' })];
const mockRefetch = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementations for the hooks
mockedUseSuggestedCorrectionsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: mockRefetch,
} as any);
mockedUseMasterItemsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
mockedUseCategoriesQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
});
it('should render a loading spinner while fetching data', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>((resolve) => {
resolvePromise = resolve;
});
// Cast to any to bypass strict type checking for the mock return value vs Promise
mockedApiClient.getSuggestedCorrections.mockReturnValue(mockPromise as any);
// Mock other calls to resolve immediately so Promise.all waits on the one we control
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify([])));
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseSuggestedCorrectionsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
refetch: mockRefetch,
} as any);
renderWithRouter();
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
await act(async () => {
resolvePromise!(new Response(JSON.stringify([])));
});
});
it('should display corrections when data is fetched successfully', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue(
new Response(JSON.stringify(mockCorrections)),
);
mockedApiClient.fetchMasterItems.mockResolvedValue(
new Response(JSON.stringify(mockMasterItems)),
);
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
mockedUseSuggestedCorrectionsQuery.mockReturnValue({
data: mockCorrections,
isLoading: false,
error: null,
refetch: mockRefetch,
} as any);
mockedUseMasterItemsQuery.mockReturnValue({
data: mockMasterItems,
isLoading: false,
error: null,
} as any);
mockedUseCategoriesQuery.mockReturnValue({
data: mockCategories,
isLoading: false,
error: null,
} as any);
renderWithRouter();
await waitFor(() => {
// Check for the mocked CorrectionRow components
expect(screen.getByTestId('correction-row-1')).toBeInTheDocument(); // This will now use suggested_correction_id
expect(screen.getByTestId('correction-row-2')).toBeInTheDocument(); // This will now use suggested_correction_id
// Check for the text content within the mocked rows
expect(screen.getByText('Bananas')).toBeInTheDocument();
expect(screen.getByText('Apples')).toBeInTheDocument();
});
// Check for the mocked CorrectionRow components
expect(screen.getByTestId('correction-row-1')).toBeInTheDocument();
expect(screen.getByTestId('correction-row-2')).toBeInTheDocument();
// Check for the text content within the mocked rows
expect(screen.getByText('Bananas')).toBeInTheDocument();
expect(screen.getByText('Apples')).toBeInTheDocument();
});
it('should display a message when there are no pending corrections', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify([])));
mockedApiClient.fetchMasterItems.mockResolvedValue(
new Response(JSON.stringify(mockMasterItems)),
);
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
mockedUseSuggestedCorrectionsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: mockRefetch,
} as any);
mockedUseMasterItemsQuery.mockReturnValue({
data: mockMasterItems,
isLoading: false,
error: null,
} as any);
mockedUseCategoriesQuery.mockReturnValue({
data: mockCategories,
isLoading: false,
error: null,
} as any);
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/no pending corrections. great job!/i)).toBeInTheDocument();
});
expect(screen.getByText(/no pending corrections. great job!/i)).toBeInTheDocument();
});
it('should display an error message if fetching corrections fails', async () => {
const errorMessage = 'Network Error: Failed to fetch';
mockedApiClient.getSuggestedCorrections.mockRejectedValue(new Error(errorMessage));
mockedApiClient.fetchMasterItems.mockResolvedValue(
new Response(JSON.stringify(mockMasterItems)),
);
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
mockedUseSuggestedCorrectionsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error(errorMessage),
refetch: mockRefetch,
} as any);
mockedUseMasterItemsQuery.mockReturnValue({
data: mockMasterItems,
isLoading: false,
error: null,
} as any);
mockedUseCategoriesQuery.mockReturnValue({
data: mockCategories,
isLoading: false,
error: null,
} as any);
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it('should display an error message if fetching master items fails', async () => {
const errorMessage = 'Could not retrieve master items list.';
mockedApiClient.getSuggestedCorrections.mockResolvedValue(
new Response(JSON.stringify(mockCorrections)),
);
mockedApiClient.fetchMasterItems.mockRejectedValue(new Error(errorMessage));
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should display an error message if fetching categories fails', async () => {
const errorMessage = 'Could not retrieve categories.';
mockedApiClient.getSuggestedCorrections.mockResolvedValue(
new Response(JSON.stringify(mockCorrections)),
);
mockedApiClient.fetchMasterItems.mockResolvedValue(
new Response(JSON.stringify(mockMasterItems)),
);
mockedApiClient.fetchCategories.mockRejectedValue(new Error(errorMessage));
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should handle unknown errors gracefully', async () => {
mockedApiClient.getSuggestedCorrections.mockRejectedValue('Unknown string error');
mockedApiClient.fetchMasterItems.mockResolvedValue(
new Response(JSON.stringify(mockMasterItems)),
);
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
renderWithRouter();
await waitFor(() => {
expect(
screen.getByText('An unknown error occurred while fetching corrections.'),
).toBeInTheDocument();
});
});
it('should refresh corrections when the refresh button is clicked', async () => {
// Mock the initial data load
mockedApiClient.getSuggestedCorrections.mockResolvedValue(
new Response(JSON.stringify(mockCorrections)),
);
mockedApiClient.fetchMasterItems.mockResolvedValue(
new Response(JSON.stringify(mockMasterItems)),
);
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
it('should call refetch when the refresh button is clicked', async () => {
mockedUseSuggestedCorrectionsQuery.mockReturnValue({
data: mockCorrections,
isLoading: false,
error: null,
refetch: mockRefetch,
} as any);
mockedUseMasterItemsQuery.mockReturnValue({
data: mockMasterItems,
isLoading: false,
error: null,
} as any);
mockedUseCategoriesQuery.mockReturnValue({
data: mockCategories,
isLoading: false,
error: null,
} as any);
renderWithRouter();
// Wait for the initial data to be rendered
await waitFor(() => expect(screen.getByText('Bananas')).toBeInTheDocument());
// All APIs should have been called once on initial load
expect(mockedApiClient.getSuggestedCorrections).toHaveBeenCalledTimes(1);
expect(mockedApiClient.fetchMasterItems).toHaveBeenCalledTimes(1);
expect(mockedApiClient.fetchCategories).toHaveBeenCalledTimes(1);
expect(screen.getByText('Bananas')).toBeInTheDocument();
// Click refresh
const refreshButton = screen.getByTitle('Refresh Corrections');
fireEvent.click(refreshButton);
// Wait for the APIs to be called a second time
await waitFor(() => expect(mockedApiClient.getSuggestedCorrections).toHaveBeenCalledTimes(2));
expect(mockedApiClient.fetchMasterItems).toHaveBeenCalledTimes(2);
expect(mockedApiClient.fetchCategories).toHaveBeenCalledTimes(2);
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
it('should remove a correction from the list when processed', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue(
new Response(JSON.stringify(mockCorrections)),
);
mockedApiClient.fetchMasterItems.mockResolvedValue(
new Response(JSON.stringify(mockMasterItems)),
);
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
it('should call onProcessed callback when a correction is processed', async () => {
mockedUseSuggestedCorrectionsQuery.mockReturnValue({
data: mockCorrections,
isLoading: false,
error: null,
refetch: mockRefetch,
} as any);
mockedUseMasterItemsQuery.mockReturnValue({
data: mockMasterItems,
isLoading: false,
error: null,
} as any);
mockedUseCategoriesQuery.mockReturnValue({
data: mockCategories,
isLoading: false,
error: null,
} as any);
renderWithRouter();
await waitFor(() => expect(screen.getByTestId('correction-row-1')).toBeInTheDocument());
expect(screen.getByTestId('correction-row-1')).toBeInTheDocument();
// Click the process button in the mock row for ID 1
fireEvent.click(screen.getByTestId('process-btn-1'));
// It should disappear
await waitFor(() => expect(screen.queryByTestId('correction-row-1')).not.toBeInTheDocument());
expect(screen.getByTestId('correction-row-2')).toBeInTheDocument();
// The onProcessed callback should trigger a refetch
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,6 +8,7 @@ import { z } from 'zod';
import * as db from '../services/db/index.db';
import type { UserProfile } from '../types';
import { geocodingService } from '../services/geocodingService.server';
import { cacheService } from '../services/cacheService.server';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import {
createUploadMiddleware,
@@ -635,6 +636,44 @@ router.post(
},
);
/**
* POST /api/admin/system/clear-cache - Clears the application data cache.
* Clears cached flyers, brands, and stats data from Redis.
* Requires admin privileges.
*/
router.post(
'/system/clear-cache',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
req.log.info(
`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`,
);
try {
const [flyersDeleted, brandsDeleted, statsDeleted] = await Promise.all([
cacheService.invalidateFlyers(req.log),
cacheService.invalidateBrands(req.log),
cacheService.invalidateStats(req.log),
]);
const totalDeleted = flyersDeleted + brandsDeleted + statsDeleted;
res.status(200).json({
message: `Successfully cleared the application cache. ${totalDeleted} keys were removed.`,
details: {
flyers: flyersDeleted,
brands: brandsDeleted,
stats: statsDeleted,
},
});
} catch (error) {
req.log.error({ error }, '[Admin] Failed to clear application cache.');
next(error);
}
},
);
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);

View File

@@ -13,7 +13,7 @@ import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
// Removed: import { logger } from '../services/logger.server'; // This was a duplicate, fixed.
import { logger } from '../services/logger.server'; // Needed for module-level logging (e.g., Zod schema transforms)
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { UserProfile } from '../types'; // This was a duplicate, fixed.
// All route handlers now use req.log (request-scoped logger) as per ADR-004
@@ -72,7 +72,8 @@ const rescanAreaSchema = z.object({
return JSON.parse(val);
} catch (err) {
// Log the actual parsing error for better debugging if invalid JSON is sent.
req.log.warn(
// Using module-level logger since Zod transforms don't have access to request context
logger.warn(
{ error: errMsg(err), receivedValue: val },
'Failed to parse cropArea in rescanAreaSchema',
);
@@ -233,6 +234,9 @@ router.post(
* POST /api/ai/upload-legacy - Process a flyer upload from a legacy client.
* This is an authenticated route that processes the flyer synchronously.
* This is used for integration testing the legacy upload flow.
*
* @deprecated Use POST /api/ai/upload-and-process instead for async queue-based processing (ADR-0006).
* This synchronous endpoint is retained only for integration testing purposes.
*/
router.post(
'/upload-legacy',
@@ -281,9 +285,12 @@ router.get(
);
/**
* This endpoint saves the processed flyer data to the database. It is the final step
* in the flyer upload workflow after the AI has extracted the data.
* POST /api/ai/flyers/process - Saves the processed flyer data to the database.
* This is the final step in the flyer upload workflow after the AI has extracted the data.
* It uses `optionalAuth` to handle submissions from both anonymous and authenticated users.
*
* @deprecated Use POST /api/ai/upload-and-process instead for async queue-based processing (ADR-0006).
* This synchronous endpoint processes flyers inline and should be migrated to the queue-based approach.
*/
router.post(
'/flyers/process',

View File

@@ -94,33 +94,17 @@ vi.mock('../services/emailService.server', () => ({
import authRouter from './auth.routes';
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
import { createTestApp } from '../tests/utils/createTestApp';
// --- 4. App Setup ---
// We need to inject cookie-parser BEFORE the router is mounted.
// Since createTestApp mounts the router immediately, we pass middleware to it if supported,
// or we construct the app manually here to ensure correct order.
// Assuming createTestApp doesn't support pre-middleware injection easily, we will
// create a standard express app here for full control, or modify createTestApp usage if possible.
// Looking at createTestApp.ts (inferred), it likely doesn't take middleware.
// Let's manually build the app for this test file to ensure cookieParser runs first.
import express from 'express';
import { errorHandler } from '../middleware/errorHandler'; // Assuming this exists
const { mockLogger } = await import('../tests/utils/mockLogger');
const app = express();
app.use(express.json());
app.use(cookieParser()); // Mount BEFORE router
// Middleware to inject the mock logger into req
app.use((req, res, next) => {
req.log = mockLogger;
next();
// --- 4. App Setup using createTestApp ---
const app = createTestApp({
router: authRouter,
basePath: '/api/auth',
// Inject cookieParser via the new middleware option
middleware: [cookieParser()],
});
app.use('/api/auth', authRouter);
app.use(errorHandler); // Mount AFTER router
const { mockLogger } = await import('../tests/utils/mockLogger');
// --- 5. Tests ---
describe('Auth Routes (/api/auth)', () => {

View File

@@ -56,6 +56,7 @@ import {
createMockUserProfile,
createMockUserWithPasswordHash,
} from '../tests/utils/mockFactories';
import { createMockRequest } from '../tests/utils/createMockRequest';
// Mock dependencies before importing the passport configuration
vi.mock('../services/db/index.db', () => ({
@@ -112,7 +113,7 @@ describe('Passport Configuration', () => {
describe('LocalStrategy (Isolated Callback Logic)', () => {
// FIX: mockReq needs a 'log' property because the implementation uses req.log
const mockReq = { ip: '127.0.0.1', log: logger } as unknown as Request;
const mockReq = createMockRequest({ ip: '127.0.0.1' });
const done = vi.fn();
it('should call done(null, user) on successful authentication', async () => {
@@ -454,12 +455,12 @@ describe('Passport Configuration', () => {
it('should call next() if user has "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
const mockReq = createMockRequest({
user: createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-id', email: 'admin@test.com' },
}),
};
});
// Act
isAdmin(mockReq as Request, mockRes as Response, mockNext);
@@ -471,12 +472,12 @@ describe('Passport Configuration', () => {
it('should call next with a ForbiddenError if user does not have "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
const mockReq = createMockRequest({
user: createMockUserProfile({
role: 'user',
user: { user_id: 'user-id', email: 'user@test.com' },
}),
};
});
// Act
isAdmin(mockReq as Request, mockRes as Response, mockNext);
@@ -488,7 +489,7 @@ describe('Passport Configuration', () => {
it('should call next with a ForbiddenError if req.user is missing', () => {
// Arrange
const mockReq = {} as Request; // No req.user
const mockReq = createMockRequest(); // No req.user
// Act
isAdmin(mockReq, mockRes as Response, mockNext);
@@ -500,12 +501,12 @@ describe('Passport Configuration', () => {
it('should log a warning when a non-admin user tries to access an admin route', () => {
// Arrange
const mockReq: Partial<Request> = {
const mockReq = createMockRequest({
user: createMockUserProfile({
role: 'user',
user: { user_id: 'user-id-123', email: 'user@test.com' },
}),
};
});
// Act
isAdmin(mockReq as Request, mockRes as Response, mockNext);
@@ -516,7 +517,7 @@ describe('Passport Configuration', () => {
it('should log a warning with "unknown" user when req.user is missing', () => {
// Arrange
const mockReq = {} as Request; // No req.user
const mockReq = createMockRequest(); // No req.user
// Act
isAdmin(mockReq, mockRes as Response, mockNext);
@@ -533,37 +534,37 @@ describe('Passport Configuration', () => {
};
// Case 1: user is not an object (e.g., a string)
const req1 = { user: 'not-an-object' } as unknown as Request;
const req1 = createMockRequest({ user: 'not-an-object' } as any);
isAdmin(req1, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 2: user is null
const req2 = { user: null } as unknown as Request;
const req2 = createMockRequest({ user: null } as any);
isAdmin(req2, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 3: user object is missing 'user' property
const req3 = { user: { role: 'admin' } } as unknown as Request;
const req3 = createMockRequest({ user: { role: 'admin' } } as any);
isAdmin(req3, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 4: user.user is not an object
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
const req4 = createMockRequest({ user: { role: 'admin', user: 'not-an-object' } } as any);
isAdmin(req4, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
vi.clearAllMocks();
// Case 5: user.user is missing 'user_id'
const req5 = {
user: { role: 'admin', user: { email: 'test@test.com' } },
} as unknown as Request;
const req5 = createMockRequest({
user: { role: 'admin', user: { email: 'test@test.com' } } as any,
});
isAdmin(req5, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
expect(mockRes.status).not.toHaveBeenCalled();
@@ -575,12 +576,12 @@ describe('Passport Configuration', () => {
it('should call next with a ForbiddenError if req.user is not a valid UserProfile object', () => {
// Arrange
const mockReq: Partial<Request> = {
const mockReq = createMockRequest({
// An object that is not a valid UserProfile (e.g., missing 'role')
user: {
user: { user_id: 'invalid-user-id' }, // Missing 'role' property
} as unknown as UserProfile, // Cast to UserProfile to satisfy req.user type, but it's intentionally malformed
};
});
// Act
isAdmin(mockReq as Request, mockRes as Response, mockNext);
@@ -601,7 +602,7 @@ describe('Passport Configuration', () => {
it('should populate req.user and call next() if authentication succeeds', () => {
// Arrange
const mockReq = {} as Request;
const mockReq = createMockRequest();
const mockUser = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-id', email: 'admin@test.com' },
@@ -621,7 +622,7 @@ describe('Passport Configuration', () => {
it('should not populate req.user and still call next() if authentication fails', () => {
// Arrange
const mockReq = {} as Request;
const mockReq = createMockRequest();
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, false, undefined),
);
@@ -634,7 +635,7 @@ describe('Passport Configuration', () => {
it('should log info and call next() if authentication provides an info message', () => {
// Arrange
const mockReq = {} as Request;
const mockReq = createMockRequest();
const mockInfo = { message: 'Token expired' };
// Mock passport.authenticate to call its callback with an info object
vi.mocked(passport.authenticate).mockImplementation(
@@ -652,7 +653,7 @@ describe('Passport Configuration', () => {
it('should log info and call next() if authentication provides an info Error object', () => {
// Arrange
const mockReq = {} as Request;
const mockReq = createMockRequest();
const mockInfoError = new Error('Token is malformed');
// Mock passport.authenticate to call its callback with an info object
vi.mocked(passport.authenticate).mockImplementation(
@@ -673,7 +674,7 @@ describe('Passport Configuration', () => {
it('should log info.toString() if info object has no message property', () => {
// Arrange
const mockReq = {} as Request;
const mockReq = createMockRequest();
const mockInfo = { custom: 'some info' };
// Mock passport.authenticate to call its callback with a custom info object
vi.mocked(passport.authenticate).mockImplementation(
@@ -693,7 +694,7 @@ describe('Passport Configuration', () => {
it('should call next() and not populate user if passport returns an error', () => {
// Arrange
const mockReq = {} as Request;
const mockReq = createMockRequest();
const authError = new Error('Malformed token');
// Mock passport.authenticate to call its callback with an error
vi.mocked(passport.authenticate).mockImplementation(
@@ -729,7 +730,7 @@ describe('Passport Configuration', () => {
it('should attach a mock admin user to req when NODE_ENV is "test"', () => {
// Arrange
vi.stubEnv('NODE_ENV', 'test');
const mockReq = {} as Request;
const mockReq = createMockRequest();
// Act
mockAuth(mockReq, mockRes as Response, mockNext);
@@ -743,7 +744,7 @@ describe('Passport Configuration', () => {
it('should do nothing and call next() when NODE_ENV is not "test"', () => {
// Arrange
vi.stubEnv('NODE_ENV', 'production');
const mockReq = {} as Request;
const mockReq = createMockRequest();
// Act
mockAuth(mockReq, mockRes as Response, mockNext);

View File

@@ -11,7 +11,7 @@ import * as bcrypt from 'bcrypt';
import { Request, Response, NextFunction } from 'express';
import * as db from '../services/db/index.db';
// Removed: import { logger } from '../services/logger.server';
import { logger } from '../services/logger.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { UserProfile } from '../types';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
@@ -271,12 +271,12 @@ const jwtOptions = {
if (!JWT_SECRET) {
logger.fatal('[Passport] CRITICAL: JWT_SECRET is missing or empty in environment variables! JwtStrategy will fail.');
} else {
req.log.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`);
logger.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`);
}
passport.use(
new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
req.log.debug(
logger.debug(
{ jwt_payload: jwt_payload ? { user_id: jwt_payload.user_id } : 'null' },
'[JWT Strategy] Verifying token payload:',
);
@@ -286,18 +286,18 @@ passport.use(
const userProfile = await db.userRepo.findUserProfileById(jwt_payload.user_id, logger);
// --- JWT STRATEGY DEBUG LOGGING ---
req.log.debug(
logger.debug(
`[JWT Strategy] DB lookup for user ID ${jwt_payload.user_id} result: ${userProfile ? 'FOUND' : 'NOT FOUND'}`,
);
if (userProfile) {
return done(null, userProfile); // User profile object will be available as req.user in protected routes
} else {
req.log.warn(`JWT authentication failed: user with ID ${jwt_payload.user_id} not found.`);
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.user_id} not found.`);
return done(null, false); // User not found or invalid token
}
} catch (err: unknown) {
req.log.error({ error: err }, 'Error during JWT authentication strategy:');
logger.error({ error: err }, 'Error during JWT authentication strategy:');
return done(err, false);
}
}),

View File

@@ -0,0 +1,168 @@
// src/schemas/flyer.schemas.test.ts
import { describe, it, expect } from 'vitest';
import { flyerInsertSchema, flyerDbInsertSchema } from './flyer.schemas';
describe('flyerInsertSchema', () => {
const validFlyer = {
file_name: 'flyer.jpg',
image_url: 'https://example.com/flyer.jpg',
icon_url: 'https://example.com/icon.jpg',
checksum: 'a'.repeat(64),
store_name: 'Test Store',
valid_from: '2023-01-01T00:00:00Z',
valid_to: '2023-01-07T00:00:00Z',
store_address: '123 Main St',
status: 'processed',
item_count: 10,
uploaded_by: '123e4567-e89b-12d3-a456-426614174000',
};
it('should validate a correct flyer object', () => {
const result = flyerInsertSchema.safeParse(validFlyer);
expect(result.success).toBe(true);
});
it('should fail if file_name is missing or empty', () => {
const invalid = { ...validFlyer, file_name: '' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('File name is required');
}
});
it('should fail if image_url is invalid', () => {
const invalid = { ...validFlyer, image_url: 'ftp://invalid.com' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(
'Flyer image URL must be a valid HTTP or HTTPS URL',
);
}
});
it('should fail if icon_url is invalid', () => {
const invalid = { ...validFlyer, icon_url: 'not-a-url' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should fail if checksum length is incorrect', () => {
const invalid = { ...validFlyer, checksum: 'abc' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Checksum must be 64 characters');
}
});
it('should fail if checksum is not hex', () => {
const invalid = { ...validFlyer, checksum: 'z'.repeat(64) };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Checksum must be a valid hexadecimal string');
}
});
it('should allow null checksum', () => {
const valid = { ...validFlyer, checksum: null };
const result = flyerInsertSchema.safeParse(valid);
expect(result.success).toBe(true);
});
it('should fail if store_name is missing', () => {
const invalid = { ...validFlyer, store_name: '' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should validate valid_from and valid_to as datetimes', () => {
const invalid = { ...validFlyer, valid_from: 'not-a-date' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should allow null valid_from, valid_to, store_address', () => {
const valid = {
...validFlyer,
valid_from: null,
valid_to: null,
store_address: null,
};
const result = flyerInsertSchema.safeParse(valid);
expect(result.success).toBe(true);
});
it('should validate status enum', () => {
const invalid = { ...validFlyer, status: 'invalid_status' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should fail if item_count is negative', () => {
const invalid = { ...validFlyer, item_count: -1 };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Item count must be non-negative');
}
});
it('should validate uploaded_by as UUID if present', () => {
const invalid = { ...validFlyer, uploaded_by: 'not-a-uuid' };
const result = flyerInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should allow null or undefined uploaded_by', () => {
const validNull = { ...validFlyer, uploaded_by: null };
expect(flyerInsertSchema.safeParse(validNull).success).toBe(true);
const validUndefined = { ...validFlyer, uploaded_by: undefined };
expect(flyerInsertSchema.safeParse(validUndefined).success).toBe(true);
});
});
describe('flyerDbInsertSchema', () => {
const validDbFlyer = {
file_name: 'flyer.jpg',
image_url: 'https://example.com/flyer.jpg',
icon_url: 'https://example.com/icon.jpg',
checksum: 'a'.repeat(64),
store_id: 1,
valid_from: '2023-01-01T00:00:00Z',
valid_to: '2023-01-07T00:00:00Z',
store_address: '123 Main St',
status: 'processed',
item_count: 10,
uploaded_by: '123e4567-e89b-12d3-a456-426614174000',
};
it('should validate a correct DB flyer object', () => {
const result = flyerDbInsertSchema.safeParse(validDbFlyer);
expect(result.success).toBe(true);
});
it('should fail if store_id is missing', () => {
const { store_id, ...invalid } = validDbFlyer;
const result = flyerDbInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should fail if store_id is not positive', () => {
const invalid = { ...validDbFlyer, store_id: 0 };
const result = flyerDbInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Store ID must be a positive integer');
}
});
it('should fail if store_id is not an integer', () => {
const invalid = { ...validDbFlyer, store_id: 1.5 };
const result = flyerDbInsertSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,226 @@
// src/services/cacheService.server.ts
/**
* @file Centralized caching service implementing the Cache-Aside pattern.
* This service provides a reusable wrapper around Redis for caching read-heavy operations.
* See ADR-009 for the caching strategy documentation.
*/
import type { Logger } from 'pino';
import { connection as redis } from './redis.server';
import { logger as globalLogger } from './logger.server';
/**
* TTL values in seconds for different cache types.
* These can be tuned based on data volatility and freshness requirements.
*/
export const CACHE_TTL = {
/** Brand/store list - rarely changes, safe to cache for 1 hour */
BRANDS: 60 * 60,
/** Flyer list - changes when new flyers are added, cache for 5 minutes */
FLYERS: 5 * 60,
/** Individual flyer data - cache for 10 minutes */
FLYER: 10 * 60,
/** Flyer items - cache for 10 minutes */
FLYER_ITEMS: 10 * 60,
/** Statistics - can be slightly stale, cache for 5 minutes */
STATS: 5 * 60,
/** Most frequent sales - aggregated data, cache for 15 minutes */
FREQUENT_SALES: 15 * 60,
/** Categories - rarely changes, cache for 1 hour */
CATEGORIES: 60 * 60,
} as const;
/**
* Cache key prefixes for different data types.
* Using consistent prefixes allows for pattern-based invalidation.
*/
export const CACHE_PREFIX = {
BRANDS: 'cache:brands',
FLYERS: 'cache:flyers',
FLYER: 'cache:flyer',
FLYER_ITEMS: 'cache:flyer-items',
STATS: 'cache:stats',
FREQUENT_SALES: 'cache:frequent-sales',
CATEGORIES: 'cache:categories',
} as const;
export interface CacheOptions {
/** Time-to-live in seconds */
ttl: number;
/** Optional logger for this operation */
logger?: Logger;
}
/**
* Centralized cache service implementing the Cache-Aside pattern.
* All cache operations are fail-safe - cache failures do not break the application.
*/
class CacheService {
/**
* Retrieves a value from cache.
* @param key The cache key
* @param logger Optional logger for this operation
* @returns The cached value or null if not found/error
*/
async get<T>(key: string, logger: Logger = globalLogger): Promise<T | null> {
try {
const cached = await redis.get(key);
if (cached) {
logger.debug({ cacheKey: key }, 'Cache hit');
return JSON.parse(cached) as T;
}
logger.debug({ cacheKey: key }, 'Cache miss');
return null;
} catch (error) {
logger.warn({ err: error, cacheKey: key }, 'Redis GET failed, proceeding without cache');
return null;
}
}
/**
* Stores a value in cache with TTL.
* @param key The cache key
* @param value The value to cache (will be JSON stringified)
* @param ttl Time-to-live in seconds
* @param logger Optional logger for this operation
*/
async set<T>(key: string, value: T, ttl: number, logger: Logger = globalLogger): Promise<void> {
try {
await redis.set(key, JSON.stringify(value), 'EX', ttl);
logger.debug({ cacheKey: key, ttl }, 'Value cached');
} catch (error) {
logger.warn({ err: error, cacheKey: key }, 'Redis SET failed, value not cached');
}
}
/**
* Deletes a specific key from cache.
* @param key The cache key to delete
* @param logger Optional logger for this operation
*/
async del(key: string, logger: Logger = globalLogger): Promise<void> {
try {
await redis.del(key);
logger.debug({ cacheKey: key }, 'Cache key deleted');
} catch (error) {
logger.warn({ err: error, cacheKey: key }, 'Redis DEL failed');
}
}
/**
* Invalidates all cache keys matching a pattern.
* Uses SCAN for safe iteration over large key sets.
* @param pattern The pattern to match (e.g., 'cache:flyers*')
* @param logger Optional logger for this operation
* @returns The number of keys deleted
*/
async invalidatePattern(pattern: string, logger: Logger = globalLogger): Promise<number> {
let cursor = '0';
let totalDeleted = 0;
try {
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
const deletedCount = await redis.del(...keys);
totalDeleted += deletedCount;
}
} while (cursor !== '0');
logger.info({ pattern, totalDeleted }, 'Cache invalidation completed');
return totalDeleted;
} catch (error) {
logger.error({ err: error, pattern }, 'Cache invalidation failed');
throw error;
}
}
/**
* Implements the Cache-Aside pattern: try cache first, fall back to fetcher, cache result.
* This is the primary method for adding caching to existing repository methods.
*
* @param key The cache key
* @param fetcher Function that retrieves data from the source (e.g., database)
* @param options Cache options including TTL
* @returns The data (from cache or fetcher)
*
* @example
* ```typescript
* const brands = await cacheService.getOrSet(
* CACHE_PREFIX.BRANDS,
* () => this.db.query('SELECT * FROM stores'),
* { ttl: CACHE_TTL.BRANDS, logger }
* );
* ```
*/
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions,
): Promise<T> {
const logger = options.logger ?? globalLogger;
// Try to get from cache first
const cached = await this.get<T>(key, logger);
if (cached !== null) {
return cached;
}
// Cache miss - fetch from source
const data = await fetcher();
// Cache the result (fire-and-forget, don't await)
this.set(key, data, options.ttl, logger).catch(() => {
// Error already logged in set()
});
return data;
}
// --- Convenience methods for specific cache types ---
/**
* Invalidates all brand-related cache entries.
*/
async invalidateBrands(logger: Logger = globalLogger): Promise<number> {
return this.invalidatePattern(`${CACHE_PREFIX.BRANDS}*`, logger);
}
/**
* Invalidates all flyer-related cache entries.
*/
async invalidateFlyers(logger: Logger = globalLogger): Promise<number> {
const patterns = [
`${CACHE_PREFIX.FLYERS}*`,
`${CACHE_PREFIX.FLYER}*`,
`${CACHE_PREFIX.FLYER_ITEMS}*`,
];
let total = 0;
for (const pattern of patterns) {
total += await this.invalidatePattern(pattern, logger);
}
return total;
}
/**
* Invalidates cache for a specific flyer and its items.
*/
async invalidateFlyer(flyerId: number, logger: Logger = globalLogger): Promise<void> {
await Promise.all([
this.del(`${CACHE_PREFIX.FLYER}:${flyerId}`, logger),
this.del(`${CACHE_PREFIX.FLYER_ITEMS}:${flyerId}`, logger),
// Also invalidate the flyers list since it may contain this flyer
this.invalidatePattern(`${CACHE_PREFIX.FLYERS}*`, logger),
]);
}
/**
* Invalidates all statistics cache entries.
*/
async invalidateStats(logger: Logger = globalLogger): Promise<number> {
return this.invalidatePattern(`${CACHE_PREFIX.STATS}*`, logger);
}
}
export const cacheService = new CacheService();

View File

@@ -244,8 +244,9 @@ describe('Flyer DB Service', () => {
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
CheckConstraintError,
);
// The implementation now generates a more detailed error message.
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
'Invalid URL format provided for image or icon.',
"[URL_CHECK_FAIL] Invalid URL format. Image: 'https://example.com/not-a-url', Icon: 'null'",
);
});
});

View File

@@ -3,6 +3,7 @@ import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import type { Logger } from 'pino';
import { UniqueConstraintError, NotFoundError, handleDbError } from './errors.db';
import { cacheService, CACHE_TTL, CACHE_PREFIX } from '../cacheService.server';
import type {
Flyer,
FlyerItem,
@@ -229,22 +230,31 @@ export class FlyerRepository {
/**
* Retrieves all distinct brands from the stores table.
* Uses cache-aside pattern with 1-hour TTL (brands rarely change).
* @returns A promise that resolves to an array of Brand objects.
*/
async getAllBrands(logger: Logger): Promise<Brand[]> {
try {
const query = `
SELECT s.store_id as brand_id, s.name, s.logo_url, s.created_at, s.updated_at
FROM public.stores s
ORDER BY s.name;
`;
const res = await this.db.query<Brand>(query);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getAllBrands', {}, {
defaultMessage: 'Failed to retrieve brands from database.',
});
}
const cacheKey = CACHE_PREFIX.BRANDS;
return cacheService.getOrSet<Brand[]>(
cacheKey,
async () => {
try {
const query = `
SELECT s.store_id as brand_id, s.name, s.logo_url, s.created_at, s.updated_at
FROM public.stores s
ORDER BY s.name;
`;
const res = await this.db.query<Brand>(query);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getAllBrands', {}, {
defaultMessage: 'Failed to retrieve brands from database.',
});
}
},
{ ttl: CACHE_TTL.BRANDS, logger },
);
}
/**
@@ -262,49 +272,67 @@ export class FlyerRepository {
/**
* Retrieves all flyers from the database, ordered by creation date.
* Uses cache-aside pattern with 5-minute TTL.
* @param limit The maximum number of flyers to return.
* @param offset The number of flyers to skip.
* @returns A promise that resolves to an array of Flyer objects.
*/
async getFlyers(logger: Logger, limit: number = 20, offset: number = 0): Promise<Flyer[]> {
try {
const query = `
SELECT
f.*,
json_build_object(
'store_id', s.store_id,
'name', s.name,
'logo_url', s.logo_url
) as store
FROM public.flyers f
JOIN public.stores s ON f.store_id = s.store_id
ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
const res = await this.db.query<Flyer>(query, [limit, offset]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getFlyers', { limit, offset }, {
defaultMessage: 'Failed to retrieve flyers from database.',
});
}
const cacheKey = `${CACHE_PREFIX.FLYERS}:${limit}:${offset}`;
return cacheService.getOrSet<Flyer[]>(
cacheKey,
async () => {
try {
const query = `
SELECT
f.*,
json_build_object(
'store_id', s.store_id,
'name', s.name,
'logo_url', s.logo_url
) as store
FROM public.flyers f
JOIN public.stores s ON f.store_id = s.store_id
ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
const res = await this.db.query<Flyer>(query, [limit, offset]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getFlyers', { limit, offset }, {
defaultMessage: 'Failed to retrieve flyers from database.',
});
}
},
{ ttl: CACHE_TTL.FLYERS, logger },
);
}
/**
* Retrieves all items for a specific flyer.
* Uses cache-aside pattern with 10-minute TTL.
* @param flyerId The ID of the flyer.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
async getFlyerItems(flyerId: number, logger: Logger): Promise<FlyerItem[]> {
try {
const res = await this.db.query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC',
[flyerId],
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getFlyerItems', { flyerId }, {
defaultMessage: 'Failed to retrieve flyer items from database.',
});
}
const cacheKey = `${CACHE_PREFIX.FLYER_ITEMS}:${flyerId}`;
return cacheService.getOrSet<FlyerItem[]>(
cacheKey,
async () => {
try {
const res = await this.db.query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC',
[flyerId],
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getFlyerItems', { flyerId }, {
defaultMessage: 'Failed to retrieve flyer items from database.',
});
}
},
{ ttl: CACHE_TTL.FLYER_ITEMS, logger },
);
}
/**
@@ -399,6 +427,7 @@ export class FlyerRepository {
/**
* Deletes a flyer and all its associated items in a transaction.
* This should typically be an admin-only action.
* Invalidates related cache entries after successful deletion.
* @param flyerId The ID of the flyer to delete.
*/
async deleteFlyer(flyerId: number, logger: Logger): Promise<void> {
@@ -413,6 +442,9 @@ export class FlyerRepository {
}
logger.info(`Successfully deleted flyer with ID: ${flyerId}`);
});
// Invalidate cache after successful deletion
await cacheService.invalidateFlyer(flyerId, logger);
} catch (error) {
handleDbError(error, logger, 'Database transaction error in deleteFlyer', { flyerId }, {
defaultMessage: 'Failed to delete flyer.',

View File

@@ -4,12 +4,13 @@ import { withTransaction } from './db/connection.db';
import { createFlyerAndItems } from './db/flyer.db';
import { AdminRepository } from './db/admin.db';
import { GamificationRepository } from './db/gamification.db';
import { cacheService } from './cacheService.server';
import type { FlyerInsert, FlyerItemInsert, Flyer } from '../types';
export class FlyerPersistenceService {
/**
* Saves the flyer and its items to the database within a transaction.
* Also logs the activity.
* Also logs the activity and invalidates related cache entries.
*/
async saveFlyer(
flyerData: FlyerInsert,
@@ -17,7 +18,7 @@ export class FlyerPersistenceService {
userId: string | undefined,
logger: Logger,
): Promise<Flyer> {
return withTransaction(async (client) => {
const flyer = await withTransaction(async (client) => {
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
logger.info(
@@ -43,5 +44,12 @@ export class FlyerPersistenceService {
}
return flyer;
});
// Invalidate flyer list cache after successful creation (fire-and-forget)
cacheService.invalidateFlyers(logger).catch(() => {
// Error already logged in invalidateFlyers
});
return flyer;
}
}

View File

@@ -1,6 +1,9 @@
// src/services/logger.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Unmock the module we are testing to override the global mock from setupFiles.
vi.unmock('./logger.server');
// Mock pino before importing the logger
const pinoMock = vi.fn(() => ({
info: vi.fn(),
@@ -25,14 +28,25 @@ describe('Server Logger', () => {
it('should initialize pino with the correct level for production', async () => {
vi.stubEnv('NODE_ENV', 'production');
await import('./logger.server');
expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' }));
expect(pinoMock).toHaveBeenCalledWith(
expect.objectContaining({ level: 'info', transport: undefined }),
);
});
it('should initialize pino with pretty-print transport for development', async () => {
vi.stubEnv('NODE_ENV', 'development');
await import('./logger.server');
expect(pinoMock).toHaveBeenCalledWith(
expect.objectContaining({ transport: expect.any(Object) }),
expect.objectContaining({ level: 'debug', transport: expect.any(Object) }),
);
});
it('should initialize pino with debug level and no transport for test', async () => {
// This is the default for vitest, but we stub it for clarity.
vi.stubEnv('NODE_ENV', 'test');
await import('./logger.server');
expect(pinoMock).toHaveBeenCalledWith(
expect.objectContaining({ level: 'debug', transport: undefined }),
);
});
});

View File

@@ -59,17 +59,40 @@ vi.mock('../../services/storage/storageService', () => {
};
});
// FIX: Import the singleton instance directly to spy on it
import { aiService } from '../../services/aiService.server';
/**
* @vitest-environment node
*/
const { mockExtractCoreData } = vi.hoisted(() => ({
mockExtractCoreData: vi.fn(),
}));
// CRITICAL: This mock function must be declared with vi.hoisted() to ensure it's available
// at the module level BEFORE any imports are resolved.
const { mockExtractCoreData } = vi.hoisted(() => {
return {
mockExtractCoreData: vi.fn(),
};
});
// CRITICAL: Mock the aiService module BEFORE any other imports that depend on it.
// This ensures workers get the mocked version, not the real one.
// We use a partial mock that only overrides extractCoreDataFromFlyerImage.
vi.mock('../../services/aiService.server', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
// Create a proxy around the actual aiService that intercepts extractCoreDataFromFlyerImage
const proxiedAiService = new Proxy(actual.aiService, {
get(target, prop) {
if (prop === 'extractCoreDataFromFlyerImage') {
return mockExtractCoreData;
}
// For all other properties/methods, return the original
return target[prop as keyof typeof target];
},
});
return {
...actual,
aiService: proxiedAiService,
};
});
// Mock the connection DB service to intercept withTransaction.
// This is crucial because FlyerPersistenceService imports directly from connection.db,
@@ -99,9 +122,8 @@ describe('Flyer Processing Background Job Integration Test', () => {
process.env.FRONTEND_URL = 'https://example.com';
console.error('[TEST SETUP] FRONTEND_URL stubbed to:', process.env.FRONTEND_URL);
// FIX: Spy on the actual singleton instance. This ensures that when the worker
// imports 'aiService', it gets the instance we are controlling here.
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockImplementation(mockExtractCoreData);
// NOTE: The aiService mock is now set up via vi.mock() at the module level (above).
// This ensures workers get the mocked version when they import aiService.
// NEW: Import workers to start them IN-PROCESS.
// This ensures they run in the same memory space as our mocks.

View File

@@ -0,0 +1,8 @@
import { vi } from 'vitest';
import { mockLogger } from '../utils/mockLogger';
// Globally mock the logger service so individual test files don't have to.
// This ensures 'import { logger } from ...' always returns the mock.
vi.mock('../../services/logger.server', () => ({
logger: mockLogger,
}));

View File

@@ -9,13 +9,56 @@ let server: Server;
// This will hold the single database pool instance for the entire test run.
let globalPool: ReturnType<typeof getPool> | null = null;
/**
* Cleans all BullMQ queues to ensure no stale jobs from previous test runs.
* This is critical because old jobs with outdated error messages can pollute test results.
*/
async function cleanAllQueues() {
// Use console.error for visibility in CI logs (stderr is often more reliable)
console.error(`[PID:${process.pid}] [QUEUE CLEANUP] Starting BullMQ queue cleanup...`);
try {
const { flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue } = await import('../../services/queues.server');
console.error(`[QUEUE CLEANUP] Successfully imported queue modules`);
const queues = [flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue];
for (const queue of queues) {
try {
// Log queue state before cleanup
const jobCounts = await queue.getJobCounts();
console.error(`[QUEUE CLEANUP] Queue "${queue.name}" before cleanup: ${JSON.stringify(jobCounts)}`);
// obliterate() removes ALL data associated with the queue from Redis
await queue.obliterate({ force: true });
console.error(` ✅ [QUEUE CLEANUP] Cleaned queue: ${queue.name}`);
} catch (error) {
// Log but don't fail - the queue might not exist yet
console.error(` ⚠️ [QUEUE CLEANUP] Could not clean queue ${queue.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
console.error(`✅ [PID:${process.pid}] [QUEUE CLEANUP] All queues cleaned successfully.`);
} catch (error) {
console.error(`❌ [PID:${process.pid}] [QUEUE CLEANUP] CRITICAL ERROR during queue cleanup:`, error);
// Don't throw - we want the tests to continue even if cleanup fails
}
}
export async function setup() {
// Ensure we are in the correct environment for these tests.
process.env.NODE_ENV = 'test';
// Fix: Set the FRONTEND_URL globally for the test server instance
process.env.FRONTEND_URL = 'https://example.com';
console.log(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`);
console.error(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`);
console.error(`[SETUP] REDIS_URL: ${process.env.REDIS_URL}`);
console.error(`[SETUP] REDIS_PASSWORD is set: ${!!process.env.REDIS_PASSWORD}`);
// CRITICAL: Clean all queues BEFORE running any tests to remove stale jobs
// from previous test runs that may have outdated error messages.
console.error(`[SETUP] About to call cleanAllQueues()...`);
await cleanAllQueues();
console.error(`[SETUP] cleanAllQueues() completed.`);
// The integration setup is now the single source of truth for preparing the test DB.
// It runs the same seed script that `npm run db:reset:test` used.

View File

@@ -0,0 +1,9 @@
import type { Request } from 'express';
import { mockLogger } from './mockLogger';
export const createMockRequest = (overrides: Partial<Request> = {}): Request => {
return {
log: mockLogger,
...overrides,
} as unknown as Request;
};

View File

@@ -1,5 +1,5 @@
// src/tests/utils/createTestApp.ts
import express, { type Router } from 'express';
import express, { type Router, type RequestHandler } from 'express';
import type { Logger } from 'pino';
import { errorHandler } from '../../middleware/errorHandler';
import { mockLogger } from './mockLogger';
@@ -17,6 +17,7 @@ interface CreateAppOptions {
router: Router;
basePath: string;
authenticatedUser?: UserProfile;
middleware?: RequestHandler[];
}
/**
@@ -24,10 +25,20 @@ interface CreateAppOptions {
* It includes JSON parsing, a mock logger, an optional authenticated user,
* the specified router, and the global error handler.
*/
export const createTestApp = ({ router, basePath, authenticatedUser }: CreateAppOptions) => {
export const createTestApp = ({
router,
basePath,
authenticatedUser,
middleware = [],
}: CreateAppOptions) => {
const app = express();
app.use(express.json({ strict: false }));
// Apply custom middleware (e.g. cookieParser)
if (middleware.length > 0) {
app.use(middleware);
}
// Inject the mock logger and authenticated user into every request.
app.use((req, res, next) => {
req.log = mockLogger;

View File

@@ -1,14 +1,23 @@
// src/utils/imageProcessor.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Logger } from 'pino';
import path from 'path';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
// Create a chainable mock for the sharp library
const toFile = vi.fn().mockResolvedValue({ info: 'mocked' });
// Chain for generateFlyerIcon
const webp = vi.fn(() => ({ toFile }));
const resize = vi.fn(() => ({ webp }));
const sharpInstance = { resize };
// Chain for processAndSaveImage
const png = vi.fn(() => ({ toFile }));
const jpeg = vi.fn(() => ({ png }));
const withMetadata = vi.fn(() => ({ jpeg }));
const sharpInstance = { resize, withMetadata };
// Mock the sharp function and attach static properties required by the implementation
const sharp = vi.fn(() => sharpInstance);
@@ -18,6 +27,9 @@ const mocks = vi.hoisted(() => {
sharp: sharp,
resize,
webp,
withMetadata,
jpeg,
png,
toFile,
mkdir: vi.fn().mockResolvedValue(undefined),
};
@@ -54,7 +66,7 @@ const logger = createMockLogger();
vi.mock('../services/logger.server', () => ({ logger }));
// --- Import the function to be tested ---
import { generateFlyerIcon } from './imageProcessor';
import { generateFlyerIcon, processAndSaveImage } from './imageProcessor';
describe('generateFlyerIcon', () => {
beforeEach(() => {
@@ -95,3 +107,48 @@ describe('generateFlyerIcon', () => {
);
});
});
describe('processAndSaveImage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Ensure toFile is in a resolved state
mocks.toFile.mockResolvedValue({ info: 'mocked' });
});
it('should process the image, strip metadata, and return the new filename', async () => {
const sourcePath = '/tmp/upload/original.jpg';
const destinationDir = '/var/www/images';
const originalFileName = 'original.jpg';
const result = await processAndSaveImage(sourcePath, destinationDir, originalFileName, logger);
// Check that the destination directory was created
expect(mocks.mkdir).toHaveBeenCalledWith(destinationDir, { recursive: true });
// Check that sharp was called with the correct source
expect(mocks.sharp).toHaveBeenCalledWith(sourcePath, { failOn: 'none' });
// Check the processing chain
expect(mocks.withMetadata).toHaveBeenCalledWith({});
expect(mocks.jpeg).toHaveBeenCalledWith({ quality: 85, mozjpeg: true });
expect(mocks.png).toHaveBeenCalledWith({ compressionLevel: 8, quality: 85 });
expect(mocks.toFile).toHaveBeenCalledWith(expect.stringContaining(path.join(destinationDir, 'original-')));
// Check the returned filename format (original-timestamp.jpg)
expect(result).toMatch(/^original-\d+\.jpg$/);
});
it('should throw an error if sharp fails to process the image', async () => {
const sharpError = new Error('Processing failed');
mocks.toFile.mockRejectedValueOnce(sharpError);
await expect(
processAndSaveImage('/path/img.jpg', '/dest', 'img.jpg', logger),
).rejects.toThrow('Failed to process image img.jpg.');
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ err: sharpError, sourcePath: '/path/img.jpg' }),
'An error occurred during image processing and saving.',
);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Request } from 'express';
import { createMockRequest } from '../tests/utils/createMockRequest';
describe('rateLimit utils', () => {
beforeEach(() => {
@@ -16,7 +16,7 @@ describe('rateLimit utils', () => {
vi.stubEnv('NODE_ENV', 'production');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = { headers: {} } as Request;
const req = createMockRequest({ headers: {} });
expect(shouldSkipRateLimit(req)).toBe(false);
});
@@ -24,7 +24,7 @@ describe('rateLimit utils', () => {
vi.stubEnv('NODE_ENV', 'development');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = { headers: {} } as Request;
const req = createMockRequest({ headers: {} });
expect(shouldSkipRateLimit(req)).toBe(false);
});
@@ -32,7 +32,7 @@ describe('rateLimit utils', () => {
vi.stubEnv('NODE_ENV', 'test');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = { headers: {} } as Request;
const req = createMockRequest({ headers: {} });
expect(shouldSkipRateLimit(req)).toBe(true);
});
@@ -40,9 +40,9 @@ describe('rateLimit utils', () => {
vi.stubEnv('NODE_ENV', 'test');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = {
const req = createMockRequest({
headers: { 'x-test-rate-limit-enable': 'true' },
} as unknown as Request;
});
expect(shouldSkipRateLimit(req)).toBe(false);
});
@@ -50,9 +50,9 @@ describe('rateLimit utils', () => {
vi.stubEnv('NODE_ENV', 'test');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = {
const req = createMockRequest({
headers: { 'x-test-rate-limit-enable': 'false' },
} as unknown as Request;
});
expect(shouldSkipRateLimit(req)).toBe(true);
});
});

View File

@@ -20,57 +20,100 @@ const createMockLogger = (): Logger =>
describe('serverUtils', () => {
describe('getBaseUrl', () => {
const originalEnv = process.env;
let mockLogger: Logger;
// Store original env values to restore after tests
const originalFrontendUrl = process.env.FRONTEND_URL;
const originalBaseUrl = process.env.BASE_URL;
const originalNodeEnv = process.env.NODE_ENV;
const originalPort = process.env.PORT;
beforeEach(() => {
// Reset mocks and environment variables before each test for isolation
vi.resetModules();
process.env = { ...originalEnv };
vi.unstubAllEnvs();
// CRITICAL: Clear env vars that might be set globally (e.g., from vitest config)
// vi.unstubAllEnvs() only removes vars set via vi.stubEnv(), not direct assignments
delete process.env.FRONTEND_URL;
delete process.env.BASE_URL;
delete process.env.NODE_ENV;
delete process.env.PORT;
mockLogger = createMockLogger();
});
afterEach(() => {
// Restore original environment variables after each test
process.env = originalEnv;
vi.unstubAllEnvs();
// Restore original values
if (originalFrontendUrl !== undefined) process.env.FRONTEND_URL = originalFrontendUrl;
else delete process.env.FRONTEND_URL;
if (originalBaseUrl !== undefined) process.env.BASE_URL = originalBaseUrl;
else delete process.env.BASE_URL;
if (originalNodeEnv !== undefined) process.env.NODE_ENV = originalNodeEnv;
else delete process.env.NODE_ENV;
if (originalPort !== undefined) process.env.PORT = originalPort;
else delete process.env.PORT;
});
it('should use FRONTEND_URL if it is a valid URL', () => {
process.env.FRONTEND_URL = 'https://valid.example.com';
vi.stubEnv('FRONTEND_URL', 'https://valid.example.com');
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('https://valid.example.com');
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should trim a trailing slash from FRONTEND_URL', () => {
process.env.FRONTEND_URL = 'https://valid.example.com/';
vi.stubEnv('FRONTEND_URL', 'https://valid.example.com/');
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('https://valid.example.com');
});
it('should use BASE_URL if FRONTEND_URL is not set', () => {
delete process.env.FRONTEND_URL;
process.env.BASE_URL = 'https://base.example.com';
vi.stubEnv('BASE_URL', 'https://base.example.com');
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('https://base.example.com');
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should fall back to example.com with default port 3000 if no URL is provided', () => {
delete process.env.FRONTEND_URL;
delete process.env.BASE_URL;
delete process.env.PORT;
it('should fall back to localhost with default port 3000 in test environment', () => {
vi.stubEnv('NODE_ENV', 'test');
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('https://example.com:3000');
expect(baseUrl).toBe('http://localhost:3000');
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should log a warning and fall back if FRONTEND_URL is invalid (does not start with http)', () => {
process.env.FRONTEND_URL = 'invalid.url.com';
it('should fall back to example.com in non-test environment', () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('PORT', '4000');
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('https://example.com:3000');
expect(baseUrl).toBe('http://example.com:4000');
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should log a warning and fall back to localhost if FRONTEND_URL is invalid in test env', () => {
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('FRONTEND_URL', 'invalid.url.com');
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:3000');
expect(mockLogger.warn).toHaveBeenCalledWith(
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to default local URL: https://example.com:3000",
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to: http://localhost:3000",
);
});
it('should log a warning and fall back to example.com if FRONTEND_URL is invalid in non-test env', () => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('FRONTEND_URL', 'invalid.url.com');
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://example.com:3000');
expect(mockLogger.warn).toHaveBeenCalledWith(
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to: http://example.com:3000",
);
});
it('should throw an error if the final URL is invalid', () => {
vi.stubEnv('FRONTEND_URL', 'http:invalid');
expect(() => getBaseUrl(mockLogger)).toThrow(
`[getBaseUrl] Generated URL 'http:invalid' does not match required pattern (must start with http:// or https://)`,
);
});
});

View File

@@ -46,6 +46,7 @@ export default defineConfig({
globalSetup: './src/tests/setup/global-setup.ts',
// The globalApiMock MUST come first to ensure it's applied before other mocks that might depend on it.
setupFiles: [
'./src/tests/setup/global.ts',
'./src/tests/setup/globalApiMock.ts',
'./src/tests/setup/tests-setup-unit.ts',
],

View File

@@ -53,6 +53,7 @@ const finalConfig = mergeConfig(
},
// This setup script starts the backend server before tests run.
globalSetup: './src/tests/setup/integration-global-setup.ts',
setupFiles: ['./src/tests/setup/global.ts'],
// The default timeout is 5000ms (5 seconds)
testTimeout: 60000, // Increased timeout for server startup and API calls, especially AI services.
hookTimeout: 60000,