From a42ee5a461cf78984f19f3decc8c724066fe42ec Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Fri, 9 Jan 2026 21:59:09 -0800 Subject: [PATCH] unit tests - wheeee! Claude is the mvp --- .../0040-testing-economics-and-priorities.md | 214 +++++++++ docs/adr/index.md | 1 + src/components/FlyerCorrectionTool.test.tsx | 45 +- src/config/env.test.ts | 432 ++++++++++++++++++ src/config/queryClient.test.tsx | 98 ++++ src/features/charts/PriceChart.test.tsx | 55 +++ src/features/flyer/AnalysisPanel.test.tsx | 57 +++ src/features/flyer/BulkImporter.test.tsx | 24 + .../flyer/ExtractedDataTable.test.tsx | 62 +++ src/features/flyer/FlyerDisplay.test.tsx | 6 + src/features/flyer/FlyerList.test.tsx | 37 ++ src/features/flyer/ProcessingStatus.test.tsx | 56 +++ .../useAddShoppingListItemMutation.test.tsx | 36 +- .../useAddWatchedItemMutation.test.tsx | 32 +- .../useCreateShoppingListMutation.test.tsx | 30 +- .../useDeleteShoppingListMutation.test.tsx | 30 +- ...useRemoveShoppingListItemMutation.test.tsx | 36 +- .../useRemoveWatchedItemMutation.test.tsx | 36 +- ...useUpdateShoppingListItemMutation.test.tsx | 45 +- .../queries/useActivityLogQuery.test.tsx | 14 + .../queries/useApplicationStatsQuery.test.tsx | 14 + src/hooks/queries/useCategoriesQuery.test.tsx | 14 + src/hooks/queries/useFlyerItemsQuery.test.tsx | 27 ++ src/hooks/queries/useFlyersQuery.test.tsx | 14 + .../queries/useMasterItemsQuery.test.tsx | 14 + .../queries/useShoppingListsQuery.test.tsx | 14 + .../useSuggestedCorrectionsQuery.test.tsx | 14 + .../queries/useWatchedItemsQuery.test.tsx | 14 + src/hooks/useDataExtraction.test.ts | 23 +- src/hooks/useFlyerSelection.test.tsx | 13 +- vitest.config.ts | 5 + 31 files changed, 1487 insertions(+), 25 deletions(-) create mode 100644 docs/adr/0040-testing-economics-and-priorities.md create mode 100644 src/config/env.test.ts create mode 100644 src/config/queryClient.test.tsx diff --git a/docs/adr/0040-testing-economics-and-priorities.md b/docs/adr/0040-testing-economics-and-priorities.md new file mode 100644 index 0000000..5f9b220 --- /dev/null +++ b/docs/adr/0040-testing-economics-and-priorities.md @@ -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. diff --git a/docs/adr/index.md b/docs/adr/index.md index feab517..a7a82c3 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -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 diff --git a/src/components/FlyerCorrectionTool.test.tsx b/src/components/FlyerCorrectionTool.test.tsx index 720dc22..9aa42d2 100644 --- a/src/components/FlyerCorrectionTool.test.tsx +++ b/src/components/FlyerCorrectionTool.test.tsx @@ -48,7 +48,9 @@ describe('FlyerCorrectionTool', () => { }); it('should not render when isOpen is false', () => { - const { container } = renderWithProviders(); + const { container } = renderWithProviders( + , + ); 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(); + + // 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'); + }); + }); }); diff --git a/src/config/env.test.ts b/src/config/env.test.ts new file mode 100644 index 0000000..5630716 --- /dev/null +++ b/src/config/env.test.ts @@ -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 = {}) { + 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'); + }); + }); +}); diff --git a/src/config/queryClient.test.tsx b/src/config/queryClient.test.tsx new file mode 100644 index 0000000..e17fc1c --- /dev/null +++ b/src/config/queryClient.test.tsx @@ -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 }) => ( + {children} + ); + + 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', + }); + }); + }); +}); diff --git a/src/features/charts/PriceChart.test.tsx b/src/features/charts/PriceChart.test.tsx index 29fbe6d..e184325 100644 --- a/src/features/charts/PriceChart.test.tsx +++ b/src/features/charts/PriceChart.test.tsx @@ -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(); + + 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(); + + 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(); + }); }); diff --git a/src/features/flyer/AnalysisPanel.test.tsx b/src/features/flyer/AnalysisPanel.test.tsx index 4522333..8107ec9 100644 --- a/src/features/flyer/AnalysisPanel.test.tsx +++ b/src/features/flyer/AnalysisPanel.test.tsx @@ -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(); + 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(); + + 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(); + 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(); + + 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(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText('Loading data...')).toBeInTheDocument(); + }); }); diff --git a/src/features/flyer/BulkImporter.test.tsx b/src/features/flyer/BulkImporter.test.tsx index f300a6b..e8222e8 100644 --- a/src/features/flyer/BulkImporter.test.tsx +++ b/src/features/flyer/BulkImporter.test.tsx @@ -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(); + 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(); + 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' }); diff --git a/src/features/flyer/ExtractedDataTable.test.tsx b/src/features/flyer/ExtractedDataTable.test.tsx index 28e48f1..dab9809 100644 --- a/src/features/flyer/ExtractedDataTable.test.tsx +++ b/src/features/flyer/ExtractedDataTable.test.tsx @@ -561,5 +561,67 @@ describe('ExtractedDataTable', () => { render(); 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(); + + 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(); + + // 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(); + }); }); }); diff --git a/src/features/flyer/FlyerDisplay.test.tsx b/src/features/flyer/FlyerDisplay.test.tsx index 3012d0c..faaa917 100644 --- a/src/features/flyer/FlyerDisplay.test.tsx +++ b/src/features/flyer/FlyerDisplay.test.tsx @@ -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(); + expect(screen.getByAltText('Store Logo')).toBeInTheDocument(); + }); + it('should format a single day validity correctly', () => { render(); expect(screen.getByText('Valid on October 26, 2023')).toBeInTheDocument(); diff --git a/src/features/flyer/FlyerList.test.tsx b/src/features/flyer/FlyerList.test.tsx index 71016dd..0b9934d 100644 --- a/src/features/flyer/FlyerList.test.tsx +++ b/src/features/flyer/FlyerList.test.tsx @@ -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( + , + ); + 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( + , + ); + + 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.'); + }); + }); }); }); diff --git a/src/features/flyer/ProcessingStatus.test.tsx b/src/features/flyer/ProcessingStatus.test.tsx index f4d91b9..01a618e 100644 --- a/src/features/flyer/ProcessingStatus.test.tsx +++ b/src/features/flyer/ProcessingStatus.test.tsx @@ -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(); + + 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(); + + 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( + , + ); + 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(); + expect(screen.queryByText(/analyzing page/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/hooks/mutations/useAddShoppingListItemMutation.test.tsx b/src/hooks/mutations/useAddShoppingListItemMutation.test.tsx index 0122109..e498e42 100644 --- a/src/hooks/mutations/useAddShoppingListItemMutation.test.tsx +++ b/src/hooks/mutations/useAddShoppingListItemMutation.test.tsx @@ -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', + ); + }); }); diff --git a/src/hooks/mutations/useAddWatchedItemMutation.test.tsx b/src/hooks/mutations/useAddWatchedItemMutation.test.tsx index 6550562..c0dc0a0 100644 --- a/src/hooks/mutations/useAddWatchedItemMutation.test.tsx +++ b/src/hooks/mutations/useAddWatchedItemMutation.test.tsx @@ -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', + ); + }); }); diff --git a/src/hooks/mutations/useCreateShoppingListMutation.test.tsx b/src/hooks/mutations/useCreateShoppingListMutation.test.tsx index e090703..41c0dba 100644 --- a/src/hooks/mutations/useCreateShoppingListMutation.test.tsx +++ b/src/hooks/mutations/useCreateShoppingListMutation.test.tsx @@ -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'); + }); }); diff --git a/src/hooks/mutations/useDeleteShoppingListMutation.test.tsx b/src/hooks/mutations/useDeleteShoppingListMutation.test.tsx index 3907955..3a24fd1 100644 --- a/src/hooks/mutations/useDeleteShoppingListMutation.test.tsx +++ b/src/hooks/mutations/useDeleteShoppingListMutation.test.tsx @@ -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'); + }); }); diff --git a/src/hooks/mutations/useRemoveShoppingListItemMutation.test.tsx b/src/hooks/mutations/useRemoveShoppingListItemMutation.test.tsx index 37db03e..43e549d 100644 --- a/src/hooks/mutations/useRemoveShoppingListItemMutation.test.tsx +++ b/src/hooks/mutations/useRemoveShoppingListItemMutation.test.tsx @@ -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', + ); + }); }); diff --git a/src/hooks/mutations/useRemoveWatchedItemMutation.test.tsx b/src/hooks/mutations/useRemoveWatchedItemMutation.test.tsx index 942a923..d77e7f8 100644 --- a/src/hooks/mutations/useRemoveWatchedItemMutation.test.tsx +++ b/src/hooks/mutations/useRemoveWatchedItemMutation.test.tsx @@ -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', + ); + }); }); diff --git a/src/hooks/mutations/useUpdateShoppingListItemMutation.test.tsx b/src/hooks/mutations/useUpdateShoppingListItemMutation.test.tsx index 3f59457..638d455 100644 --- a/src/hooks/mutations/useUpdateShoppingListItemMutation.test.tsx +++ b/src/hooks/mutations/useUpdateShoppingListItemMutation.test.tsx @@ -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', + ); + }); }); diff --git a/src/hooks/queries/useActivityLogQuery.test.tsx b/src/hooks/queries/useActivityLogQuery.test.tsx index 579b1f3..e46c3c8 100644 --- a/src/hooks/queries/useActivityLogQuery.test.tsx +++ b/src/hooks/queries/useActivityLogQuery.test.tsx @@ -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, diff --git a/src/hooks/queries/useApplicationStatsQuery.test.tsx b/src/hooks/queries/useApplicationStatsQuery.test.tsx index a53c18d..ca5c0cd 100644 --- a/src/hooks/queries/useApplicationStatsQuery.test.tsx +++ b/src/hooks/queries/useApplicationStatsQuery.test.tsx @@ -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'); + }); }); diff --git a/src/hooks/queries/useCategoriesQuery.test.tsx b/src/hooks/queries/useCategoriesQuery.test.tsx index 3571bf8..9120c30 100644 --- a/src/hooks/queries/useCategoriesQuery.test.tsx +++ b/src/hooks/queries/useCategoriesQuery.test.tsx @@ -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, diff --git a/src/hooks/queries/useFlyerItemsQuery.test.tsx b/src/hooks/queries/useFlyerItemsQuery.test.tsx index d2cfeef..938500f 100644 --- a/src/hooks/queries/useFlyerItemsQuery.test.tsx +++ b/src/hooks/queries/useFlyerItemsQuery.test.tsx @@ -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, diff --git a/src/hooks/queries/useFlyersQuery.test.tsx b/src/hooks/queries/useFlyersQuery.test.tsx index b1095cf..bb0e104 100644 --- a/src/hooks/queries/useFlyersQuery.test.tsx +++ b/src/hooks/queries/useFlyersQuery.test.tsx @@ -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, diff --git a/src/hooks/queries/useMasterItemsQuery.test.tsx b/src/hooks/queries/useMasterItemsQuery.test.tsx index c513db7..24ab76d 100644 --- a/src/hooks/queries/useMasterItemsQuery.test.tsx +++ b/src/hooks/queries/useMasterItemsQuery.test.tsx @@ -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, diff --git a/src/hooks/queries/useShoppingListsQuery.test.tsx b/src/hooks/queries/useShoppingListsQuery.test.tsx index 1eb2770..49f8708 100644 --- a/src/hooks/queries/useShoppingListsQuery.test.tsx +++ b/src/hooks/queries/useShoppingListsQuery.test.tsx @@ -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, diff --git a/src/hooks/queries/useSuggestedCorrectionsQuery.test.tsx b/src/hooks/queries/useSuggestedCorrectionsQuery.test.tsx index e839395..9829785 100644 --- a/src/hooks/queries/useSuggestedCorrectionsQuery.test.tsx +++ b/src/hooks/queries/useSuggestedCorrectionsQuery.test.tsx @@ -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, diff --git a/src/hooks/queries/useWatchedItemsQuery.test.tsx b/src/hooks/queries/useWatchedItemsQuery.test.tsx index 0f3dae5..e84da9f 100644 --- a/src/hooks/queries/useWatchedItemsQuery.test.tsx +++ b/src/hooks/queries/useWatchedItemsQuery.test.tsx @@ -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, diff --git a/src/hooks/useDataExtraction.test.ts b/src/hooks/useDataExtraction.test.ts index 58b0f87..bac0447 100644 --- a/src/hooks/useDataExtraction.test.ts +++ b/src/hooks/useDataExtraction.test.ts @@ -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; + 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 }) => diff --git a/src/hooks/useFlyerSelection.test.tsx b/src/hooks/useFlyerSelection.test.tsx index b6f8ba4..c4bfff8 100644 --- a/src/hooks/useFlyerSelection.test.tsx +++ b/src/hooks/useFlyerSelection.test.tsx @@ -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[] = [ diff --git a/vitest.config.ts b/vitest.config.ts index 81bc4a4..b5c5174 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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 + ], + }, }, });