unit tests - wheeee! Claude is the mvp
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m11s

This commit is contained in:
2026-01-09 21:59:09 -08:00
parent 71710c8316
commit a42ee5a461
31 changed files with 1487 additions and 25 deletions

View File

@@ -0,0 +1,214 @@
# ADR-040: Testing Economics and Priorities
**Date**: 2026-01-09
**Status**: Accepted
## Context
ADR-010 established the testing strategy and standards. However, it does not address the economic trade-offs of testing: when the cost of writing and maintaining tests exceeds their value. This document provides practical guidance on where to invest testing effort for maximum return.
## Decision
We adopt a **value-based testing approach** that prioritizes tests based on:
1. Risk of the code path (what breaks if this fails?)
2. Stability of the code (how often does this change?)
3. Complexity of the logic (can a human easily verify correctness?)
4. Cost of the test (setup complexity, execution time, maintenance burden)
## Testing Investment Matrix
| Test Type | Investment Level | When to Write | When to Skip |
| --------------- | ------------------- | ------------------------------- | --------------------------------- |
| **E2E** | Minimal (5 tests) | Critical user flows only | Everything else |
| **Integration** | Moderate (17 tests) | API contracts, auth, DB queries | Internal service wiring |
| **Unit** | High (185+ tests) | Business logic, utilities | Defensive fallbacks, trivial code |
## High-Value Tests (Always Write)
### E2E Tests (Budget: 5-10 tests total)
Write E2E tests for flows where failure means:
- Users cannot sign up or log in
- Users cannot complete the core value proposition (upload flyer → see deals)
- Money or data is at risk
**Current E2E coverage is appropriate:**
- `auth.e2e.test.ts` - Registration, login, password reset
- `flyer-upload.e2e.test.ts` - Complete upload pipeline
- `user-journey.e2e.test.ts` - Full user workflow
- `admin-authorization.e2e.test.ts` - Admin access control
- `admin-dashboard.e2e.test.ts` - Admin operations
**Do NOT add E2E tests for:**
- UI variations or styling
- Edge cases (handle in unit tests)
- Features that can be tested faster at a lower level
### Integration Tests (Budget: 15-25 tests)
Write integration tests for:
- Every public API endpoint (contract testing)
- Authentication and authorization flows
- Database queries that involve joins or complex logic
- Middleware behavior (rate limiting, validation)
**Current integration coverage is appropriate:**
- Auth, admin, user routes
- Flyer processing pipeline
- Shopping lists, budgets, recipes
- Gamification and notifications
**Do NOT add integration tests for:**
- Internal service-to-service calls (mock at boundaries)
- Simple CRUD operations (test the repository pattern once)
- UI components (use unit tests)
### Unit Tests (Budget: Proportional to complexity)
Write unit tests for:
- **Pure functions and utilities** - High value, easy to test
- **Business logic in services** - Medium-high value
- **React components** - Rendering, user interactions, state changes
- **Custom hooks** - Data transformation, side effects
- **Validators and parsers** - Edge cases matter here
## Low-Value Tests (Skip or Defer)
### Tests That Cost More Than They're Worth
1. **Defensive fallback code protected by types**
```typescript
// This fallback can never execute if types are correct
const name = store.name || 'Unknown'; // store.name is required
```
- If you need `as any` to test it, the type system already prevents it
- Either remove the fallback or accept the coverage gap
2. **Switch/case default branches for exhaustive enums**
```typescript
switch (status) {
case 'pending':
return 'yellow';
case 'complete':
return 'green';
default:
return ''; // TypeScript prevents this
}
```
- The default exists for safety, not for execution
- Don't test impossible states
3. **Trivial component variations**
- Testing every tab in a tab panel when they share logic
- Testing loading states that just show a spinner
- Testing disabled button states (test the logic that disables, not the disabled state)
4. **Tests requiring excessive mock setup**
- If test setup is longer than test assertions, reconsider
- Per ADR-010: "Excessive mock setup" is a code smell
5. **Framework behavior verification**
- React rendering, React Query caching, Router navigation
- Trust the framework; test your code
### Coverage Gaps to Accept
The following coverage gaps are acceptable and should NOT be closed with tests:
| Pattern | Reason | Alternative |
| ------------------------------------------ | ------------------------- | ----------------------------- |
| `value \|\| 'default'` for required fields | Type system prevents | Remove fallback or accept gap |
| `catch (error) { ... }` for typed APIs | Error types are known | Test the expected error types |
| `default:` in exhaustive switches | TypeScript exhaustiveness | Accept gap |
| Logging statements | Observability, not logic | No test needed |
| Feature flags / environment checks | Tested by deployment | Config tests if complex |
## Time Budget Guidelines
For a typical feature (new API endpoint + UI):
| Activity | Time Budget | Notes |
| --------------------------------------- | ----------- | ------------------------------------- |
| Unit tests (component + hook + utility) | 30-45 min | Write alongside code |
| Integration test (API contract) | 15-20 min | One test per endpoint |
| E2E test | 0 min | Only for critical paths |
| Total testing overhead | ~1 hour | Should not exceed implementation time |
**Rule of thumb**: If testing takes longer than implementation, you're either:
1. Testing too much
2. Writing tests that are too complex
3. Testing code that should be refactored
## Coverage Targets
We explicitly reject arbitrary coverage percentage targets. Instead:
| Metric | Target | Rationale |
| ---------------------- | --------------- | -------------------------------------- |
| Statement coverage | No target | High coverage ≠ quality tests |
| Branch coverage | No target | Many branches are defensive/impossible |
| E2E test count | 5-10 | Critical paths only |
| Integration test count | 15-25 | API contracts |
| Unit test files | 1:1 with source | Colocated, proportional |
## When to Add Tests to Existing Code
Add tests when:
1. **Fixing a bug** - Add a test that would have caught it
2. **Refactoring** - Add tests before changing behavior
3. **Code review feedback** - Reviewer identifies risk
4. **Production incident** - Prevent recurrence
Do NOT add tests:
1. To increase coverage percentages
2. For code that hasn't changed in 6+ months
3. For code scheduled for deletion/replacement
## Consequences
**Positive:**
- Testing effort focuses on high-risk, high-value code
- Developers spend less time on low-value tests
- Test suite runs faster (fewer unnecessary tests)
- Maintenance burden decreases
**Negative:**
- Some defensive code paths remain untested
- Coverage percentages may not satisfy external audits
- Requires judgment calls that may be inconsistent
## Key Files
- `docs/adr/0010-testing-strategy-and-standards.md` - Testing mechanics
- `vitest.config.ts` - Coverage configuration
- `src/tests/` - Test utilities and setup
## Review Checklist
Before adding a new test, ask:
1. [ ] What user-visible behavior does this test protect?
2. [ ] Can this be tested at a lower level (unit vs integration)?
3. [ ] Does this test require `as any` or mock gymnastics?
4. [ ] Will this test break when implementation changes (brittle)?
5. [ ] Is the test setup simpler than the code being tested?
If any answer suggests low value, skip the test or simplify.

View File

@@ -60,6 +60,7 @@ This directory contains a log of the architectural decisions made for the Flyer
**[ADR-010](./0010-testing-strategy-and-standards.md)**: Testing Strategy and Standards (Accepted)
**[ADR-021](./0021-code-formatting-and-linting-unification.md)**: Code Formatting and Linting Unification (Accepted)
**[ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md)**: Standardized Naming Convention for AI and Database Types (Accepted)
**[ADR-040](./0040-testing-economics-and-priorities.md)**: Testing Economics and Priorities (Accepted)
## 9. Architecture Patterns

View File

@@ -48,7 +48,9 @@ describe('FlyerCorrectionTool', () => {
});
it('should not render when isOpen is false', () => {
const { container } = renderWithProviders(<FlyerCorrectionTool {...defaultProps} isOpen={false} />);
const { container } = renderWithProviders(
<FlyerCorrectionTool {...defaultProps} isOpen={false} />,
);
expect(container.firstChild).toBeNull();
});
@@ -302,4 +304,45 @@ describe('FlyerCorrectionTool', () => {
expect(clearRectSpy).toHaveBeenCalled();
});
it('should call rescanImageArea with "dates" type when Extract Sale Dates is clicked', async () => {
mockedAiApiClient.rescanImageArea.mockResolvedValue(
new Response(JSON.stringify({ text: 'Jan 1 - Jan 7' })),
);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
// Wait for image fetch to complete
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(defaultProps.imageUrl));
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
const image = screen.getByAltText('Flyer for correction');
// Mock image dimensions
Object.defineProperty(image, 'naturalWidth', { value: 1000, configurable: true });
Object.defineProperty(image, 'naturalHeight', { value: 800, configurable: true });
Object.defineProperty(image, 'clientWidth', { value: 500, configurable: true });
Object.defineProperty(image, 'clientHeight', { value: 400, configurable: true });
// Draw a selection
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
fireEvent.mouseMove(canvas, { clientX: 60, clientY: 30 });
fireEvent.mouseUp(canvas);
// Click the "Extract Sale Dates" button instead of "Extract Store Name"
fireEvent.click(screen.getByRole('button', { name: /extract sale dates/i }));
await waitFor(() => {
expect(mockedAiApiClient.rescanImageArea).toHaveBeenCalledWith(
expect.any(File),
expect.objectContaining({ x: 20, y: 20, width: 100, height: 40 }),
'dates', // This is the key difference - testing the 'dates' extraction type
);
});
await waitFor(() => {
expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Jan 1 - Jan 7');
expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('dates', 'Jan 1 - Jan 7');
});
});
});

432
src/config/env.test.ts Normal file
View File

@@ -0,0 +1,432 @@
// src/config/env.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('env config', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
/**
* Sets up minimal valid environment variables for config parsing.
*/
function setValidEnv(overrides: Record<string, string> = {}) {
process.env = {
NODE_ENV: 'test',
// Database (required)
DB_HOST: 'localhost',
DB_PORT: '5432',
DB_USER: 'testuser',
DB_PASSWORD: 'testpass',
DB_NAME: 'testdb',
// Redis (required)
REDIS_URL: 'redis://localhost:6379',
// Auth (required - min 32 chars)
JWT_SECRET: 'this-is-a-test-secret-that-is-at-least-32-characters-long',
...overrides,
};
}
describe('successful config parsing', () => {
it('should parse valid configuration with all required fields', async () => {
setValidEnv();
const { config } = await import('./env');
expect(config.database.host).toBe('localhost');
expect(config.database.port).toBe(5432);
expect(config.database.user).toBe('testuser');
expect(config.database.password).toBe('testpass');
expect(config.database.name).toBe('testdb');
expect(config.redis.url).toBe('redis://localhost:6379');
expect(config.auth.jwtSecret).toBe(
'this-is-a-test-secret-that-is-at-least-32-characters-long',
);
});
it('should use default values for optional fields', async () => {
setValidEnv();
const { config } = await import('./env');
// Worker defaults
expect(config.worker.concurrency).toBe(1);
expect(config.worker.lockDuration).toBe(30000);
expect(config.worker.emailConcurrency).toBe(10);
expect(config.worker.analyticsConcurrency).toBe(1);
expect(config.worker.cleanupConcurrency).toBe(10);
expect(config.worker.weeklyAnalyticsConcurrency).toBe(1);
// Server defaults
expect(config.server.port).toBe(3001);
expect(config.server.nodeEnv).toBe('test');
expect(config.server.storagePath).toBe('/var/www/flyer-crawler.projectium.com/flyer-images');
// AI defaults
expect(config.ai.geminiRpm).toBe(5);
expect(config.ai.priceQualityThreshold).toBe(0.5);
// SMTP defaults
expect(config.smtp.port).toBe(587);
expect(config.smtp.secure).toBe(false);
});
it('should parse custom port values', async () => {
setValidEnv({
DB_PORT: '5433',
PORT: '4000',
SMTP_PORT: '465',
});
const { config } = await import('./env');
expect(config.database.port).toBe(5433);
expect(config.server.port).toBe(4000);
expect(config.smtp.port).toBe(465);
});
it('should parse boolean SMTP_SECURE correctly', async () => {
setValidEnv({
SMTP_SECURE: 'true',
});
const { config } = await import('./env');
expect(config.smtp.secure).toBe(true);
});
it('should parse false for SMTP_SECURE when set to false', async () => {
setValidEnv({
SMTP_SECURE: 'false',
});
const { config } = await import('./env');
expect(config.smtp.secure).toBe(false);
});
it('should parse worker concurrency values', async () => {
setValidEnv({
WORKER_CONCURRENCY: '5',
WORKER_LOCK_DURATION: '60000',
EMAIL_WORKER_CONCURRENCY: '20',
ANALYTICS_WORKER_CONCURRENCY: '3',
CLEANUP_WORKER_CONCURRENCY: '15',
WEEKLY_ANALYTICS_WORKER_CONCURRENCY: '2',
});
const { config } = await import('./env');
expect(config.worker.concurrency).toBe(5);
expect(config.worker.lockDuration).toBe(60000);
expect(config.worker.emailConcurrency).toBe(20);
expect(config.worker.analyticsConcurrency).toBe(3);
expect(config.worker.cleanupConcurrency).toBe(15);
expect(config.worker.weeklyAnalyticsConcurrency).toBe(2);
});
it('should parse AI configuration values', async () => {
setValidEnv({
GEMINI_API_KEY: 'test-gemini-key',
GEMINI_RPM: '10',
AI_PRICE_QUALITY_THRESHOLD: '0.75',
});
const { config } = await import('./env');
expect(config.ai.geminiApiKey).toBe('test-gemini-key');
expect(config.ai.geminiRpm).toBe(10);
expect(config.ai.priceQualityThreshold).toBe(0.75);
});
it('should parse Google configuration values', async () => {
setValidEnv({
GOOGLE_MAPS_API_KEY: 'test-maps-key',
GOOGLE_CLIENT_ID: 'test-client-id',
GOOGLE_CLIENT_SECRET: 'test-client-secret',
});
const { config } = await import('./env');
expect(config.google.mapsApiKey).toBe('test-maps-key');
expect(config.google.clientId).toBe('test-client-id');
expect(config.google.clientSecret).toBe('test-client-secret');
});
it('should parse optional SMTP configuration', async () => {
setValidEnv({
SMTP_HOST: 'smtp.example.com',
SMTP_USER: 'smtp-user',
SMTP_PASS: 'smtp-pass',
SMTP_FROM_EMAIL: 'noreply@example.com',
});
const { config } = await import('./env');
expect(config.smtp.host).toBe('smtp.example.com');
expect(config.smtp.user).toBe('smtp-user');
expect(config.smtp.pass).toBe('smtp-pass');
expect(config.smtp.fromEmail).toBe('noreply@example.com');
});
it('should parse optional JWT_SECRET_PREVIOUS for rotation', async () => {
setValidEnv({
JWT_SECRET_PREVIOUS: 'old-secret-that-is-at-least-32-characters-long',
});
const { config } = await import('./env');
expect(config.auth.jwtSecretPrevious).toBe('old-secret-that-is-at-least-32-characters-long');
});
it('should handle empty string values as undefined for optional int fields', async () => {
setValidEnv({
GEMINI_RPM: '',
AI_PRICE_QUALITY_THRESHOLD: ' ',
});
const { config } = await import('./env');
// Should use defaults when empty
expect(config.ai.geminiRpm).toBe(5);
expect(config.ai.priceQualityThreshold).toBe(0.5);
});
});
describe('convenience helpers', () => {
it('should export isProduction as false in test env', async () => {
setValidEnv({ NODE_ENV: 'test' });
const { isProduction } = await import('./env');
expect(isProduction).toBe(false);
});
it('should export isTest as true in test env', async () => {
setValidEnv({ NODE_ENV: 'test' });
const { isTest } = await import('./env');
expect(isTest).toBe(true);
});
it('should export isDevelopment as false in test env', async () => {
setValidEnv({ NODE_ENV: 'test' });
const { isDevelopment } = await import('./env');
expect(isDevelopment).toBe(false);
});
it('should export isSmtpConfigured as false when SMTP not configured', async () => {
setValidEnv();
const { isSmtpConfigured } = await import('./env');
expect(isSmtpConfigured).toBe(false);
});
it('should export isSmtpConfigured as true when all SMTP fields present', async () => {
setValidEnv({
SMTP_HOST: 'smtp.example.com',
SMTP_USER: 'user',
SMTP_PASS: 'pass',
SMTP_FROM_EMAIL: 'noreply@example.com',
});
const { isSmtpConfigured } = await import('./env');
expect(isSmtpConfigured).toBe(true);
});
it('should export isAiConfigured as false when Gemini not configured', async () => {
setValidEnv();
const { isAiConfigured } = await import('./env');
expect(isAiConfigured).toBe(false);
});
it('should export isAiConfigured as true when Gemini key present', async () => {
setValidEnv({
GEMINI_API_KEY: 'test-key',
});
const { isAiConfigured } = await import('./env');
expect(isAiConfigured).toBe(true);
});
it('should export isGoogleMapsConfigured as false when not configured', async () => {
setValidEnv();
const { isGoogleMapsConfigured } = await import('./env');
expect(isGoogleMapsConfigured).toBe(false);
});
it('should export isGoogleMapsConfigured as true when Maps key present', async () => {
setValidEnv({
GOOGLE_MAPS_API_KEY: 'test-maps-key',
});
const { isGoogleMapsConfigured } = await import('./env');
expect(isGoogleMapsConfigured).toBe(true);
});
});
describe('validation errors', () => {
it('should throw error when DB_HOST is missing', async () => {
setValidEnv();
delete process.env.DB_HOST;
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should throw error when DB_USER is missing', async () => {
setValidEnv();
delete process.env.DB_USER;
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should throw error when DB_PASSWORD is missing', async () => {
setValidEnv();
delete process.env.DB_PASSWORD;
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should throw error when DB_NAME is missing', async () => {
setValidEnv();
delete process.env.DB_NAME;
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should throw error when REDIS_URL is missing', async () => {
setValidEnv();
delete process.env.REDIS_URL;
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should throw error when REDIS_URL is invalid', async () => {
setValidEnv({
REDIS_URL: 'not-a-valid-url',
});
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should throw error when JWT_SECRET is missing', async () => {
setValidEnv();
delete process.env.JWT_SECRET;
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should throw error when JWT_SECRET is too short', async () => {
setValidEnv({
JWT_SECRET: 'short',
});
await expect(import('./env')).rejects.toThrow('CONFIGURATION ERROR');
});
it('should include field path in error message', async () => {
setValidEnv();
delete process.env.DB_HOST;
await expect(import('./env')).rejects.toThrow('database.host');
});
});
describe('environment modes', () => {
it('should set nodeEnv to development by default', async () => {
setValidEnv();
delete process.env.NODE_ENV;
const { config } = await import('./env');
expect(config.server.nodeEnv).toBe('development');
});
it('should accept production as NODE_ENV', async () => {
setValidEnv({
NODE_ENV: 'production',
});
const { config, isProduction, isDevelopment, isTest } = await import('./env');
expect(config.server.nodeEnv).toBe('production');
expect(isProduction).toBe(true);
expect(isDevelopment).toBe(false);
expect(isTest).toBe(false);
});
it('should accept development as NODE_ENV', async () => {
setValidEnv({
NODE_ENV: 'development',
});
const { config, isProduction, isDevelopment, isTest } = await import('./env');
expect(config.server.nodeEnv).toBe('development');
expect(isProduction).toBe(false);
expect(isDevelopment).toBe(true);
expect(isTest).toBe(false);
});
});
describe('server configuration', () => {
it('should parse FRONTEND_URL when provided', async () => {
setValidEnv({
FRONTEND_URL: 'https://example.com',
});
const { config } = await import('./env');
expect(config.server.frontendUrl).toBe('https://example.com');
});
it('should parse BASE_URL when provided', async () => {
setValidEnv({
BASE_URL: '/api/v1',
});
const { config } = await import('./env');
expect(config.server.baseUrl).toBe('/api/v1');
});
it('should parse STORAGE_PATH when provided', async () => {
setValidEnv({
STORAGE_PATH: '/custom/storage/path',
});
const { config } = await import('./env');
expect(config.server.storagePath).toBe('/custom/storage/path');
});
});
describe('Redis configuration', () => {
it('should parse REDIS_PASSWORD when provided', async () => {
setValidEnv({
REDIS_PASSWORD: 'redis-secret',
});
const { config } = await import('./env');
expect(config.redis.password).toBe('redis-secret');
});
});
});

View File

@@ -0,0 +1,98 @@
// src/config/queryClient.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { useMutation } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { queryClient } from './queryClient';
import * as loggerModule from '../services/logger.client';
vi.mock('../services/logger.client', () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
const mockedLogger = vi.mocked(loggerModule.logger);
describe('queryClient', () => {
beforeEach(() => {
vi.resetAllMocks();
queryClient.clear();
});
afterEach(() => {
queryClient.clear();
});
describe('configuration', () => {
it('should have correct default query options', () => {
const defaultOptions = queryClient.getDefaultOptions();
expect(defaultOptions.queries?.staleTime).toBe(1000 * 60 * 5); // 5 minutes
expect(defaultOptions.queries?.gcTime).toBe(1000 * 60 * 30); // 30 minutes
expect(defaultOptions.queries?.retry).toBe(1);
expect(defaultOptions.queries?.refetchOnWindowFocus).toBe(false);
expect(defaultOptions.queries?.refetchOnMount).toBe(true);
expect(defaultOptions.queries?.refetchOnReconnect).toBe(false);
});
it('should have correct default mutation options', () => {
const defaultOptions = queryClient.getDefaultOptions();
expect(defaultOptions.mutations?.retry).toBe(0);
expect(defaultOptions.mutations?.onError).toBeDefined();
});
});
describe('mutation onError callback', () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it('should log Error instance message on mutation error', async () => {
const testError = new Error('Test mutation error');
const { result } = renderHook(
() =>
useMutation({
mutationFn: async () => {
throw testError;
},
}),
{ wrapper },
);
result.current.mutate();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedLogger.error).toHaveBeenCalledWith('Mutation error', {
error: 'Test mutation error',
});
});
it('should log "Unknown error" for non-Error objects', async () => {
const { result } = renderHook(
() =>
useMutation({
mutationFn: async () => {
throw 'string error';
},
}),
{ wrapper },
);
result.current.mutate();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedLogger.error).toHaveBeenCalledWith('Mutation error', {
error: 'Unknown error',
});
});
});
});

View File

@@ -124,4 +124,59 @@ describe('PriceChart', () => {
// Milk: $1.13/L (already metric)
expect(screen.getByText('$1.13/L')).toBeInTheDocument();
});
it('should display N/A when unit_price is null or undefined', () => {
const dealsWithoutUnitPrice: DealItem[] = [
{
item: 'Mystery Item',
master_item_name: null,
price_display: '$9.99',
price_in_cents: 999,
quantity: '1 pack',
storeName: 'Test Store',
unit_price: null, // No unit price available
},
];
mockedUseActiveDeals.mockReturnValue({
activeDeals: dealsWithoutUnitPrice,
isLoading: false,
error: null,
totalActiveItems: dealsWithoutUnitPrice.length,
});
render(<PriceChart {...defaultProps} />);
expect(screen.getByText('Mystery Item')).toBeInTheDocument();
expect(screen.getByText('$9.99')).toBeInTheDocument();
expect(screen.getByText('N/A')).toBeInTheDocument();
});
it('should not show master item name when it matches the item name (case insensitive)', () => {
const dealWithSameMasterName: DealItem[] = [
{
item: 'Apples',
master_item_name: 'APPLES', // Same as item name, different case
price_display: '$2.99',
price_in_cents: 299,
quantity: 'per lb',
storeName: 'Fresh Mart',
unit_price: { value: 299, unit: 'lb' },
},
];
mockedUseActiveDeals.mockReturnValue({
activeDeals: dealWithSameMasterName,
isLoading: false,
error: null,
totalActiveItems: dealWithSameMasterName.length,
});
render(<PriceChart {...defaultProps} />);
expect(screen.getByText('Apples')).toBeInTheDocument();
// The master item name should NOT be shown since it matches the item name
expect(screen.queryByText('(APPLES)')).not.toBeInTheDocument();
expect(screen.queryByText('(Apples)')).not.toBeInTheDocument();
});
});

View File

@@ -301,4 +301,61 @@ describe('AnalysisPanel', () => {
expect(screen.getByText('Some insights.')).toBeInTheDocument();
expect(screen.queryByText('Sources:')).not.toBeInTheDocument();
});
it('should display sources for Plan Trip analysis type', () => {
const { rerender } = render(<AnalysisPanel selectedFlyer={mockFlyer} />);
fireEvent.click(screen.getByRole('tab', { name: /plan trip/i }));
mockedUseAiAnalysis.mockReturnValue({
results: { PLAN_TRIP: 'Here is your trip plan.' },
sources: {
PLAN_TRIP: [{ title: 'Store Location', uri: 'https://maps.example.com/store1' }],
},
loadingAnalysis: null,
error: null,
runAnalysis: mockRunAnalysis,
generatedImageUrl: null,
generateImage: mockGenerateImage,
});
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
expect(screen.getByText('Here is your trip plan.')).toBeInTheDocument();
expect(screen.getByText('Sources:')).toBeInTheDocument();
expect(screen.getByText('Store Location')).toBeInTheDocument();
});
it('should display sources for Compare Prices analysis type', () => {
const { rerender } = render(<AnalysisPanel selectedFlyer={mockFlyer} />);
fireEvent.click(screen.getByRole('tab', { name: /compare prices/i }));
mockedUseAiAnalysis.mockReturnValue({
results: { COMPARE_PRICES: 'Price comparison results.' },
sources: {
COMPARE_PRICES: [{ title: 'Price Source', uri: 'https://prices.example.com/compare' }],
},
loadingAnalysis: null,
error: null,
runAnalysis: mockRunAnalysis,
generatedImageUrl: null,
generateImage: mockGenerateImage,
});
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
expect(screen.getByText('Price comparison results.')).toBeInTheDocument();
expect(screen.getByText('Sources:')).toBeInTheDocument();
expect(screen.getByText('Price Source')).toBeInTheDocument();
});
it('should show a loading spinner when loading watched items', () => {
mockedUseUserData.mockReturnValue({
watchedItems: [],
isLoading: true,
error: null,
});
render(<AnalysisPanel selectedFlyer={mockFlyer} />);
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('Loading data...')).toBeInTheDocument();
});
});

View File

@@ -112,6 +112,30 @@ describe('BulkImporter', () => {
expect(dropzone).not.toHaveClass('border-brand-primary');
});
it('should not call onFilesChange when files are dropped while isProcessing is true', () => {
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={true} />);
const dropzone = screen.getByText(/processing, please wait.../i).closest('label')!;
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
});
expect(mockOnFilesChange).not.toHaveBeenCalled();
});
it('should handle file input change with null files', async () => {
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
const input = screen.getByLabelText(/click to upload/i);
// Simulate a change event with null files (e.g., when user cancels file picker)
fireEvent.change(input, { target: { files: null } });
expect(mockOnFilesChange).not.toHaveBeenCalled();
});
describe('when files are selected', () => {
const imageFile = new File(['image-content'], 'flyer.jpg', { type: 'image/jpeg' });
const pdfFile = new File(['pdf-content'], 'document.pdf', { type: 'application/pdf' });

View File

@@ -561,5 +561,67 @@ describe('ExtractedDataTable', () => {
render(<ExtractedDataTable {...defaultProps} items={[itemWithQtyNum]} />);
expect(screen.getByText('(5)')).toBeInTheDocument();
});
it('should use fallback category when adding to watchlist for items without category_name', () => {
const itemWithoutCategory = createMockFlyerItem({
flyer_item_id: 999,
item: 'Mystery Item',
master_item_id: 10,
category_name: undefined,
flyer_id: 1,
});
// Mock masterItems to include a matching item for canonical name resolution
vi.mocked(useMasterItems).mockReturnValue({
masterItems: [
createMockMasterGroceryItem({
master_grocery_item_id: 10,
name: 'Canonical Mystery',
}),
],
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} items={[itemWithoutCategory]} />);
const itemRow = screen.getByText('Mystery Item').closest('tr')!;
const watchButton = within(itemRow).getByTitle("Add 'Canonical Mystery' to your watchlist");
fireEvent.click(watchButton);
expect(mockAddWatchedItem).toHaveBeenCalledWith('Canonical Mystery', 'Other/Miscellaneous');
});
it('should not call addItemToList when activeListId is null and button is clicked', () => {
vi.mocked(useShoppingLists).mockReturnValue({
activeListId: null,
shoppingLists: [],
addItemToList: mockAddItemToList,
setActiveListId: vi.fn(),
createList: vi.fn(),
deleteList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
isCreatingList: false,
isDeletingList: false,
isAddingItem: false,
isUpdatingItem: false,
isRemovingItem: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
// Even with disabled button, test the handler logic by verifying no call is made
// The buttons are disabled but we verify that even if clicked, no action occurs
const addToListButtons = screen.getAllByTitle('Select a shopping list first');
expect(addToListButtons.length).toBeGreaterThan(0);
// Click the button (even though disabled)
fireEvent.click(addToListButtons[0]);
// addItemToList should not be called because activeListId is null
expect(mockAddItemToList).not.toHaveBeenCalled();
});
});
});

View File

@@ -65,6 +65,12 @@ describe('FlyerDisplay', () => {
expect(screen.queryByAltText('SuperMart Logo')).not.toBeInTheDocument();
});
it('should use fallback alt text when store has logo_url but no name', () => {
const storeWithoutName = { ...mockStore, name: undefined };
render(<FlyerDisplay {...defaultProps} store={storeWithoutName as any} />);
expect(screen.getByAltText('Store Logo')).toBeInTheDocument();
});
it('should format a single day validity correctly', () => {
render(<FlyerDisplay {...defaultProps} validFrom="2023-10-26" validTo="2023-10-26" />);
expect(screen.getByText('Valid on October 26, 2023')).toBeInTheDocument();

View File

@@ -322,6 +322,20 @@ describe('FlyerList', () => {
expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument();
expect(screen.getByText('• Expires in 6 days')).toHaveClass('text-green-600');
});
it('should show "Expires in 1 day" (singular) when exactly 1 day left', () => {
vi.setSystemTime(new Date('2023-10-10T12:00:00Z')); // 1 day left until Oct 11
render(
<FlyerList
flyers={[mockFlyers[0]]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('• Expires in 1 day')).toBeInTheDocument();
expect(screen.getByText('• Expires in 1 day')).toHaveClass('text-orange-500');
});
});
describe('Admin Functionality', () => {
@@ -420,6 +434,29 @@ describe('FlyerList', () => {
expect(mockedToast.error).toHaveBeenCalledWith('Cleanup failed');
});
});
it('should show generic error toast if cleanup API call fails with non-Error object', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
// Reject with a non-Error value (e.g., a string or object)
mockedApiClient.cleanupFlyerFiles.mockRejectedValue('Some non-error value');
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
fireEvent.click(cleanupButton);
await waitFor(() => {
expect(mockedApiClient.cleanupFlyerFiles).toHaveBeenCalledWith(1);
expect(mockedToast.error).toHaveBeenCalledWith('Failed to enqueue cleanup job.');
});
});
});
});

View File

@@ -210,4 +210,60 @@ describe('ProcessingStatus', () => {
expect(nonCriticalErrorStage).toHaveTextContent('(optional)');
});
});
describe('Edge Cases', () => {
it('should render null for unknown stage status icon', () => {
const stagesWithUnknownStatus: ProcessingStage[] = [
createMockProcessingStage({
name: 'Unknown Stage',
status: 'unknown-status' as any,
detail: '',
}),
];
render(<ProcessingStatus stages={stagesWithUnknownStatus} estimatedTime={60} />);
const stageIcon = screen.getByTestId('stage-icon-0');
// The icon container should be empty (no SVG or spinner rendered)
expect(stageIcon.querySelector('svg')).not.toBeInTheDocument();
expect(stageIcon.querySelector('.animate-spin')).not.toBeInTheDocument();
});
it('should return empty string for unknown stage status text color', () => {
const stagesWithUnknownStatus: ProcessingStage[] = [
createMockProcessingStage({
name: 'Unknown Stage',
status: 'unknown-status' as any,
detail: '',
}),
];
render(<ProcessingStatus stages={stagesWithUnknownStatus} estimatedTime={60} />);
const stageText = screen.getByTestId('stage-text-0');
// Should not have any of the known status color classes
expect(stageText.className).not.toContain('text-brand-primary');
expect(stageText.className).not.toContain('text-gray-700');
expect(stageText.className).not.toContain('text-gray-400');
expect(stageText.className).not.toContain('text-red-500');
expect(stageText.className).not.toContain('text-yellow-600');
});
it('should not render page progress bar when total is 1', () => {
render(
<ProcessingStatus stages={[]} estimatedTime={60} pageProgress={{ current: 1, total: 1 }} />,
);
expect(screen.queryByText(/converting pdf/i)).not.toBeInTheDocument();
});
it('should not render stage progress bar when total is 1', () => {
const stagesWithSinglePageProgress: ProcessingStage[] = [
createMockProcessingStage({
name: 'Extracting Items',
status: 'in-progress',
progress: { current: 1, total: 1 },
}),
];
render(<ProcessingStatus stages={stagesWithSinglePageProgress} estimatedTime={60} />);
expect(screen.queryByText(/analyzing page/i)).not.toBeInTheDocument();
});
});
});

View File

@@ -60,7 +60,9 @@ describe('useAddShoppingListItemMutation', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addShoppingListItem).toHaveBeenCalledWith(1, { customItemName: 'Special Milk' });
expect(mockedApiClient.addShoppingListItem).toHaveBeenCalledWith(1, {
customItemName: 'Special Milk',
});
});
it('should invalidate shopping-lists query on success', async () => {
@@ -97,7 +99,7 @@ describe('useAddShoppingListItemMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item already exists');
});
it('should handle API error without message', async () => {
it('should handle API error when json parse fails', async () => {
mockedApiClient.addShoppingListItem.mockResolvedValue({
ok: false,
status: 500,
@@ -114,6 +116,22 @@ describe('useAddShoppingListItemMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Request failed with status 500');
});
it('should handle API error with empty message in response', async () => {
mockedApiClient.addShoppingListItem.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: '' }),
} 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('Failed to add item to shopping list');
});
it('should handle network error', async () => {
mockedApiClient.addShoppingListItem.mockRejectedValue(new Error('Network error'));
@@ -125,4 +143,18 @@ describe('useAddShoppingListItemMutation', () => {
expect(result.current.error?.message).toBe('Network error');
});
it('should use fallback error message when error has no message', async () => {
mockedApiClient.addShoppingListItem.mockRejectedValue(new Error(''));
const { result } = renderHook(() => useAddShoppingListItemMutation(), { wrapper });
result.current.mutate({ listId: 1, item: { masterItemId: 42 } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedNotifications.notifyError).toHaveBeenCalledWith(
'Failed to add item to shopping list',
);
});
});

View File

@@ -97,7 +97,7 @@ describe('useAddWatchedItemMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item already watched');
});
it('should handle API error without message', async () => {
it('should handle API error when json parse fails', async () => {
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: false,
status: 500,
@@ -112,4 +112,34 @@ describe('useAddWatchedItemMutation', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should handle API error with empty message in response', async () => {
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Butter' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to add watched item');
});
it('should use fallback error message when error has no message', async () => {
mockedApiClient.addWatchedItem.mockRejectedValue(new Error(''));
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Yogurt' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedNotifications.notifyError).toHaveBeenCalledWith(
'Failed to add item to watched list',
);
});
});

View File

@@ -81,7 +81,7 @@ describe('useCreateShoppingListMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('List name already exists');
});
it('should handle API error without message', async () => {
it('should handle API error when json parse fails', async () => {
mockedApiClient.createShoppingList.mockResolvedValue({
ok: false,
status: 500,
@@ -96,4 +96,32 @@ describe('useCreateShoppingListMutation', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should handle API error with empty message in response', async () => {
mockedApiClient.createShoppingList.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useCreateShoppingListMutation(), { wrapper });
result.current.mutate({ name: 'Empty Error' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to create shopping list');
});
it('should use fallback error message when error has no message', async () => {
mockedApiClient.createShoppingList.mockRejectedValue(new Error(''));
const { result } = renderHook(() => useCreateShoppingListMutation(), { wrapper });
result.current.mutate({ name: 'New List' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Failed to create shopping list');
});
});

View File

@@ -81,7 +81,7 @@ describe('useDeleteShoppingListMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Shopping list not found');
});
it('should handle API error without message', async () => {
it('should handle API error when json parse fails', async () => {
mockedApiClient.deleteShoppingList.mockResolvedValue({
ok: false,
status: 500,
@@ -96,4 +96,32 @@ describe('useDeleteShoppingListMutation', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should handle API error with empty message in response', async () => {
mockedApiClient.deleteShoppingList.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useDeleteShoppingListMutation(), { wrapper });
result.current.mutate({ listId: 456 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to delete shopping list');
});
it('should use fallback error message when error has no message', async () => {
mockedApiClient.deleteShoppingList.mockRejectedValue(new Error(''));
const { result } = renderHook(() => useDeleteShoppingListMutation(), { wrapper });
result.current.mutate({ listId: 789 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Failed to delete shopping list');
});
});

View File

@@ -44,7 +44,9 @@ describe('useRemoveShoppingListItemMutation', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.removeShoppingListItem).toHaveBeenCalledWith(42);
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item removed from shopping list');
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith(
'Item removed from shopping list',
);
});
it('should invalidate shopping-lists query on success', async () => {
@@ -81,7 +83,7 @@ describe('useRemoveShoppingListItemMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item not found');
});
it('should handle API error without message', async () => {
it('should handle API error when json parse fails', async () => {
mockedApiClient.removeShoppingListItem.mockResolvedValue({
ok: false,
status: 500,
@@ -96,4 +98,34 @@ describe('useRemoveShoppingListItemMutation', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should handle API error with empty message in response', async () => {
mockedApiClient.removeShoppingListItem.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useRemoveShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 88 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to remove shopping list item');
});
it('should use fallback error message when error has no message', async () => {
mockedApiClient.removeShoppingListItem.mockRejectedValue(new Error(''));
const { result } = renderHook(() => useRemoveShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 555 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedNotifications.notifyError).toHaveBeenCalledWith(
'Failed to remove shopping list item',
);
});
});

View File

@@ -44,7 +44,9 @@ describe('useRemoveWatchedItemMutation', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.removeWatchedItem).toHaveBeenCalledWith(123);
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item removed from watched list');
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith(
'Item removed from watched list',
);
});
it('should invalidate watched-items query on success', async () => {
@@ -81,7 +83,7 @@ describe('useRemoveWatchedItemMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Watched item not found');
});
it('should handle API error without message', async () => {
it('should handle API error when json parse fails', async () => {
mockedApiClient.removeWatchedItem.mockResolvedValue({
ok: false,
status: 500,
@@ -96,4 +98,34 @@ describe('useRemoveWatchedItemMutation', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should handle API error with empty message in response', async () => {
mockedApiClient.removeWatchedItem.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useRemoveWatchedItemMutation(), { wrapper });
result.current.mutate({ masterItemId: 222 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to remove watched item');
});
it('should use fallback error message when error has no message', async () => {
mockedApiClient.removeWatchedItem.mockRejectedValue(new Error(''));
const { result } = renderHook(() => useRemoveWatchedItemMutation(), { wrapper });
result.current.mutate({ masterItemId: 321 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedNotifications.notifyError).toHaveBeenCalledWith(
'Failed to remove item from watched list',
);
});
});

View File

@@ -74,7 +74,9 @@ describe('useUpdateShoppingListItemMutation', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { custom_item_name: 'Organic Milk' });
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, {
custom_item_name: 'Organic Milk',
});
});
it('should update notes', async () => {
@@ -89,7 +91,9 @@ describe('useUpdateShoppingListItemMutation', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { notes: 'Get the 2% variety' });
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, {
notes: 'Get the 2% variety',
});
});
it('should update multiple fields at once', async () => {
@@ -104,7 +108,10 @@ describe('useUpdateShoppingListItemMutation', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, { quantity: 2, notes: 'Important' });
expect(mockedApiClient.updateShoppingListItem).toHaveBeenCalledWith(42, {
quantity: 2,
notes: 'Important',
});
});
it('should invalidate shopping-lists query on success', async () => {
@@ -141,7 +148,7 @@ describe('useUpdateShoppingListItemMutation', () => {
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item not found');
});
it('should handle API error without message', async () => {
it('should handle API error when json parse fails', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: false,
status: 500,
@@ -156,4 +163,34 @@ describe('useUpdateShoppingListItemMutation', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should handle API error with empty message in response', async () => {
mockedApiClient.updateShoppingListItem.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 99, updates: { notes: 'test' } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to update shopping list item');
});
it('should use fallback error message when error has no message', async () => {
mockedApiClient.updateShoppingListItem.mockRejectedValue(new Error(''));
const { result } = renderHook(() => useUpdateShoppingListItemMutation(), { wrapper });
result.current.mutate({ itemId: 77, updates: { is_purchased: true } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockedNotifications.notifyError).toHaveBeenCalledWith(
'Failed to update shopping list item',
);
});
});

View File

@@ -87,6 +87,20 @@ describe('useActivityLogQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useActivityLogQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch activity log');
});
it('should return empty array for no activity log entries', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue({
ok: true,

View File

@@ -75,4 +75,18 @@ describe('useApplicationStatsQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.getApplicationStats.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useApplicationStatsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch application stats');
});
});

View File

@@ -73,6 +73,20 @@ describe('useCategoriesQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchCategories.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useCategoriesQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch categories');
});
it('should return empty array for no categories', async () => {
mockedApiClient.fetchCategories.mockResolvedValue({
ok: true,

View File

@@ -83,6 +83,33 @@ describe('useFlyerItemsQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useFlyerItemsQuery(42), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch flyer items');
});
it('should throw error when refetch is called without flyerId', async () => {
// This tests the internal guard in queryFn that throws if flyerId is undefined
// We call refetch() manually to force the queryFn to execute even when disabled
const { result } = renderHook(() => useFlyerItemsQuery(undefined), { wrapper });
// Force the query to run by calling refetch
await result.current.refetch();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Flyer ID is required');
});
it('should return empty array when API returns no items', async () => {
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: true,

View File

@@ -87,6 +87,20 @@ describe('useFlyersQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchFlyers.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useFlyersQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch flyers');
});
it('should return empty array for no flyers', async () => {
mockedApiClient.fetchFlyers.mockResolvedValue({
ok: true,

View File

@@ -73,6 +73,20 @@ describe('useMasterItemsQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchMasterItems.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useMasterItemsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch master items');
});
it('should return empty array for no master items', async () => {
mockedApiClient.fetchMasterItems.mockResolvedValue({
ok: true,

View File

@@ -83,6 +83,20 @@ describe('useShoppingListsQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchShoppingLists.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useShoppingListsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch shopping lists');
});
it('should return empty array for no shopping lists', async () => {
mockedApiClient.fetchShoppingLists.mockResolvedValue({
ok: true,

View File

@@ -72,6 +72,20 @@ describe('useSuggestedCorrectionsQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useSuggestedCorrectionsQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch suggested corrections');
});
it('should return empty array for no corrections', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
ok: true,

View File

@@ -83,6 +83,20 @@ describe('useWatchedItemsQuery', () => {
expect(result.current.error?.message).toBe('Request failed with status 500');
});
it('should use fallback message when error.message is empty', async () => {
mockedApiClient.fetchWatchedItems.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '' }),
} as Response);
const { result } = renderHook(() => useWatchedItemsQuery(true), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Failed to fetch watched items');
});
it('should return empty array for no watched items', async () => {
mockedApiClient.fetchWatchedItems.mockResolvedValue({
ok: true,

View File

@@ -1,22 +1,29 @@
// src/hooks/useDataExtraction.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useDataExtraction } from './useDataExtraction';
import type { Flyer } from '../types';
// Create a mock flyer for testing
const createMockFlyer = (id: number, storeName: string = `Store ${id}`): Flyer => ({
flyer_id: id,
store: { store_id: id, name: storeName },
start_date: '2024-01-01',
end_date: '2024-01-07',
store: {
store_id: id,
name: storeName,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
file_name: `flyer${id}.jpg`,
image_url: `https://example.com/flyer${id}.jpg`,
icon_url: `https://example.com/flyer${id}_icon.jpg`,
status: 'processed',
item_count: 0,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
describe('useDataExtraction Hook', () => {
let mockOnFlyerUpdate: ReturnType<typeof vi.fn>;
let mockOnFlyerUpdate: Mock<(flyer: Flyer) => void>;
beforeEach(() => {
mockOnFlyerUpdate = vi.fn();
@@ -66,7 +73,7 @@ describe('useDataExtraction Hook', () => {
expect(mockOnFlyerUpdate).toHaveBeenCalledTimes(1);
const updatedFlyer = mockOnFlyerUpdate.mock.calls[0][0];
expect(updatedFlyer.store.name).toBe('New Store Name');
expect(updatedFlyer.store?.name).toBe('New Store Name');
// Ensure other properties are preserved
expect(updatedFlyer.flyer_id).toBe(1);
expect(updatedFlyer.image_url).toBe('https://example.com/flyer1.jpg');
@@ -86,7 +93,7 @@ describe('useDataExtraction Hook', () => {
});
const updatedFlyer = mockOnFlyerUpdate.mock.calls[0][0];
expect(updatedFlyer.store.store_id).toBe(42);
expect(updatedFlyer.store?.store_id).toBe(42);
});
});
@@ -168,7 +175,7 @@ describe('useDataExtraction Hook', () => {
it('should update handler when onFlyerUpdate changes', () => {
const mockFlyer = createMockFlyer(1);
const mockOnFlyerUpdate2 = vi.fn();
const mockOnFlyerUpdate2: Mock<(flyer: Flyer) => void> = vi.fn();
const { result, rerender } = renderHook(
({ onFlyerUpdate }) =>

View File

@@ -20,12 +20,19 @@ vi.mock('../services/logger.client', () => ({
// Create mock flyers for testing
const createMockFlyer = (id: number, storeName: string = `Store ${id}`): Flyer => ({
flyer_id: id,
store: { store_id: id, name: storeName },
start_date: '2024-01-01',
end_date: '2024-01-07',
store: {
store_id: id,
name: storeName,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
file_name: `flyer${id}.jpg`,
image_url: `https://example.com/flyer${id}.jpg`,
icon_url: `https://example.com/flyer${id}_icon.jpg`,
status: 'processed',
item_count: 0,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
const mockFlyers: Flyer[] = [

View File

@@ -13,5 +13,10 @@ export default defineConfig({
// This line is the key fix: it tells Vitest to include the type definitions
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
exclude: [
'**/index.ts', // barrel exports don't need coverage
],
},
},
});