unit tests - wheeee! Claude is the mvp
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m11s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m11s
This commit is contained in:
214
docs/adr/0040-testing-economics-and-priorities.md
Normal file
214
docs/adr/0040-testing-economics-and-priorities.md
Normal 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.
|
||||||
@@ -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-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-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-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
|
## 9. Architecture Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when isOpen is false', () => {
|
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();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,4 +304,45 @@ describe('FlyerCorrectionTool', () => {
|
|||||||
|
|
||||||
expect(clearRectSpy).toHaveBeenCalled();
|
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
432
src/config/env.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/config/queryClient.test.tsx
Normal file
98
src/config/queryClient.test.tsx
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,4 +124,59 @@ describe('PriceChart', () => {
|
|||||||
// Milk: $1.13/L (already metric)
|
// Milk: $1.13/L (already metric)
|
||||||
expect(screen.getByText('$1.13/L')).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -301,4 +301,61 @@ describe('AnalysisPanel', () => {
|
|||||||
expect(screen.getByText('Some insights.')).toBeInTheDocument();
|
expect(screen.getByText('Some insights.')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Sources:')).not.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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,6 +112,30 @@ describe('BulkImporter', () => {
|
|||||||
expect(dropzone).not.toHaveClass('border-brand-primary');
|
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', () => {
|
describe('when files are selected', () => {
|
||||||
const imageFile = new File(['image-content'], 'flyer.jpg', { type: 'image/jpeg' });
|
const imageFile = new File(['image-content'], 'flyer.jpg', { type: 'image/jpeg' });
|
||||||
const pdfFile = new File(['pdf-content'], 'document.pdf', { type: 'application/pdf' });
|
const pdfFile = new File(['pdf-content'], 'document.pdf', { type: 'application/pdf' });
|
||||||
|
|||||||
@@ -561,5 +561,67 @@ describe('ExtractedDataTable', () => {
|
|||||||
render(<ExtractedDataTable {...defaultProps} items={[itemWithQtyNum]} />);
|
render(<ExtractedDataTable {...defaultProps} items={[itemWithQtyNum]} />);
|
||||||
expect(screen.getByText('(5)')).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ describe('FlyerDisplay', () => {
|
|||||||
expect(screen.queryByAltText('SuperMart Logo')).not.toBeInTheDocument();
|
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', () => {
|
it('should format a single day validity correctly', () => {
|
||||||
render(<FlyerDisplay {...defaultProps} validFrom="2023-10-26" validTo="2023-10-26" />);
|
render(<FlyerDisplay {...defaultProps} validFrom="2023-10-26" validTo="2023-10-26" />);
|
||||||
expect(screen.getByText('Valid on October 26, 2023')).toBeInTheDocument();
|
expect(screen.getByText('Valid on October 26, 2023')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -322,6 +322,20 @@ describe('FlyerList', () => {
|
|||||||
expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument();
|
expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument();
|
||||||
expect(screen.getByText('• Expires in 6 days')).toHaveClass('text-green-600');
|
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', () => {
|
describe('Admin Functionality', () => {
|
||||||
@@ -420,6 +434,29 @@ describe('FlyerList', () => {
|
|||||||
expect(mockedToast.error).toHaveBeenCalledWith('Cleanup failed');
|
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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -210,4 +210,60 @@ describe('ProcessingStatus', () => {
|
|||||||
expect(nonCriticalErrorStage).toHaveTextContent('(optional)');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ describe('useAddShoppingListItemMutation', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
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 () => {
|
it('should invalidate shopping-lists query on success', async () => {
|
||||||
@@ -97,7 +99,7 @@ describe('useAddShoppingListItemMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item already exists');
|
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({
|
mockedApiClient.addShoppingListItem.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -114,6 +116,22 @@ describe('useAddShoppingListItemMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Request failed with status 500');
|
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 () => {
|
it('should handle network error', async () => {
|
||||||
mockedApiClient.addShoppingListItem.mockRejectedValue(new Error('Network error'));
|
mockedApiClient.addShoppingListItem.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
@@ -125,4 +143,18 @@ describe('useAddShoppingListItemMutation', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Network error');
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe('useAddWatchedItemMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item already watched');
|
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({
|
mockedApiClient.addWatchedItem.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -112,4 +112,34 @@ describe('useAddWatchedItemMutation', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe('useCreateShoppingListMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('List name already exists');
|
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({
|
mockedApiClient.createShoppingList.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -96,4 +96,32 @@ describe('useCreateShoppingListMutation', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe('useDeleteShoppingListMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Shopping list not found');
|
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({
|
mockedApiClient.deleteShoppingList.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -96,4 +96,32 @@ describe('useDeleteShoppingListMutation', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ describe('useRemoveShoppingListItemMutation', () => {
|
|||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
expect(mockedApiClient.removeShoppingListItem).toHaveBeenCalledWith(42);
|
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 () => {
|
it('should invalidate shopping-lists query on success', async () => {
|
||||||
@@ -81,7 +83,7 @@ describe('useRemoveShoppingListItemMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item not found');
|
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({
|
mockedApiClient.removeShoppingListItem.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -96,4 +98,34 @@ describe('useRemoveShoppingListItemMutation', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ describe('useRemoveWatchedItemMutation', () => {
|
|||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
expect(mockedApiClient.removeWatchedItem).toHaveBeenCalledWith(123);
|
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 () => {
|
it('should invalidate watched-items query on success', async () => {
|
||||||
@@ -81,7 +83,7 @@ describe('useRemoveWatchedItemMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Watched item not found');
|
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({
|
mockedApiClient.removeWatchedItem.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -96,4 +98,34 @@ describe('useRemoveWatchedItemMutation', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ describe('useUpdateShoppingListItemMutation', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
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 () => {
|
it('should update notes', async () => {
|
||||||
@@ -89,7 +91,9 @@ describe('useUpdateShoppingListItemMutation', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
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 () => {
|
it('should update multiple fields at once', async () => {
|
||||||
@@ -104,7 +108,10 @@ describe('useUpdateShoppingListItemMutation', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
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 () => {
|
it('should invalidate shopping-lists query on success', async () => {
|
||||||
@@ -141,7 +148,7 @@ describe('useUpdateShoppingListItemMutation', () => {
|
|||||||
expect(mockedNotifications.notifyError).toHaveBeenCalledWith('Item not found');
|
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({
|
mockedApiClient.updateShoppingListItem.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -156,4 +163,34 @@ describe('useUpdateShoppingListItemMutation', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ describe('useActivityLogQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array for no activity log entries', async () => {
|
||||||
mockedApiClient.fetchActivityLog.mockResolvedValue({
|
mockedApiClient.fetchActivityLog.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -75,4 +75,18 @@ describe('useApplicationStatsQuery', () => {
|
|||||||
|
|
||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ describe('useCategoriesQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array for no categories', async () => {
|
||||||
mockedApiClient.fetchCategories.mockResolvedValue({
|
mockedApiClient.fetchCategories.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -83,6 +83,33 @@ describe('useFlyerItemsQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array when API returns no items', async () => {
|
||||||
mockedApiClient.fetchFlyerItems.mockResolvedValue({
|
mockedApiClient.fetchFlyerItems.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ describe('useFlyersQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array for no flyers', async () => {
|
||||||
mockedApiClient.fetchFlyers.mockResolvedValue({
|
mockedApiClient.fetchFlyers.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ describe('useMasterItemsQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array for no master items', async () => {
|
||||||
mockedApiClient.fetchMasterItems.mockResolvedValue({
|
mockedApiClient.fetchMasterItems.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ describe('useShoppingListsQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array for no shopping lists', async () => {
|
||||||
mockedApiClient.fetchShoppingLists.mockResolvedValue({
|
mockedApiClient.fetchShoppingLists.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ describe('useSuggestedCorrectionsQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array for no corrections', async () => {
|
||||||
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
|
mockedApiClient.getSuggestedCorrections.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ describe('useWatchedItemsQuery', () => {
|
|||||||
expect(result.current.error?.message).toBe('Request failed with status 500');
|
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 () => {
|
it('should return empty array for no watched items', async () => {
|
||||||
mockedApiClient.fetchWatchedItems.mockResolvedValue({
|
mockedApiClient.fetchWatchedItems.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
// src/hooks/useDataExtraction.test.ts
|
// src/hooks/useDataExtraction.test.ts
|
||||||
import { renderHook, act } from '@testing-library/react';
|
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 { useDataExtraction } from './useDataExtraction';
|
||||||
import type { Flyer } from '../types';
|
import type { Flyer } from '../types';
|
||||||
|
|
||||||
// Create a mock flyer for testing
|
// Create a mock flyer for testing
|
||||||
const createMockFlyer = (id: number, storeName: string = `Store ${id}`): Flyer => ({
|
const createMockFlyer = (id: number, storeName: string = `Store ${id}`): Flyer => ({
|
||||||
flyer_id: id,
|
flyer_id: id,
|
||||||
store: { store_id: id, name: storeName },
|
store: {
|
||||||
start_date: '2024-01-01',
|
store_id: id,
|
||||||
end_date: '2024-01-07',
|
name: storeName,
|
||||||
image_url: `https://example.com/flyer${id}.jpg`,
|
|
||||||
status: 'processed',
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
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', () => {
|
describe('useDataExtraction Hook', () => {
|
||||||
let mockOnFlyerUpdate: ReturnType<typeof vi.fn>;
|
let mockOnFlyerUpdate: Mock<(flyer: Flyer) => void>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockOnFlyerUpdate = vi.fn();
|
mockOnFlyerUpdate = vi.fn();
|
||||||
@@ -66,7 +73,7 @@ describe('useDataExtraction Hook', () => {
|
|||||||
|
|
||||||
expect(mockOnFlyerUpdate).toHaveBeenCalledTimes(1);
|
expect(mockOnFlyerUpdate).toHaveBeenCalledTimes(1);
|
||||||
const updatedFlyer = mockOnFlyerUpdate.mock.calls[0][0];
|
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
|
// Ensure other properties are preserved
|
||||||
expect(updatedFlyer.flyer_id).toBe(1);
|
expect(updatedFlyer.flyer_id).toBe(1);
|
||||||
expect(updatedFlyer.image_url).toBe('https://example.com/flyer1.jpg');
|
expect(updatedFlyer.image_url).toBe('https://example.com/flyer1.jpg');
|
||||||
@@ -86,7 +93,7 @@ describe('useDataExtraction Hook', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updatedFlyer = mockOnFlyerUpdate.mock.calls[0][0];
|
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', () => {
|
it('should update handler when onFlyerUpdate changes', () => {
|
||||||
const mockFlyer = createMockFlyer(1);
|
const mockFlyer = createMockFlyer(1);
|
||||||
const mockOnFlyerUpdate2 = vi.fn();
|
const mockOnFlyerUpdate2: Mock<(flyer: Flyer) => void> = vi.fn();
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ onFlyerUpdate }) =>
|
({ onFlyerUpdate }) =>
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ vi.mock('../services/logger.client', () => ({
|
|||||||
// Create mock flyers for testing
|
// Create mock flyers for testing
|
||||||
const createMockFlyer = (id: number, storeName: string = `Store ${id}`): Flyer => ({
|
const createMockFlyer = (id: number, storeName: string = `Store ${id}`): Flyer => ({
|
||||||
flyer_id: id,
|
flyer_id: id,
|
||||||
store: { store_id: id, name: storeName },
|
store: {
|
||||||
start_date: '2024-01-01',
|
store_id: id,
|
||||||
end_date: '2024-01-07',
|
name: storeName,
|
||||||
image_url: `https://example.com/flyer${id}.jpg`,
|
|
||||||
status: 'processed',
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
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[] = [
|
const mockFlyers: Flyer[] = [
|
||||||
|
|||||||
@@ -13,5 +13,10 @@ export default defineConfig({
|
|||||||
|
|
||||||
// This line is the key fix: it tells Vitest to include the type definitions
|
// This line is the key fix: it tells Vitest to include the type definitions
|
||||||
include: ['src/**/*.test.{ts,tsx}'],
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
exclude: [
|
||||||
|
'**/index.ts', // barrel exports don't need coverage
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user