test fixes and doc work
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m50s

This commit is contained in:
2026-01-28 15:33:48 -08:00
parent e548d1b0cc
commit 4f06698dfd
18 changed files with 3210 additions and 48 deletions

View File

@@ -27,6 +27,51 @@ podman exec -it flyer-crawler-dev npm run type-check
Out-of-sync = test failures. Out-of-sync = test failures.
### Server Access: READ-ONLY (Production/Test Servers)
**CRITICAL**: The `claude-win10` user has **READ-ONLY** access to production and test servers.
**Claude Code does NOT have:**
- Root or sudo access
- Write permissions on servers
- Ability to execute PM2 restart, systemctl, or other write operations directly
**Correct Workflow for Server Operations:**
| Step | Actor | Action |
| ---- | ------ | --------------------------------------------------------------------------- |
| 1 | Claude | Provide **diagnostic commands** (read-only checks) for user to run |
| 2 | User | Execute commands on server, report results |
| 3 | Claude | Analyze results, provide **fix commands** (1-3 at a time) |
| 4 | User | Execute fix commands, report results |
| 5 | Claude | Provide **verification commands** to confirm success and check side effects |
| 6 | Claude | Document progress stage by stage |
| 7 | Claude | Update ALL relevant documentation when complete |
**Example - Diagnosing PM2 Issues:**
```bash
# Step 1: Claude provides diagnostic commands (user runs these)
pm2 list
pm2 logs flyer-crawler-api --lines 50
systemctl status redis
# Step 3: After user reports results, Claude provides fix commands
pm2 restart flyer-crawler-api
# Wait for user confirmation before next command
# Step 5: Claude provides verification
pm2 list
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq .
```
**Never Do:**
- `ssh root@projectium.com "pm2 restart all"` (Claude cannot execute this)
- Assume commands succeeded without user confirmation
- Provide more than 3 fix commands at once (errors may cascade)
### Communication Style ### Communication Style
Ask before assuming. Never assume: Ask before assuming. Never assume:
@@ -294,8 +339,8 @@ podman cp "d:/path/file" container:/tmp/file
# Dev container # Dev container
MSYS_NO_PATHCONV=1 podman exec -e DATABASE_URL=postgresql://bugsink:bugsink_dev_password@postgres:5432/bugsink -e SECRET_KEY=dev-bugsink-secret-key-minimum-50-characters-for-security flyer-crawler-dev sh -c 'cd /opt/bugsink/conf && DJANGO_SETTINGS_MODULE=bugsink_conf PYTHONPATH=/opt/bugsink/conf:/opt/bugsink/lib/python3.10/site-packages /opt/bugsink/bin/python -m django create_auth_token' MSYS_NO_PATHCONV=1 podman exec -e DATABASE_URL=postgresql://bugsink:bugsink_dev_password@postgres:5432/bugsink -e SECRET_KEY=dev-bugsink-secret-key-minimum-50-characters-for-security flyer-crawler-dev sh -c 'cd /opt/bugsink/conf && DJANGO_SETTINGS_MODULE=bugsink_conf PYTHONPATH=/opt/bugsink/conf:/opt/bugsink/lib/python3.10/site-packages /opt/bugsink/bin/python -m django create_auth_token'
# Production (via SSH) # Production (user executes on server)
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token" cd /opt/bugsink && bugsink-manage create_auth_token
``` ```
### Logstash ### Logstash

View File

@@ -1,5 +1,21 @@
# DevOps Subagent Reference # DevOps Subagent Reference
## Critical Rule: Server Access is READ-ONLY
**Claude Code has READ-ONLY access to production/test servers.** The `claude-win10` user cannot execute write operations directly.
When working with production/test servers:
1. **Provide commands** for the user to execute (do not attempt SSH)
2. **Wait for user** to report command output
3. **Provide fix commands** 1-3 at a time (errors may cascade)
4. **Verify success** with read-only commands after user executes fixes
5. **Document findings** in relevant documentation
Commands in this reference are for the **user to run on the server**, not for Claude to execute.
---
## Critical Rule: Git Bash Path Conversion ## Critical Rule: Git Bash Path Conversion
Git Bash on Windows auto-converts Unix paths, breaking container commands. Git Bash on Windows auto-converts Unix paths, breaking container commands.
@@ -69,12 +85,11 @@ MSYS_NO_PATHCONV=1 podman exec -it flyer-crawler-dev psql -U postgres -d flyer_c
## PM2 Commands ## PM2 Commands
### Production Server (via SSH) ### Production Server
> **Note**: These commands are for the **user to execute on the server**. Claude Code provides commands but cannot run them directly. See [Server Access is READ-ONLY](#critical-rule-server-access-is-read-only) above.
```bash ```bash
# SSH to server
ssh root@projectium.com
# List all apps # List all apps
pm2 list pm2 list
@@ -210,9 +225,10 @@ INFO
### Production ### Production
> **Note**: User executes these commands on the server.
```bash ```bash
# Via SSH # Access Redis CLI
ssh root@projectium.com
redis-cli -a $REDIS_PASSWORD redis-cli -a $REDIS_PASSWORD
# Flush cache (use with caution) # Flush cache (use with caution)
@@ -278,10 +294,9 @@ Trigger `manual-db-backup.yml` from Gitea Actions UI.
### Manual Backup ### Manual Backup
```bash > **Note**: User executes these commands on the server.
# SSH to server
ssh root@projectium.com
```bash
# Backup # Backup
PGPASSWORD=$DB_PASSWORD pg_dump -h $DB_HOST -U $DB_USER $DB_NAME > backup_$(date +%Y%m%d).sql PGPASSWORD=$DB_PASSWORD pg_dump -h $DB_HOST -U $DB_USER $DB_NAME > backup_$(date +%Y%m%d).sql
@@ -301,8 +316,10 @@ MSYS_NO_PATHCONV=1 podman exec -e DATABASE_URL=postgresql://bugsink:bugsink_dev_
### Production Token Generation ### Production Token Generation
> **Note**: User executes this command on the server.
```bash ```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token" cd /opt/bugsink && bugsink-manage create_auth_token
``` ```
--- ---

View File

@@ -0,0 +1,367 @@
# ADR-057: Test Remediation Post-API Versioning and Frontend Rework
**Date**: 2026-01-28
**Status**: Accepted
**Context**: Major test remediation effort completed after ADR-008 API versioning implementation and frontend style rework
## Context
Following the completion of ADR-008 Phase 2 (API Versioning Strategy) and a concurrent frontend style/design rework, the test suite experienced 105 test failures across unit tests and E2E tests. This ADR documents the systematic remediation effort, root cause analysis, and lessons learned to prevent similar issues in future migrations.
### Scope of Failures
| Test Type | Failures | Total Tests | Pass Rate After Fix |
| ---------- | -------- | ----------- | ------------------- |
| Unit Tests | 69 | 3,392 | 100% |
| E2E Tests | 36 | 36 | 100% |
| **Total** | **105** | **3,428** | **100%** |
### Root Causes Identified
The failures were categorized into six distinct categories:
1. **API Versioning Path Mismatches** (71 failures)
- Test files using `/api/` instead of `/api/v1/`
- Environment variables not set for API base URL
- Integration and E2E tests calling unversioned endpoints
2. **Dark Mode Class Assertion Failures** (8 failures)
- Frontend rework changed Tailwind dark mode utility classes
- Test assertions checking for outdated class names
3. **Selected Item Styling Changes** (6 failures)
- Component styling refactored to new design tokens
- Test assertions expecting old CSS class combinations
4. **Admin-Only Component Visibility** (12 failures)
- MainLayout tests not properly mocking admin role
- ActivityLog component visibility tied to role-based access
5. **Mock Hoisting Issues** (5 failures)
- Queue mocks not available during module initialization
- Vitest's module hoisting order causing mock setup failures
6. **Error Log Path Hardcoding** (3 failures)
- Route handlers logging hardcoded paths like `/api/flyers`
- Test assertions expecting versioned paths `/api/v1/flyers`
## Decision
We implemented a systematic remediation approach addressing each failure category with targeted fixes while establishing patterns to prevent regression.
### 1. API Versioning Configuration Updates
**Files Modified**:
- `vite.config.ts`
- `vitest.config.e2e.ts`
- `vitest.config.integration.ts`
**Pattern Applied**: Centralize API base URL in Vitest environment variables
```typescript
// vite.config.ts - Unit test configuration
test: {
env: {
// ADR-008: Ensure API versioning is correctly set for unit tests
VITE_API_BASE_URL: '/api/v1',
},
// ...
}
// vitest.config.e2e.ts - E2E test configuration
test: {
env: {
// ADR-008: API versioning - all routes use /api/v1 prefix
VITE_API_BASE_URL: 'http://localhost:3098/api/v1',
},
// ...
}
// vitest.config.integration.ts - Integration test configuration
test: {
env: {
// ADR-008: API versioning - all routes use /api/v1 prefix
VITE_API_BASE_URL: 'http://localhost:3099/api/v1',
},
// ...
}
```
### 2. E2E Test URL Path Updates
**Files Modified** (7 files, 31 URL occurrences):
- `src/tests/e2e/budget-journey.e2e.test.ts`
- `src/tests/e2e/deals-journey.e2e.test.ts`
- `src/tests/e2e/flyer-upload.e2e.test.ts`
- `src/tests/e2e/inventory-journey.e2e.test.ts`
- `src/tests/e2e/receipt-journey.e2e.test.ts`
- `src/tests/e2e/upc-journey.e2e.test.ts`
- `src/tests/e2e/user-journey.e2e.test.ts`
**Pattern Applied**: Update all hardcoded API paths to versioned endpoints
```typescript
// Before
const response = await getRequest().post('/api/auth/register').send({...});
// After
const response = await getRequest().post('/api/v1/auth/register').send({...});
```
### 3. Unit Test Assertion Updates for UI Changes
**Files Modified**:
- `src/features/flyer/FlyerDisplay.test.tsx`
- `src/features/flyer/FlyerList.test.tsx`
**Pattern Applied**: Update CSS class assertions to match new design system
```typescript
// FlyerDisplay.test.tsx - Dark mode class update
// Before
expect(image).toHaveClass('dark:brightness-75');
// After
expect(image).toHaveClass('dark:brightness-90');
// FlyerList.test.tsx - Selected item styling update
// Before
expect(selectedItem).toHaveClass('ring-2', 'ring-brand-primary');
// After
expect(selectedItem).toHaveClass('border-brand-primary', 'bg-teal-50/50', 'dark:bg-teal-900/10');
```
### 4. Admin-Only Component Test Separation
**File Modified**: `src/layouts/MainLayout.test.tsx`
**Pattern Applied**: Separate test cases for admin vs. regular user visibility
```typescript
describe('for authenticated users', () => {
beforeEach(() => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser }),
});
});
it('renders auth-gated components for regular users (PriceHistoryChart, Leaderboard)', () => {
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('price-history-chart')).toBeInTheDocument();
expect(screen.getByTestId('leaderboard')).toBeInTheDocument();
// ActivityLog is admin-only, should NOT be present for regular users
expect(screen.queryByTestId('activity-log')).not.toBeInTheDocument();
});
it('renders ActivityLog for admin users', () => {
mockedUseAuth.mockReturnValue({
...defaultUseAuthReturn,
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ user: mockUser, role: 'admin' }),
});
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('activity-log')).toBeInTheDocument();
});
});
```
### 5. vi.hoisted() Pattern for Queue Mocks
**File Modified**: `src/routes/health.routes.test.ts`
**Pattern Applied**: Use `vi.hoisted()` to ensure mocks are available during module hoisting
```typescript
// Use vi.hoisted to create mock queue objects that are available during vi.mock hoisting.
// This ensures the mock objects exist when the factory function runs.
const { mockQueuesModule } = vi.hoisted(() => {
// Helper function to create a mock queue object with vi.fn()
const createMockQueue = () => ({
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
});
return {
mockQueuesModule: {
flyerQueue: createMockQueue(),
emailQueue: createMockQueue(),
// ... additional queues
},
};
});
// Mock the queues.server module BEFORE the health router imports it.
vi.mock('../services/queues.server', () => mockQueuesModule);
// Import the router AFTER all mocks are defined.
import healthRouter from './health.routes';
```
### 6. Dynamic Error Log Paths
**Pattern Applied**: Use `req.originalUrl` instead of hardcoded paths in error handlers
```typescript
// Before (INCORRECT - hardcoded path)
req.log.error({ error }, 'Error in /api/flyers/:id:');
// After (CORRECT - dynamic path)
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
```
## Implementation Summary
### Files Modified (14 total)
| Category | Files | Changes |
| -------------------- | ----- | ------------------------------------------------- |
| Vitest Configuration | 3 | Added `VITE_API_BASE_URL` environment variables |
| E2E Tests | 7 | Updated 31 API endpoint URLs |
| Unit Tests | 4 | Updated assertions for UI, mocks, and admin roles |
### Verification Results
After remediation, all tests pass in the dev container environment:
```text
Unit Tests: 3,392 passing
E2E Tests: 36 passing
Integration: 345/348 passing (3 known issues, unrelated)
Type Check: Passing
```
## Consequences
### Positive
1. **Test Suite Stability**: All tests now pass consistently in the dev container
2. **API Versioning Compliance**: Tests enforce the `/api/v1/` path requirement
3. **Pattern Documentation**: Clear patterns established for future test maintenance
4. **Separation of Concerns**: Admin vs. user test cases properly separated
5. **Mock Reliability**: `vi.hoisted()` pattern prevents mock timing issues
### Negative
1. **Maintenance Overhead**: Future API version changes will require test updates
2. **Manual Migration**: No automated tool to update test paths during versioning
### Neutral
1. **Test Execution Time**: No significant impact on test execution duration
2. **Coverage Metrics**: Coverage percentages unchanged
## Best Practices Established
### 1. API Versioning in Tests
**Always use versioned API paths in tests**:
```typescript
// Good
const response = await request.get('/api/v1/users/profile');
// Bad
const response = await request.get('/api/users/profile');
```
**Configure environment variables centrally in Vitest configs** rather than in individual test files.
### 2. vi.hoisted() for Module-Level Mocks
When mocking modules that are imported at the top level of other modules:
```typescript
// Pattern: Define mocks with vi.hoisted() BEFORE vi.mock() calls
const { mockModule } = vi.hoisted(() => ({
mockModule: {
someFunction: vi.fn(),
},
}));
vi.mock('./some-module', () => mockModule);
// Import AFTER mocks
import { something } from './module-that-imports-some-module';
```
### 3. Testing Conditional Component Rendering
When testing components that render differently based on user role:
1. Create separate `describe` blocks for each role
2. Set up role-specific mocks in `beforeEach`
3. Explicitly test both presence AND absence of role-gated components
### 4. CSS Class Assertions After UI Refactors
After frontend style changes:
1. Review component implementation for new class names
2. Update test assertions to match actual CSS classes
3. Consider using partial matching for complex class combinations:
```typescript
// Flexible matching for Tailwind classes
expect(element).toHaveClass('border-brand-primary');
// vs exact matching
expect(element).toHaveClass('border-brand-primary', 'bg-teal-50/50', 'dark:bg-teal-900/10');
```
### 5. Error Logging Paths
**Always use dynamic paths in error logs**:
```typescript
// Pattern: Use req.originalUrl for request path logging
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
```
This ensures error logs reflect the actual request URL including version prefixes.
## Migration Checklist for Future API Version Changes
When implementing a new API version (e.g., v2), follow this checklist:
- [ ] Update `vite.config.ts` test environment `VITE_API_BASE_URL`
- [ ] Update `vitest.config.e2e.ts` test environment `VITE_API_BASE_URL`
- [ ] Update `vitest.config.integration.ts` test environment `VITE_API_BASE_URL`
- [ ] Search and replace `/api/v1/` with `/api/v2/` in E2E test files
- [ ] Search and replace `/api/v1/` with `/api/v2/` in integration test files
- [ ] Verify route handler error logs use `req.originalUrl`
- [ ] Run full test suite in dev container to verify
**Search command for finding hardcoded paths**:
```bash
grep -r "/api/v1/" src/tests/
grep -r "'/api/" src/routes/*.ts
```
## Related ADRs
- [ADR-008](./0008-api-versioning-strategy.md) - API Versioning Strategy
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy and Standards
- [ADR-014](./0014-containerization-and-deployment-strategy.md) - Platform: Linux Only
- [ADR-040](./0040-testing-economics-and-priorities.md) - Testing Economics and Priorities
- [ADR-012](./0012-frontend-component-library-and-design-system.md) - Frontend Component Library
## Key Files
| File | Purpose |
| ------------------------------ | -------------------------------------------- |
| `vite.config.ts` | Unit test environment configuration |
| `vitest.config.e2e.ts` | E2E test environment configuration |
| `vitest.config.integration.ts` | Integration test environment configuration |
| `src/tests/e2e/*.e2e.test.ts` | E2E test files with versioned API paths |
| `src/routes/*.routes.test.ts` | Route test files with `vi.hoisted()` pattern |
| `docs/development/TESTING.md` | Testing guide with best practices |

View File

@@ -71,6 +71,7 @@ This directory contains a log of the architectural decisions made for the Flyer
**[ADR-040](./0040-testing-economics-and-priorities.md)**: Testing Economics and Priorities (Accepted) **[ADR-040](./0040-testing-economics-and-priorities.md)**: Testing Economics and Priorities (Accepted)
**[ADR-045](./0045-test-data-factories-and-fixtures.md)**: Test Data Factories and Fixtures (Accepted) **[ADR-045](./0045-test-data-factories-and-fixtures.md)**: Test Data Factories and Fixtures (Accepted)
**[ADR-047](./0047-project-file-and-folder-organization.md)**: Project File and Folder Organization (Proposed) **[ADR-047](./0047-project-file-and-folder-organization.md)**: Project File and Folder Organization (Proposed)
**[ADR-057](./0057-test-remediation-post-api-versioning.md)**: Test Remediation Post-API Versioning (Accepted)
## 9. Architecture Patterns ## 9. Architecture Patterns

View File

@@ -147,6 +147,7 @@ When creating new route handlers:
## Related Documentation ## Related Documentation
- [ADR-008: API Versioning Strategy](../adr/0008-api-versioning-strategy.md) - Versioning implementation details - [ADR-008: API Versioning Strategy](../adr/0008-api-versioning-strategy.md) - Versioning implementation details
- [ADR-057: Test Remediation Post-API Versioning](../adr/0057-test-remediation-post-api-versioning.md) - Comprehensive remediation guide
- [ADR-004: Structured Logging](../adr/0004-standardized-application-wide-structured-logging.md) - Logging standards - [ADR-004: Structured Logging](../adr/0004-standardized-application-wide-structured-logging.md) - Logging standards
- [CODE-PATTERNS.md](CODE-PATTERNS.md) - General code patterns - [CODE-PATTERNS.md](CODE-PATTERNS.md) - General code patterns
- [TESTING.md](TESTING.md) - Testing guidelines - [TESTING.md](TESTING.md) - Testing guidelines

View File

@@ -262,6 +262,8 @@ Opens a browser-based test runner with filtering and debugging capabilities.
6. **Use unique filenames** - file upload tests need timestamp-based filenames 6. **Use unique filenames** - file upload tests need timestamp-based filenames
7. **Check exit codes** - `npm run type-check` returns 0 on success, non-zero on error 7. **Check exit codes** - `npm run type-check` returns 0 on success, non-zero on error
8. **Use `req.originalUrl` in error logs** - never hardcode API paths in error messages 8. **Use `req.originalUrl` in error logs** - never hardcode API paths in error messages
9. **Use versioned API paths** - always use `/api/v1/` prefix in test requests
10. **Use `vi.hoisted()` for module mocks** - ensure mocks are available during module initialization
## Testing Error Log Messages ## Testing Error Log Messages
@@ -314,3 +316,159 @@ expect(logSpy).toHaveBeenCalledWith(
``` ```
See [Error Logging Path Patterns](ERROR-LOGGING-PATHS.md) for complete documentation. See [Error Logging Path Patterns](ERROR-LOGGING-PATHS.md) for complete documentation.
## API Versioning in Tests (ADR-008, ADR-057)
All API endpoints use the `/api/v1/` prefix. Tests must use versioned paths.
### Configuration
API base URLs are configured centrally in Vitest config files:
| Config File | Environment Variable | Value |
| ------------------------------ | -------------------- | ------------------------------ |
| `vite.config.ts` | `VITE_API_BASE_URL` | `/api/v1` |
| `vitest.config.e2e.ts` | `VITE_API_BASE_URL` | `http://localhost:3098/api/v1` |
| `vitest.config.integration.ts` | `VITE_API_BASE_URL` | `http://localhost:3099/api/v1` |
### Writing API Tests
```typescript
// Good - versioned path
const response = await request.post('/api/v1/auth/login').send({...});
// Bad - unversioned path (will fail)
const response = await request.post('/api/auth/login').send({...});
```
### Migration Checklist
When API version changes (e.g., v1 to v2):
1. Update all Vitest config `VITE_API_BASE_URL` values
2. Search and replace API paths in E2E tests: `grep -r "/api/v1/" src/tests/e2e/`
3. Search and replace API paths in integration tests
4. Verify route handler error logs use `req.originalUrl`
5. Run full test suite in dev container
See [ADR-057](../adr/0057-test-remediation-post-api-versioning.md) for complete migration guidance.
## vi.hoisted() Pattern for Module Mocks
When mocking modules that are imported at module initialization time (like queues or database connections), use `vi.hoisted()` to ensure mocks are available during hoisting.
### Problem: Mock Not Available During Import
```typescript
// BAD: Mock might not be ready when module imports it
vi.mock('../services/queues.server', () => ({
flyerQueue: { getJobCounts: vi.fn() }, // May not exist yet
}));
import healthRouter from './health.routes'; // Imports queues.server
```
### Solution: Use vi.hoisted()
```typescript
// GOOD: Mocks are created during hoisting, before vi.mock runs
const { mockQueuesModule } = vi.hoisted(() => {
const createMockQueue = () => ({
getJobCounts: vi.fn().mockResolvedValue({
waiting: 0,
active: 0,
failed: 0,
delayed: 0,
}),
});
return {
mockQueuesModule: {
flyerQueue: createMockQueue(),
emailQueue: createMockQueue(),
// ... additional queues
},
};
});
// Now the mock object exists when vi.mock factory runs
vi.mock('../services/queues.server', () => mockQueuesModule);
// Safe to import after mocks are defined
import healthRouter from './health.routes';
```
See [ADR-057](../adr/0057-test-remediation-post-api-versioning.md) for additional patterns.
## Testing Role-Based Component Visibility
When testing components that render differently based on user roles:
### Pattern: Separate Test Cases by Role
```typescript
describe('for authenticated users', () => {
beforeEach(() => {
mockedUseAuth.mockReturnValue({
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ role: 'user' }),
});
});
it('renders user-accessible components', () => {
render(<MyComponent />);
expect(screen.getByTestId('user-component')).toBeInTheDocument();
// Admin-only should NOT be present
expect(screen.queryByTestId('admin-only')).not.toBeInTheDocument();
});
});
describe('for admin users', () => {
beforeEach(() => {
mockedUseAuth.mockReturnValue({
authStatus: 'AUTHENTICATED',
userProfile: createMockUserProfile({ role: 'admin' }),
});
});
it('renders admin-only components', () => {
render(<MyComponent />);
expect(screen.getByTestId('admin-only')).toBeInTheDocument();
});
});
```
### Key Points
1. Create separate `describe` blocks for each role
2. Set up role-specific mocks in `beforeEach`
3. Test both presence AND absence of role-gated components
4. Use `screen.queryByTestId()` for elements that should NOT exist
## CSS Class Assertions After UI Refactors
After frontend style changes, update test assertions to match new CSS classes.
### Handling Tailwind Class Changes
```typescript
// Before refactor
expect(selectedItem).toHaveClass('ring-2', 'ring-brand-primary');
// After refactor - update to new classes
expect(selectedItem).toHaveClass('border-brand-primary', 'bg-teal-50/50');
```
### Flexible Matching
For complex class combinations, consider partial matching:
```typescript
// Check for key classes, ignore utility classes
expect(element).toHaveClass('border-brand-primary');
// Or use regex for patterns
expect(element.className).toMatch(/dark:bg-teal-\d+/);
```
See [ADR-057](../adr/0057-test-remediation-post-api-versioning.md) for lessons learned from the test remediation effort.

View File

@@ -6,6 +6,20 @@ This guide covers the manual installation of Flyer Crawler and its dependencies
--- ---
## Server Access Model
All commands in this guide are intended for the **system administrator** to execute directly on the server. Claude Code and AI tools have **READ-ONLY** access to production servers and cannot execute these commands directly.
When Claude assists with server setup or troubleshooting:
1. Claude provides commands for the administrator to execute
2. Administrator runs commands and reports output
3. Claude analyzes results and provides next steps (1-3 commands at a time)
4. Administrator executes and reports results
5. Claude provides verification commands to confirm success
---
## Table of Contents ## Table of Contents
1. [System Prerequisites](#system-prerequisites) 1. [System Prerequisites](#system-prerequisites)

View File

@@ -2,6 +2,26 @@
This guide covers deploying Flyer Crawler to a production server. This guide covers deploying Flyer Crawler to a production server.
## Server Access Model
**Important**: Claude Code (and AI tools) have **READ-ONLY** access to production/test servers. The deployment workflow is:
| Actor | Capability |
| ------------ | --------------------------------------------------------------- |
| Gitea CI/CD | Automated deployments via workflows (has write access) |
| User (human) | Manual server access for troubleshooting and emergency fixes |
| Claude Code | Provides commands for user to execute; cannot run them directly |
When troubleshooting deployment issues:
1. Claude provides **diagnostic commands** for the user to run
2. User executes commands and reports output
3. Claude analyzes results and provides **fix commands** (1-3 at a time)
4. User executes fixes and reports results
5. Claude provides **verification commands** to confirm success
---
## Prerequisites ## Prerequisites
- Ubuntu server (22.04 LTS recommended) - Ubuntu server (22.04 LTS recommended)

View File

@@ -276,10 +276,10 @@ Dev Container (in `.mcp.json`):
Bugsink 2.0.11 does not have a UI for API tokens. Create via Django management command. Bugsink 2.0.11 does not have a UI for API tokens. Create via Django management command.
**Production**: **Production** (user executes on server):
```bash ```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token" cd /opt/bugsink && bugsink-manage create_auth_token
``` ```
**Dev Container**: **Dev Container**:
@@ -388,11 +388,9 @@ Log Sources Logstash Outputs
### Pipeline Status ### Pipeline Status
**Check Logstash Service**: **Check Logstash Service** (user executes on server):
```bash ```bash
ssh root@projectium.com
# Service status # Service status
systemctl status logstash systemctl status logstash
@@ -485,9 +483,11 @@ PM2 manages the Node.js application processes in production.
### Basic Commands ### Basic Commands
> **Note**: These commands are for the user to execute on the server. Claude Code provides commands but cannot run them directly.
```bash ```bash
ssh root@projectium.com # Switch to gitea-runner user (PM2 runs under this user)
su - gitea-runner # PM2 runs under this user su - gitea-runner
# List all processes # List all processes
pm2 list pm2 list
@@ -835,27 +835,26 @@ Configure alerts in your monitoring tool (UptimeRobot, Datadog, etc.):
### Quick Diagnostic Commands ### Quick Diagnostic Commands
> **Note**: User executes these commands on the server. Claude Code provides commands but cannot run them directly.
```bash ```bash
# Full system health check # Service status checks
ssh root@projectium.com << 'EOF'
echo "=== Service Status ==="
systemctl status pm2-gitea-runner --no-pager systemctl status pm2-gitea-runner --no-pager
systemctl status logstash --no-pager systemctl status logstash --no-pager
systemctl status redis --no-pager systemctl status redis --no-pager
systemctl status postgresql --no-pager systemctl status postgresql --no-pager
echo "=== PM2 Processes ===" # PM2 processes (run as gitea-runner)
su - gitea-runner -c "pm2 list" su - gitea-runner -c "pm2 list"
echo "=== Disk Space ===" # Disk space
df -h / /var df -h / /var
echo "=== Memory ===" # Memory
free -h free -h
echo "=== Recent Errors ===" # Recent errors
journalctl -p err -n 20 --no-pager journalctl -p err -n 20 --no-pager
EOF
``` ```
### Runbook Quick Reference ### Runbook Quick Reference

View File

@@ -6,6 +6,79 @@ This guide covers DevOps-related subagents for deployment, infrastructure, and o
- **infra-architect**: Resource optimization, capacity planning - **infra-architect**: Resource optimization, capacity planning
- **bg-worker**: Background jobs, PM2 workers, BullMQ queues - **bg-worker**: Background jobs, PM2 workers, BullMQ queues
---
## CRITICAL: Server Access Model
**Claude Code has READ-ONLY access to production/test servers.**
The `claude-win10` user cannot execute write operations (PM2 restart, systemctl, file modifications) directly on servers. The devops subagent must **provide commands for the user to execute**, not attempt to run them via SSH.
### Command Delegation Workflow
When troubleshooting or making changes to production/test servers:
| Phase | Actor | Action |
| -------- | ------ | ----------------------------------------------------------- |
| Diagnose | Claude | Provide read-only diagnostic commands |
| Report | User | Execute commands, share output with Claude |
| Analyze | Claude | Interpret results, identify root cause |
| Fix | Claude | Provide 1-3 fix commands (never more, errors may cascade) |
| Execute | User | Run fix commands, report results |
| Verify | Claude | Provide verification commands to confirm success |
| Document | Claude | Update relevant documentation with findings and resolutions |
### Example: PM2 Process Issue
Step 1 - Diagnostic Commands (Claude provides, user runs):
```bash
# Check PM2 process status
pm2 list
# View recent error logs
pm2 logs flyer-crawler-api --err --lines 50
# Check system resources
free -h
df -h /var/www
```
Step 2 - User reports output to Claude
Step 3 - Fix Commands (Claude provides 1-3 at a time):
```bash
# Restart the failing process
pm2 restart flyer-crawler-api
```
Step 4 - User executes and reports result
Step 5 - Verification Commands:
```bash
# Confirm process is running
pm2 list
# Test API health
curl -s https://flyer-crawler.projectium.com/api/health/ready | jq .
```
### What NOT to Do
```bash
# WRONG - Claude cannot execute this directly
ssh root@projectium.com "pm2 restart all"
# WRONG - Providing too many commands at once
pm2 stop all && rm -rf node_modules && npm install && pm2 start all
# WRONG - Assuming commands succeeded without user confirmation
```
---
## The devops Subagent ## The devops Subagent
### When to Use ### When to Use
@@ -372,6 +445,8 @@ redis-cli -a $REDIS_PASSWORD
## Service Management Commands ## Service Management Commands
> **Note**: These commands are for the **user to execute on the server**. Claude Code provides these commands but cannot run them directly due to read-only server access. See [Server Access Model](#critical-server-access-model) above.
### PM2 Commands ### PM2 Commands
```bash ```bash

View File

@@ -109,10 +109,10 @@ MSYS_NO_PATHCONV=1 podman exec -e DATABASE_URL=postgresql://bugsink:bugsink_dev_
### Production Token ### Production Token
SSH into the production server: User executes this command on the production server:
```bash ```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage create_auth_token" cd /opt/bugsink && bugsink-manage create_auth_token
``` ```
**Output:** Same format - 40-character hex token. **Output:** Same format - 40-character hex token.
@@ -795,10 +795,10 @@ podman exec flyer-crawler-dev pg_isready -U bugsink -d bugsink -h postgres
podman exec flyer-crawler-dev psql -U postgres -h postgres -c "\l" | grep bugsink podman exec flyer-crawler-dev psql -U postgres -h postgres -c "\l" | grep bugsink
``` ```
**Production:** **Production** (user executes on server):
```bash ```bash
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage check" cd /opt/bugsink && bugsink-manage check
``` ```
### PostgreSQL Sequence Out of Sync (Duplicate Key Errors) ### PostgreSQL Sequence Out of Sync (Duplicate Key Errors)
@@ -834,10 +834,9 @@ SELECT
END as status; END as status;
" "
# Production # Production (user executes on server)
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage dbshell" <<< " cd /opt/bugsink && bugsink-manage dbshell
SELECT MAX(id) as max_id, (SELECT last_value FROM projects_project_id_seq) as seq_value FROM projects_project; # Then run: SELECT MAX(id) as max_id, (SELECT last_value FROM projects_project_id_seq) as seq_value FROM projects_project;
"
``` ```
**Solution:** **Solution:**
@@ -850,10 +849,9 @@ podman exec flyer-crawler-dev psql -U bugsink -h postgres -d bugsink -c "
SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true); SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
" "
# Production # Production (user executes on server)
ssh root@projectium.com "cd /opt/bugsink && bugsink-manage dbshell" <<< " cd /opt/bugsink && bugsink-manage dbshell
SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true); # Then run: SELECT setval('projects_project_id_seq', COALESCE((SELECT MAX(id) FROM projects_project), 1), true);
"
``` ```
**Verification:** **Verification:**

View File

@@ -0,0 +1,424 @@
// src/components/NotificationBell.test.tsx
import React from 'react';
import { screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { NotificationBell, ConnectionStatus } from './NotificationBell';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
// Mock the useWebSocket hook
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
// Mock the useEventBus hook
vi.mock('../hooks/useEventBus', () => ({
useEventBus: vi.fn(),
}));
// Import the mocked modules
import { useWebSocket } from '../hooks/useWebSocket';
import { useEventBus } from '../hooks/useEventBus';
// Type the mocked functions
const mockUseWebSocket = useWebSocket as Mock;
const mockUseEventBus = useEventBus as Mock;
describe('NotificationBell', () => {
let eventBusCallback: ((data?: unknown) => void) | null = null;
beforeEach(() => {
vi.clearAllMocks();
eventBusCallback = null;
// Default mock: connected state, no error
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
// Capture the callback passed to useEventBus
mockUseEventBus.mockImplementation((_event: string, callback: (data?: unknown) => void) => {
eventBusCallback = callback;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should render the notification bell button', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button', { name: /notifications/i });
expect(button).toBeInTheDocument();
});
it('should render with custom className', () => {
renderWithProviders(<NotificationBell className="custom-class" />);
const container = screen.getByRole('button').parentElement;
expect(container).toHaveClass('custom-class');
});
it('should show connection status indicator by default', () => {
const { container } = renderWithProviders(<NotificationBell />);
// The status indicator is a span with inline style containing backgroundColor
const statusIndicator = container.querySelector('span[title="Connected"]');
expect(statusIndicator).toBeInTheDocument();
});
it('should hide connection status indicator when showConnectionStatus is false', () => {
const { container } = renderWithProviders(<NotificationBell showConnectionStatus={false} />);
// No status indicator should be present (no span with title Connected/Connecting/Disconnected)
const connectedIndicator = container.querySelector('span[title="Connected"]');
const connectingIndicator = container.querySelector('span[title="Connecting"]');
const disconnectedIndicator = container.querySelector('span[title="Disconnected"]');
expect(connectedIndicator).not.toBeInTheDocument();
expect(connectingIndicator).not.toBeInTheDocument();
expect(disconnectedIndicator).not.toBeInTheDocument();
});
});
describe('unread count badge', () => {
it('should not show badge when unread count is zero', () => {
renderWithProviders(<NotificationBell />);
// The badge displays numbers, check that no number badge exists
const badge = screen.queryByText(/^\d+$/);
expect(badge).not.toBeInTheDocument();
});
it('should show badge with count when notifications arrive', () => {
renderWithProviders(<NotificationBell />);
// Simulate a notification arriving via event bus
expect(eventBusCallback).not.toBeNull();
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
});
const badge = screen.getByText('1');
expect(badge).toBeInTheDocument();
});
it('should increment count when multiple notifications arrive', () => {
renderWithProviders(<NotificationBell />);
// Simulate multiple notifications
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test 1' }] });
eventBusCallback!({ deals: [{ item_name: 'Test 2' }] });
eventBusCallback!({ deals: [{ item_name: 'Test 3' }] });
});
const badge = screen.getByText('3');
expect(badge).toBeInTheDocument();
});
it('should display 99+ when count exceeds 99', () => {
renderWithProviders(<NotificationBell />);
// Simulate 100 notifications
act(() => {
for (let i = 0; i < 100; i++) {
eventBusCallback!({ deals: [{ item_name: `Test ${i}` }] });
}
});
const badge = screen.getByText('99+');
expect(badge).toBeInTheDocument();
});
it('should not increment count when notification data is undefined', () => {
renderWithProviders(<NotificationBell />);
// Simulate a notification with undefined data
act(() => {
eventBusCallback!(undefined);
});
const badge = screen.queryByText(/^\d+$/);
expect(badge).not.toBeInTheDocument();
});
});
describe('click behavior', () => {
it('should reset unread count when clicked', () => {
renderWithProviders(<NotificationBell />);
// First, add some notifications
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
});
expect(screen.getByText('1')).toBeInTheDocument();
// Click the bell
const button = screen.getByRole('button');
fireEvent.click(button);
// Badge should no longer show
expect(screen.queryByText('1')).not.toBeInTheDocument();
});
it('should call onClick callback when provided', () => {
const mockOnClick = vi.fn();
renderWithProviders(<NotificationBell onClick={mockOnClick} />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('should handle click without onClick callback', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
// Should not throw
expect(() => fireEvent.click(button)).not.toThrow();
});
});
describe('connection status', () => {
it('should show green indicator when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
const { container } = renderWithProviders(<NotificationBell />);
const statusIndicator = container.querySelector('span[title="Connected"]');
expect(statusIndicator).toBeInTheDocument();
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(16, 185, 129)' });
});
it('should show red indicator when error occurs', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
const { container } = renderWithProviders(<NotificationBell />);
const statusIndicator = container.querySelector('span[title="Disconnected"]');
expect(statusIndicator).toBeInTheDocument();
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(239, 68, 68)' });
});
it('should show amber indicator when connecting', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: null,
});
const { container } = renderWithProviders(<NotificationBell />);
const statusIndicator = container.querySelector('span[title="Connecting"]');
expect(statusIndicator).toBeInTheDocument();
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(245, 158, 11)' });
});
it('should show error tooltip when disconnected with error', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
renderWithProviders(<NotificationBell />);
expect(screen.getByText('Live notifications unavailable')).toBeInTheDocument();
});
it('should not show error tooltip when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<NotificationBell />);
expect(screen.queryByText('Live notifications unavailable')).not.toBeInTheDocument();
});
});
describe('aria attributes', () => {
it('should have correct aria-label without unread notifications', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Notifications');
});
it('should have correct aria-label with unread notifications', () => {
renderWithProviders(<NotificationBell />);
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
eventBusCallback!({ deals: [{ item_name: 'Test2' }] });
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Notifications (2 unread)');
});
it('should have correct title when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', 'Connected to live notifications');
});
it('should have correct title when connecting', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: null,
});
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', 'Connecting...');
});
it('should have correct title when error occurs', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Network error',
});
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', 'WebSocket error: Network error');
});
});
describe('bell icon styling', () => {
it('should have default color when no unread notifications', () => {
renderWithProviders(<NotificationBell />);
const button = screen.getByRole('button');
const svg = button.querySelector('svg');
expect(svg).toHaveClass('text-gray-600');
});
it('should have highlighted color when there are unread notifications', () => {
renderWithProviders(<NotificationBell />);
act(() => {
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
});
const button = screen.getByRole('button');
const svg = button.querySelector('svg');
expect(svg).toHaveClass('text-blue-600');
});
});
describe('event bus subscription', () => {
it('should subscribe to notification:deal event', () => {
renderWithProviders(<NotificationBell />);
expect(mockUseEventBus).toHaveBeenCalledWith('notification:deal', expect.any(Function));
});
});
describe('useWebSocket configuration', () => {
it('should call useWebSocket with autoConnect: true', () => {
renderWithProviders(<NotificationBell />);
expect(mockUseWebSocket).toHaveBeenCalledWith({ autoConnect: true });
});
});
});
describe('ConnectionStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should show "Live" text when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<ConnectionStatus />);
expect(screen.getByText('Live')).toBeInTheDocument();
});
it('should show "Offline" text when disconnected with error', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
renderWithProviders(<ConnectionStatus />);
expect(screen.getByText('Offline')).toBeInTheDocument();
});
it('should show "Connecting..." text when connecting', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: null,
});
renderWithProviders(<ConnectionStatus />);
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
it('should call useWebSocket with autoConnect: true', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<ConnectionStatus />);
expect(mockUseWebSocket).toHaveBeenCalledWith({ autoConnect: true });
});
it('should render Wifi icon when connected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: true,
error: null,
});
renderWithProviders(<ConnectionStatus />);
const container = screen.getByText('Live').parentElement;
const svg = container?.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('text-green-600');
});
it('should render WifiOff icon when disconnected', () => {
mockUseWebSocket.mockReturnValue({
isConnected: false,
error: 'Connection failed',
});
renderWithProviders(<ConnectionStatus />);
const container = screen.getByText('Offline').parentElement;
const svg = container?.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('text-red-600');
});
});

View File

@@ -0,0 +1,776 @@
// src/components/NotificationToastHandler.test.tsx
import React from 'react';
import { render, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { NotificationToastHandler } from './NotificationToastHandler';
import type { DealNotificationData, SystemMessageData } from '../types/websocket';
// Use vi.hoisted to properly hoist mock functions
const { mockToastSuccess, mockToastError, mockToastDefault } = vi.hoisted(() => ({
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
mockToastDefault: vi.fn(),
}));
// Mock react-hot-toast
vi.mock('react-hot-toast', () => {
const toastFn = (message: string, options?: unknown) => mockToastDefault(message, options);
toastFn.success = mockToastSuccess;
toastFn.error = mockToastError;
return {
default: toastFn,
};
});
// Mock useWebSocket hook
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
// Mock useEventBus hook
vi.mock('../hooks/useEventBus', () => ({
useEventBus: vi.fn(),
}));
// Mock formatCurrency
vi.mock('../utils/formatUtils', () => ({
formatCurrency: vi.fn((cents: number) => `$${(cents / 100).toFixed(2)}`),
}));
// Import mocked modules
import { useWebSocket } from '../hooks/useWebSocket';
import { useEventBus } from '../hooks/useEventBus';
const mockUseWebSocket = useWebSocket as Mock;
const mockUseEventBus = useEventBus as Mock;
describe('NotificationToastHandler', () => {
let eventBusCallbacks: Map<string, (data?: unknown) => void>;
let onConnectCallback: (() => void) | undefined;
let onDisconnectCallback: (() => void) | undefined;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Clear toast mocks
mockToastSuccess.mockClear();
mockToastError.mockClear();
mockToastDefault.mockClear();
eventBusCallbacks = new Map();
onConnectCallback = undefined;
onDisconnectCallback = undefined;
// Default mock implementation for useWebSocket
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: true,
error: null,
};
},
);
// Capture callbacks for different event types
mockUseEventBus.mockImplementation((event: string, callback: (data?: unknown) => void) => {
eventBusCallbacks.set(event, callback);
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('rendering', () => {
it('should render null (no visible output)', () => {
const { container } = render(<NotificationToastHandler />);
expect(container.firstChild).toBeNull();
});
it('should subscribe to event bus on mount', () => {
render(<NotificationToastHandler />);
expect(mockUseEventBus).toHaveBeenCalledWith('notification:deal', expect.any(Function));
expect(mockUseEventBus).toHaveBeenCalledWith('notification:system', expect.any(Function));
expect(mockUseEventBus).toHaveBeenCalledWith('notification:error', expect.any(Function));
});
});
describe('connection events', () => {
it('should show success toast on connect when enabled', () => {
render(<NotificationToastHandler enabled={true} />);
// Trigger onConnect callback
onConnectCallback?.();
expect(mockToastSuccess).toHaveBeenCalledWith(
'Connected to live notifications',
expect.objectContaining({
duration: 2000,
icon: expect.any(String),
}),
);
});
it('should not show success toast on connect when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
onConnectCallback?.();
expect(mockToastSuccess).not.toHaveBeenCalled();
});
it('should show error toast on disconnect when error exists', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection lost',
};
},
);
render(<NotificationToastHandler enabled={true} />);
onDisconnectCallback?.();
expect(mockToastError).toHaveBeenCalledWith(
'Disconnected from live notifications',
expect.objectContaining({
duration: 3000,
icon: expect.any(String),
}),
);
});
it('should not show disconnect toast when disabled', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection lost',
};
},
);
render(<NotificationToastHandler enabled={false} />);
onDisconnectCallback?.();
expect(mockToastError).not.toHaveBeenCalled();
});
it('should not show disconnect toast when no error', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: null,
};
},
);
render(<NotificationToastHandler enabled={true} />);
onDisconnectCallback?.();
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('deal notifications', () => {
it('should show toast for single deal notification', () => {
render(<NotificationToastHandler />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal found',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(mockToastSuccess).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
duration: 5000,
icon: expect.any(String),
position: 'top-right',
}),
);
});
it('should show toast for multiple deals notification', () => {
render(<NotificationToastHandler />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Store A',
store_id: 1,
},
{
item_name: 'Bread',
best_price_in_cents: 299,
store_name: 'Store B',
store_id: 2,
},
{
item_name: 'Eggs',
best_price_in_cents: 499,
store_name: 'Store C',
store_id: 3,
},
],
user_id: 'user-123',
message: 'Multiple deals found',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(mockToastSuccess).toHaveBeenCalled();
});
it('should not show toast when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal found',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(mockToastSuccess).not.toHaveBeenCalled();
});
it('should not show toast when data is undefined', () => {
render(<NotificationToastHandler />);
const callback = eventBusCallbacks.get('notification:deal');
callback?.(undefined);
expect(mockToastSuccess).not.toHaveBeenCalled();
});
});
describe('system messages', () => {
it('should show error toast for error severity', () => {
render(<NotificationToastHandler />);
const systemData: SystemMessageData = {
message: 'System error occurred',
severity: 'error',
};
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
expect(mockToastError).toHaveBeenCalledWith(
'System error occurred',
expect.objectContaining({
duration: 6000,
position: 'top-center',
icon: expect.any(String),
}),
);
});
it('should show warning toast for warning severity', () => {
render(<NotificationToastHandler />);
const systemData: SystemMessageData = {
message: 'System warning',
severity: 'warning',
};
// For warning, the default toast() is called
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
// Warning uses the regular toast function (mockToastDefault)
expect(mockToastDefault).toHaveBeenCalledWith(
'System warning',
expect.objectContaining({
duration: 4000,
position: 'top-center',
icon: expect.any(String),
}),
);
});
it('should show info toast for info severity', () => {
render(<NotificationToastHandler />);
const systemData: SystemMessageData = {
message: 'System info',
severity: 'info',
};
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
// Info uses the regular toast function (mockToastDefault)
expect(mockToastDefault).toHaveBeenCalledWith(
'System info',
expect.objectContaining({
duration: 4000,
position: 'top-center',
icon: expect.any(String),
}),
);
});
it('should not show toast when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
const systemData: SystemMessageData = {
message: 'System error',
severity: 'error',
};
const callback = eventBusCallbacks.get('notification:system');
callback?.(systemData);
expect(mockToastError).not.toHaveBeenCalled();
});
it('should not show toast when data is undefined', () => {
render(<NotificationToastHandler />);
const callback = eventBusCallbacks.get('notification:system');
callback?.(undefined);
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('error notifications', () => {
it('should show error toast with message and code', () => {
render(<NotificationToastHandler />);
const errorData = {
message: 'Something went wrong',
code: 'ERR_001',
};
const callback = eventBusCallbacks.get('notification:error');
callback?.(errorData);
expect(mockToastError).toHaveBeenCalledWith(
'Error: Something went wrong',
expect.objectContaining({
duration: 5000,
icon: expect.any(String),
}),
);
});
it('should show error toast without code', () => {
render(<NotificationToastHandler />);
const errorData = {
message: 'Something went wrong',
};
const callback = eventBusCallbacks.get('notification:error');
callback?.(errorData);
expect(mockToastError).toHaveBeenCalledWith(
'Error: Something went wrong',
expect.objectContaining({
duration: 5000,
}),
);
});
it('should not show toast when disabled', () => {
render(<NotificationToastHandler enabled={false} />);
const errorData = {
message: 'Something went wrong',
};
const callback = eventBusCallbacks.get('notification:error');
callback?.(errorData);
expect(mockToastError).not.toHaveBeenCalled();
});
it('should not show toast when data is undefined', () => {
render(<NotificationToastHandler />);
const callback = eventBusCallbacks.get('notification:error');
callback?.(undefined);
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('sound playback', () => {
it('should not play sound by default', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={false} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).not.toHaveBeenCalled();
});
it('should create Audio instance when playSound is true', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
// Verify Audio constructor was called with correct URL
expect(AudioMock).toHaveBeenCalledWith('/notification-sound.mp3');
});
it('should use custom sound URL', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} soundUrl="/custom-sound.mp3" />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).toHaveBeenCalledWith('/custom-sound.mp3');
});
it('should handle audio play failure gracefully', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const audioPlayMock = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
// Should not throw even if play() fails
expect(() => callback?.(dealData)).not.toThrow();
// Audio constructor should still be called
expect(AudioMock).toHaveBeenCalled();
});
it('should handle Audio constructor failure gracefully', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const AudioMock = vi.fn().mockImplementation(() => {
throw new Error('Audio not supported');
});
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
// Should not throw
expect(() => callback?.(dealData)).not.toThrow();
});
});
describe('persistent connection error', () => {
it('should show error toast after delay when connection error persists', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
render(<NotificationToastHandler enabled={true} />);
// Fast-forward 5 seconds
act(() => {
vi.advanceTimersByTime(5000);
});
expect(mockToastError).toHaveBeenCalledWith(
'Unable to connect to live notifications. Some features may be limited.',
expect.objectContaining({
duration: 5000,
icon: expect.any(String),
}),
);
});
it('should not show error toast before delay', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
render(<NotificationToastHandler enabled={true} />);
// Advance only 4 seconds
act(() => {
vi.advanceTimersByTime(4000);
});
expect(mockToastError).not.toHaveBeenCalledWith(
expect.stringContaining('Unable to connect'),
expect.anything(),
);
});
it('should not show persistent error toast when disabled', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
render(<NotificationToastHandler enabled={false} />);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(mockToastError).not.toHaveBeenCalled();
});
it('should clear timeout on unmount', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: 'Connection failed',
};
},
);
const { unmount } = render(<NotificationToastHandler enabled={true} />);
// Unmount before timer fires
unmount();
act(() => {
vi.advanceTimersByTime(5000);
});
// The toast should not be called because component unmounted
expect(mockToastError).not.toHaveBeenCalledWith(
expect.stringContaining('Unable to connect'),
expect.anything(),
);
});
it('should not show persistent error toast when there is no error', () => {
mockUseWebSocket.mockImplementation(
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
onConnectCallback = options?.onConnect;
onDisconnectCallback = options?.onDisconnect;
return {
isConnected: false,
error: null,
};
},
);
render(<NotificationToastHandler enabled={true} />);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(mockToastError).not.toHaveBeenCalled();
});
});
describe('default props', () => {
it('should default enabled to true', () => {
render(<NotificationToastHandler />);
onConnectCallback?.();
expect(mockToastSuccess).toHaveBeenCalled();
});
it('should default playSound to false', () => {
const AudioMock = vi.fn();
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).not.toHaveBeenCalled();
});
it('should default soundUrl to /notification-sound.mp3', () => {
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
const AudioMock = vi.fn().mockImplementation(() => ({
play: audioPlayMock,
volume: 0,
}));
vi.stubGlobal('Audio', AudioMock);
render(<NotificationToastHandler playSound={true} />);
const dealData: DealNotificationData = {
deals: [
{
item_name: 'Milk',
best_price_in_cents: 399,
store_name: 'Test Store',
store_id: 1,
},
],
user_id: 'user-123',
message: 'New deal',
};
const callback = eventBusCallbacks.get('notification:deal');
callback?.(dealData);
expect(AudioMock).toHaveBeenCalledWith('/notification-sound.mp3');
});
});
});

View File

@@ -0,0 +1,395 @@
// src/features/store/StoreCard.test.tsx
import React from 'react';
import { screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { StoreCard } from './StoreCard';
import { renderWithProviders } from '../../tests/utils/renderWithProviders';
describe('StoreCard', () => {
const mockStoreWithLogo = {
store_id: 1,
name: 'Test Store',
logo_url: 'https://example.com/logo.png',
locations: [
{
address_line_1: '123 Main Street',
city: 'Toronto',
province_state: 'ON',
postal_code: 'M5V 1A1',
},
],
};
const mockStoreWithoutLogo = {
store_id: 2,
name: 'Another Store',
logo_url: null,
locations: [
{
address_line_1: '456 Oak Avenue',
city: 'Vancouver',
province_state: 'BC',
postal_code: 'V6B 2M9',
},
],
};
const mockStoreWithMultipleLocations = {
store_id: 3,
name: 'Multi Location Store',
logo_url: 'https://example.com/multi-logo.png',
locations: [
{
address_line_1: '100 First Street',
city: 'Montreal',
province_state: 'QC',
postal_code: 'H2X 1Y6',
},
{
address_line_1: '200 Second Street',
city: 'Montreal',
province_state: 'QC',
postal_code: 'H3A 2T1',
},
{
address_line_1: '300 Third Street',
city: 'Montreal',
province_state: 'QC',
postal_code: 'H4B 3C2',
},
],
};
const mockStoreNoLocations = {
store_id: 4,
name: 'No Location Store',
logo_url: 'https://example.com/no-loc-logo.png',
locations: [],
};
const mockStoreUndefinedLocations = {
store_id: 5,
name: 'Undefined Locations Store',
logo_url: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('store name rendering', () => {
it('should render the store name', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
expect(screen.getByText('Test Store')).toBeInTheDocument();
});
it('should render store name with truncation class', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveClass('truncate');
});
});
describe('logo rendering', () => {
it('should render logo image when logo_url is provided', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const logo = screen.getByAltText('Test Store logo');
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src', 'https://example.com/logo.png');
});
it('should render initials fallback when logo_url is null', () => {
renderWithProviders(<StoreCard store={mockStoreWithoutLogo} />);
expect(screen.getByText('AN')).toBeInTheDocument();
});
it('should render initials fallback when logo_url is undefined', () => {
const storeWithUndefinedLogo = {
store_id: 10,
name: 'Test Name',
logo_url: undefined,
};
renderWithProviders(<StoreCard store={storeWithUndefinedLogo} />);
expect(screen.getByText('TE')).toBeInTheDocument();
});
it('should convert initials to uppercase', () => {
const storeWithLowercase = {
store_id: 11,
name: 'lowercase store',
logo_url: null,
};
renderWithProviders(<StoreCard store={storeWithLowercase} />);
expect(screen.getByText('LO')).toBeInTheDocument();
});
it('should handle single character store name', () => {
const singleCharStore = {
store_id: 12,
name: 'X',
logo_url: null,
};
renderWithProviders(<StoreCard store={singleCharStore} />);
// Both the store name and initials will be 'X'
// Check that there are exactly 2 elements with 'X'
const elements = screen.getAllByText('X');
expect(elements).toHaveLength(2);
});
it('should handle empty string store name', () => {
const emptyNameStore = {
store_id: 13,
name: '',
logo_url: null,
};
// This will render empty string for initials
const { container } = renderWithProviders(<StoreCard store={emptyNameStore} />);
// The fallback div should still render
const fallbackDiv = container.querySelector('.h-12.w-12.flex');
expect(fallbackDiv).toBeInTheDocument();
});
});
describe('location display', () => {
it('should not show location when showLocations is false (default)', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
expect(screen.queryByText('123 Main Street')).not.toBeInTheDocument();
expect(screen.queryByText(/Toronto/)).not.toBeInTheDocument();
});
it('should show primary location when showLocations is true', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} showLocations={true} />);
expect(screen.getByText('123 Main Street')).toBeInTheDocument();
expect(screen.getByText('Toronto, ON M5V 1A1')).toBeInTheDocument();
});
it('should show "No location data" when showLocations is true but no locations exist', () => {
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={true} />);
expect(screen.getByText('No location data')).toBeInTheDocument();
});
it('should show "No location data" when locations is undefined', () => {
renderWithProviders(
<StoreCard
store={mockStoreUndefinedLocations as typeof mockStoreWithLogo}
showLocations={true}
/>,
);
expect(screen.getByText('No location data')).toBeInTheDocument();
});
it('should not show "No location data" message when showLocations is false', () => {
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={false} />);
expect(screen.queryByText('No location data')).not.toBeInTheDocument();
});
});
describe('multiple locations', () => {
it('should show additional locations count for 2 locations', () => {
const storeWith2Locations = {
...mockStoreWithLogo,
locations: [
mockStoreWithMultipleLocations.locations[0],
mockStoreWithMultipleLocations.locations[1],
],
};
renderWithProviders(<StoreCard store={storeWith2Locations} showLocations={true} />);
expect(screen.getByText('+ 1 more location')).toBeInTheDocument();
});
it('should show additional locations count for 3+ locations', () => {
renderWithProviders(
<StoreCard store={mockStoreWithMultipleLocations} showLocations={true} />,
);
expect(screen.getByText('+ 2 more locations')).toBeInTheDocument();
});
it('should show primary location from multiple locations', () => {
renderWithProviders(
<StoreCard store={mockStoreWithMultipleLocations} showLocations={true} />,
);
// Should show first location
expect(screen.getByText('100 First Street')).toBeInTheDocument();
expect(screen.getByText('Montreal, QC H2X 1Y6')).toBeInTheDocument();
// Should NOT show secondary locations directly
expect(screen.queryByText('200 Second Street')).not.toBeInTheDocument();
});
it('should not show additional locations count for single location', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} showLocations={true} />);
expect(screen.queryByText(/more location/)).not.toBeInTheDocument();
});
});
describe('accessibility', () => {
it('should have proper alt text for logo', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const logo = screen.getByAltText('Test Store logo');
expect(logo).toBeInTheDocument();
});
it('should use heading level 3 for store name', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('Test Store');
});
});
describe('styling', () => {
it('should apply flex layout to container', () => {
const { container } = renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const mainDiv = container.firstChild;
expect(mainDiv).toHaveClass('flex', 'items-start', 'space-x-3');
});
it('should apply proper styling to logo image', () => {
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
const logo = screen.getByAltText('Test Store logo');
expect(logo).toHaveClass(
'h-12',
'w-12',
'object-contain',
'rounded-md',
'bg-gray-100',
'dark:bg-gray-700',
'p-1',
'flex-shrink-0',
);
});
it('should apply proper styling to initials fallback', () => {
const { container } = renderWithProviders(<StoreCard store={mockStoreWithoutLogo} />);
const initialsDiv = container.querySelector('.h-12.w-12.flex.items-center.justify-center');
expect(initialsDiv).toHaveClass(
'h-12',
'w-12',
'flex',
'items-center',
'justify-center',
'bg-gray-200',
'dark:bg-gray-700',
'rounded-md',
'text-gray-400',
'text-xs',
'flex-shrink-0',
);
});
it('should apply italic style to "No location data" text', () => {
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={true} />);
const noLocationText = screen.getByText('No location data');
expect(noLocationText).toHaveClass('italic');
});
});
describe('edge cases', () => {
it('should handle store with special characters in name', () => {
const specialCharStore = {
store_id: 20,
name: "Store & Co's <Best>",
logo_url: null,
};
renderWithProviders(<StoreCard store={specialCharStore} />);
expect(screen.getByText("Store & Co's <Best>")).toBeInTheDocument();
expect(screen.getByText('ST')).toBeInTheDocument();
});
it('should handle store with unicode characters', () => {
const unicodeStore = {
store_id: 21,
name: 'Cafe Le Cafe',
logo_url: null,
};
renderWithProviders(<StoreCard store={unicodeStore} />);
expect(screen.getByText('Cafe Le Cafe')).toBeInTheDocument();
expect(screen.getByText('CA')).toBeInTheDocument();
});
it('should handle location with long address', () => {
const longAddressStore = {
store_id: 22,
name: 'Long Address Store',
logo_url: 'https://example.com/logo.png',
locations: [
{
address_line_1: '1234567890 Very Long Street Name That Exceeds Normal Length',
city: 'Vancouver',
province_state: 'BC',
postal_code: 'V6B 2M9',
},
],
};
renderWithProviders(<StoreCard store={longAddressStore} showLocations={true} />);
const addressElement = screen.getByText(
'1234567890 Very Long Street Name That Exceeds Normal Length',
);
expect(addressElement).toHaveClass('truncate');
});
});
describe('data types', () => {
it('should accept store_id as number', () => {
const store = {
store_id: 12345,
name: 'Numeric ID Store',
logo_url: null,
};
// This should compile and render without errors
renderWithProviders(<StoreCard store={store} />);
expect(screen.getByText('Numeric ID Store')).toBeInTheDocument();
});
it('should handle empty logo_url string', () => {
const storeWithEmptyLogo = {
store_id: 30,
name: 'Empty Logo Store',
logo_url: '',
};
// Empty string is truthy check, but might cause issues with img src
// The component checks for truthy logo_url, so empty string will render initials
// Actually, empty string '' is falsy in JavaScript, so this would show initials
renderWithProviders(<StoreCard store={storeWithEmptyLogo} />);
// Empty string is falsy, so initials should show
expect(screen.getByText('EM')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,311 @@
// src/hooks/useEventBus.test.ts
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { useEventBus } from './useEventBus';
// Mock the eventBus service
vi.mock('../services/eventBus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
dispatch: vi.fn(),
},
}));
import { eventBus } from '../services/eventBus';
const mockEventBus = eventBus as {
on: Mock;
off: Mock;
dispatch: Mock;
};
describe('useEventBus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('subscription', () => {
it('should subscribe to the event on mount', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
expect(mockEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function));
});
it('should unsubscribe from the event on unmount', () => {
const callback = vi.fn();
const { unmount } = renderHook(() => useEventBus('test-event', callback));
unmount();
expect(mockEventBus.off).toHaveBeenCalledTimes(1);
expect(mockEventBus.off).toHaveBeenCalledWith('test-event', expect.any(Function));
});
it('should pass the same callback reference to on and off', () => {
const callback = vi.fn();
const { unmount } = renderHook(() => useEventBus('test-event', callback));
const onCallback = mockEventBus.on.mock.calls[0][1];
unmount();
const offCallback = mockEventBus.off.mock.calls[0][1];
expect(onCallback).toBe(offCallback);
});
});
describe('callback execution', () => {
it('should call the callback when event is dispatched', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
// Get the registered callback and call it
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback({ message: 'hello' });
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({ message: 'hello' });
});
it('should call the callback with undefined data', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback(undefined);
expect(callback).toHaveBeenCalledWith(undefined);
});
it('should call the callback with null data', () => {
const callback = vi.fn();
renderHook(() => useEventBus('test-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback(null);
expect(callback).toHaveBeenCalledWith(null);
});
});
describe('callback ref updates', () => {
it('should use the latest callback when event is dispatched', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const { rerender } = renderHook(({ callback }) => useEventBus('test-event', callback), {
initialProps: { callback: callback1 },
});
// Rerender with new callback
rerender({ callback: callback2 });
// Get the registered callback and call it
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback({ message: 'hello' });
// Should call the new callback, not the old one
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledWith({ message: 'hello' });
});
it('should not re-subscribe when callback changes', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const { rerender } = renderHook(({ callback }) => useEventBus('test-event', callback), {
initialProps: { callback: callback1 },
});
// Clear mock counts
mockEventBus.on.mockClear();
mockEventBus.off.mockClear();
// Rerender with new callback
rerender({ callback: callback2 });
// Should NOT unsubscribe and re-subscribe
expect(mockEventBus.off).not.toHaveBeenCalled();
expect(mockEventBus.on).not.toHaveBeenCalled();
});
});
describe('event name changes', () => {
it('should re-subscribe when event name changes', () => {
const callback = vi.fn();
const { rerender } = renderHook(({ event }) => useEventBus(event, callback), {
initialProps: { event: 'event-1' },
});
// Initial subscription
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
expect(mockEventBus.on).toHaveBeenCalledWith('event-1', expect.any(Function));
// Clear mock
mockEventBus.on.mockClear();
// Rerender with different event
rerender({ event: 'event-2' });
// Should unsubscribe from old event
expect(mockEventBus.off).toHaveBeenCalledWith('event-1', expect.any(Function));
// Should subscribe to new event
expect(mockEventBus.on).toHaveBeenCalledWith('event-2', expect.any(Function));
});
});
describe('multiple hooks', () => {
it('should allow multiple subscriptions to same event', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
renderHook(() => useEventBus('shared-event', callback1));
renderHook(() => useEventBus('shared-event', callback2));
expect(mockEventBus.on).toHaveBeenCalledTimes(2);
// Both should be subscribed to same event
expect(mockEventBus.on.mock.calls[0][0]).toBe('shared-event');
expect(mockEventBus.on.mock.calls[1][0]).toBe('shared-event');
});
it('should allow subscriptions to different events', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
renderHook(() => useEventBus('event-a', callback1));
renderHook(() => useEventBus('event-b', callback2));
expect(mockEventBus.on).toHaveBeenCalledTimes(2);
expect(mockEventBus.on).toHaveBeenCalledWith('event-a', expect.any(Function));
expect(mockEventBus.on).toHaveBeenCalledWith('event-b', expect.any(Function));
});
});
describe('type safety', () => {
it('should correctly type the callback data', () => {
interface TestData {
id: number;
name: string;
}
const callback = vi.fn<[TestData?], void>();
renderHook(() => useEventBus<TestData>('typed-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
registeredCallback({ id: 1, name: 'test' });
expect(callback).toHaveBeenCalledWith({ id: 1, name: 'test' });
});
it('should handle callback with optional parameter', () => {
const callback = vi.fn<[string?], void>();
renderHook(() => useEventBus<string>('optional-event', callback));
const registeredCallback = mockEventBus.on.mock.calls[0][1];
// Call with data
registeredCallback('hello');
expect(callback).toHaveBeenCalledWith('hello');
// Call without data
registeredCallback();
expect(callback).toHaveBeenCalledWith(undefined);
});
});
describe('edge cases', () => {
it('should handle empty string event name', () => {
const callback = vi.fn();
renderHook(() => useEventBus('', callback));
expect(mockEventBus.on).toHaveBeenCalledWith('', expect.any(Function));
});
it('should handle event names with special characters', () => {
const callback = vi.fn();
renderHook(() => useEventBus('event:with:colons', callback));
expect(mockEventBus.on).toHaveBeenCalledWith('event:with:colons', expect.any(Function));
});
it('should handle rapid mount/unmount cycles', () => {
const callback = vi.fn();
const { unmount: unmount1 } = renderHook(() => useEventBus('rapid-event', callback));
unmount1();
const { unmount: unmount2 } = renderHook(() => useEventBus('rapid-event', callback));
unmount2();
const { unmount: unmount3 } = renderHook(() => useEventBus('rapid-event', callback));
unmount3();
// Should have 3 subscriptions and 3 unsubscriptions
expect(mockEventBus.on).toHaveBeenCalledTimes(3);
expect(mockEventBus.off).toHaveBeenCalledTimes(3);
});
});
describe('stable callback reference', () => {
it('should use useCallback for stable reference', () => {
const callback = vi.fn();
const { rerender } = renderHook(() => useEventBus('stable-event', callback));
const firstCallbackRef = mockEventBus.on.mock.calls[0][1];
// Force a rerender
rerender();
// The callback passed to eventBus.on should remain the same
// (no re-subscription means the same callback is used)
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
// Verify the callback still works after rerender
firstCallbackRef({ data: 'test' });
expect(callback).toHaveBeenCalledWith({ data: 'test' });
});
});
describe('cleanup timing', () => {
it('should unsubscribe before component is fully unmounted', () => {
const callback = vi.fn();
const cleanupOrder: string[] = [];
// Override off to track when it's called
mockEventBus.off.mockImplementation(() => {
cleanupOrder.push('eventBus.off');
});
const { unmount } = renderHook(() => useEventBus('cleanup-event', callback));
cleanupOrder.push('before unmount');
unmount();
cleanupOrder.push('after unmount');
expect(cleanupOrder).toEqual(['before unmount', 'eventBus.off', 'after unmount']);
});
});
});

View File

@@ -0,0 +1,560 @@
// src/hooks/useOnboardingTour.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { useOnboardingTour } from './useOnboardingTour';
// Mock driver.js
const mockDrive = vi.fn();
const mockDestroy = vi.fn();
const mockDriverInstance = {
drive: mockDrive,
destroy: mockDestroy,
};
vi.mock('driver.js', () => ({
driver: vi.fn(() => mockDriverInstance),
Driver: vi.fn(),
DriveStep: vi.fn(),
}));
import { driver } from 'driver.js';
const mockDriver = driver as Mock;
describe('useOnboardingTour', () => {
const STORAGE_KEY = 'flyer_crawler_onboarding_completed';
// Mock localStorage
let mockLocalStorage: { [key: string]: string };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Reset mock driver instance methods
mockDrive.mockClear();
mockDestroy.mockClear();
// Reset localStorage mock
mockLocalStorage = {};
// Mock localStorage
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(
(key: string) => mockLocalStorage[key] || null,
);
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => {
mockLocalStorage[key] = value;
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => {
delete mockLocalStorage[key];
});
// Mock document.getElementById for style injection check
vi.spyOn(document, 'getElementById').mockReturnValue(null);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('initialization', () => {
it('should return startTour, skipTour, and replayTour functions', () => {
mockLocalStorage[STORAGE_KEY] = 'true'; // Prevent auto-start
const { result } = renderHook(() => useOnboardingTour());
expect(result.current.startTour).toBeInstanceOf(Function);
expect(result.current.skipTour).toBeInstanceOf(Function);
expect(result.current.replayTour).toBeInstanceOf(Function);
});
it('should auto-start tour if not completed', async () => {
// Don't set the storage key - tour not completed
renderHook(() => useOnboardingTour());
// Fast-forward past the 500ms delay
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockDriver).toHaveBeenCalled();
expect(mockDrive).toHaveBeenCalled();
});
it('should not auto-start tour if already completed', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
renderHook(() => useOnboardingTour());
// Fast-forward past the 500ms delay
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockDrive).not.toHaveBeenCalled();
});
});
describe('startTour', () => {
it('should create and start the driver tour', () => {
mockLocalStorage[STORAGE_KEY] = 'true'; // Prevent auto-start
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
expect(mockDriver).toHaveBeenCalledWith(
expect.objectContaining({
showProgress: true,
steps: expect.any(Array),
nextBtnText: 'Next',
prevBtnText: 'Previous',
doneBtnText: 'Done',
progressText: 'Step {{current}} of {{total}}',
onDestroyed: expect.any(Function),
}),
);
expect(mockDrive).toHaveBeenCalled();
});
it('should inject custom CSS styles', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Track the created style element
const createdStyleElement = document.createElement('style');
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi
.spyOn(document, 'createElement')
.mockImplementation((tagName: string) => {
if (tagName === 'style') {
return createdStyleElement;
}
return originalCreateElement(tagName);
});
const appendChildSpy = vi.spyOn(document.head, 'appendChild');
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
expect(createElementSpy).toHaveBeenCalledWith('style');
expect(appendChildSpy).toHaveBeenCalled();
});
it('should not inject styles if they already exist', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Mock that the style element already exists
vi.spyOn(document, 'getElementById').mockReturnValue({
id: 'driver-js-custom-styles',
} as HTMLElement);
const createElementSpy = vi.spyOn(document, 'createElement');
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
// createElement should not be called for the style element
const styleCreateCalls = createElementSpy.mock.calls.filter((call) => call[0] === 'style');
expect(styleCreateCalls).toHaveLength(0);
});
it('should destroy existing tour before starting new one', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
// Start tour twice
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
act(() => {
result.current.startTour();
});
expect(mockDestroy).toHaveBeenCalled();
});
it('should mark tour complete when onDestroyed is called', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
// Get the onDestroyed callback
const driverConfig = mockDriver.mock.calls[0][0];
const onDestroyed = driverConfig.onDestroyed;
act(() => {
onDestroyed();
});
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
});
describe('skipTour', () => {
it('should destroy the tour if active', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
// Start the tour first
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
act(() => {
result.current.skipTour();
});
expect(mockDestroy).toHaveBeenCalled();
});
it('should mark tour as complete', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.skipTour();
});
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
it('should handle skip when no tour is active', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
// Skip without starting
expect(() => {
act(() => {
result.current.skipTour();
});
}).not.toThrow();
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
});
describe('replayTour', () => {
it('should start the tour', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.replayTour();
});
expect(mockDriver).toHaveBeenCalled();
expect(mockDrive).toHaveBeenCalled();
});
it('should work even if tour was previously completed', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.replayTour();
});
expect(mockDrive).toHaveBeenCalled();
});
});
describe('cleanup', () => {
it('should destroy tour on unmount', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result, unmount } = renderHook(() => useOnboardingTour());
// Start the tour
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
unmount();
expect(mockDestroy).toHaveBeenCalled();
});
it('should clear timeout on unmount if tour not started yet', () => {
// Don't set storage key - tour will try to auto-start
const { unmount } = renderHook(() => useOnboardingTour());
// Unmount before the 500ms delay
unmount();
// Now advance timers - tour should NOT start
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockDrive).not.toHaveBeenCalled();
});
it('should not throw on unmount when no tour is active', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { unmount } = renderHook(() => useOnboardingTour());
// Unmount without starting tour
expect(() => unmount()).not.toThrow();
});
});
describe('auto-start delay', () => {
it('should wait 500ms before auto-starting tour', () => {
// Don't set storage key
renderHook(() => useOnboardingTour());
// Tour should not have started yet
expect(mockDrive).not.toHaveBeenCalled();
// Advance 499ms
act(() => {
vi.advanceTimersByTime(499);
});
expect(mockDrive).not.toHaveBeenCalled();
// Advance 1 more ms
act(() => {
vi.advanceTimersByTime(1);
});
expect(mockDrive).toHaveBeenCalled();
});
});
describe('tour steps configuration', () => {
it('should configure tour with 6 steps', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
const driverConfig = mockDriver.mock.calls[0][0];
expect(driverConfig.steps).toHaveLength(6);
});
it('should have correct step elements', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
const driverConfig = mockDriver.mock.calls[0][0];
const steps = driverConfig.steps;
expect(steps[0].element).toBe('[data-tour="flyer-uploader"]');
expect(steps[1].element).toBe('[data-tour="extracted-data-table"]');
expect(steps[2].element).toBe('[data-tour="watch-button"]');
expect(steps[3].element).toBe('[data-tour="watched-items"]');
expect(steps[4].element).toBe('[data-tour="price-chart"]');
expect(steps[5].element).toBe('[data-tour="shopping-list"]');
});
it('should have popover configuration for each step', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
const driverConfig = mockDriver.mock.calls[0][0];
const steps = driverConfig.steps;
steps.forEach(
(step: {
popover: { title: string; description: string; side: string; align: string };
}) => {
expect(step.popover).toBeDefined();
expect(step.popover.title).toBeDefined();
expect(step.popover.description).toBeDefined();
expect(step.popover.side).toBeDefined();
expect(step.popover.align).toBeDefined();
},
);
});
});
describe('function stability', () => {
it('should maintain stable function references across rerenders', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result, rerender } = renderHook(() => useOnboardingTour());
const initialStartTour = result.current.startTour;
const initialSkipTour = result.current.skipTour;
const initialReplayTour = result.current.replayTour;
rerender();
expect(result.current.startTour).toBe(initialStartTour);
expect(result.current.skipTour).toBe(initialSkipTour);
expect(result.current.replayTour).toBe(initialReplayTour);
});
});
describe('localStorage key', () => {
it('should use correct storage key', () => {
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.skipTour();
});
expect(localStorage.setItem).toHaveBeenCalledWith(
'flyer_crawler_onboarding_completed',
'true',
);
});
it('should read from correct storage key on mount', () => {
mockLocalStorage['flyer_crawler_onboarding_completed'] = 'true';
renderHook(() => useOnboardingTour());
expect(localStorage.getItem).toHaveBeenCalledWith('flyer_crawler_onboarding_completed');
});
});
describe('edge cases', () => {
it('should handle multiple startTour calls gracefully', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
result.current.startTour();
result.current.startTour();
});
// Each startTour destroys the previous one
expect(mockDestroy).toHaveBeenCalledTimes(2); // Called before 2nd and 3rd startTour
expect(mockDrive).toHaveBeenCalledTimes(3);
});
it('should handle skipTour after startTour', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
mockDestroy.mockClear();
act(() => {
result.current.skipTour();
});
expect(mockDestroy).toHaveBeenCalledTimes(1);
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
});
it('should handle replayTour multiple times', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.replayTour();
});
mockDriver.mockClear();
mockDrive.mockClear();
act(() => {
result.current.replayTour();
});
expect(mockDriver).toHaveBeenCalled();
expect(mockDrive).toHaveBeenCalled();
});
});
describe('CSS injection', () => {
it('should set correct id on style element', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Track the created style element
const createdStyleElement = document.createElement('style');
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
if (tagName === 'style') {
return createdStyleElement;
}
return originalCreateElement(tagName);
});
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
expect(createdStyleElement.id).toBe('driver-js-custom-styles');
});
it('should inject CSS containing custom styles', () => {
mockLocalStorage[STORAGE_KEY] = 'true';
// Track the created style element
const createdStyleElement = document.createElement('style');
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
if (tagName === 'style') {
return createdStyleElement;
}
return originalCreateElement(tagName);
});
const { result } = renderHook(() => useOnboardingTour());
act(() => {
result.current.startTour();
});
// Check that textContent contains expected CSS rules
expect(createdStyleElement.textContent).toContain('.driver-popover');
expect(createdStyleElement.textContent).toContain('background-color');
});
});
});

View File

@@ -135,7 +135,7 @@ describe('Worker Service Lifecycle', () => {
cleanupWorker = workerService.cleanupWorker; cleanupWorker = workerService.cleanupWorker;
weeklyAnalyticsWorker = workerService.weeklyAnalyticsWorker; weeklyAnalyticsWorker = workerService.weeklyAnalyticsWorker;
tokenCleanupWorker = workerService.tokenCleanupWorker; tokenCleanupWorker = workerService.tokenCleanupWorker;
}); }, 15000); // Increase timeout for module re-import which can be slow
afterEach(() => { afterEach(() => {
// Clean up all event listeners on the mock connection to prevent open handles. // Clean up all event listeners on the mock connection to prevent open handles.
@@ -144,8 +144,8 @@ describe('Worker Service Lifecycle', () => {
}); });
it('should log a success message when Redis connects', () => { it('should log a success message when Redis connects', () => {
// Re-import redis.server to trigger its event listeners with the mock // redis.server is already imported via workers.server in beforeEach,
import('./redis.server'); // which attaches event listeners to mockRedisConnection.
// Act: Simulate the 'connect' event on the mock Redis connection // Act: Simulate the 'connect' event on the mock Redis connection
mockRedisConnection.emit('connect'); mockRedisConnection.emit('connect');
@@ -154,7 +154,8 @@ describe('Worker Service Lifecycle', () => {
}); });
it('should log an error message when Redis connection fails', () => { it('should log an error message when Redis connection fails', () => {
import('./redis.server'); // redis.server is already imported via workers.server in beforeEach,
// which attaches event listeners to mockRedisConnection.
const redisError = new Error('Connection refused'); const redisError = new Error('Connection refused');
mockRedisConnection.emit('error', redisError); mockRedisConnection.emit('error', redisError);
expect(mockLogger.error).toHaveBeenCalledWith({ err: redisError }, '[Redis] Connection error.'); expect(mockLogger.error).toHaveBeenCalledWith({ err: redisError }, '[Redis] Connection error.');

View File

@@ -77,22 +77,22 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
// Calculate checksum (required by the API) // Calculate checksum (required by the API)
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// 4. Upload the flyer // 4. Upload the flyer (uses /ai/upload-and-process endpoint with flyerFile field)
const uploadResponse = await getRequest() const uploadResponse = await getRequest()
.post('/api/v1/flyers/upload') .post('/api/v1/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`) .set('Authorization', `Bearer ${authToken}`)
.attach('flyer', fileBuffer, fileName) .attach('flyerFile', fileBuffer, fileName)
.field('checksum', checksum); .field('checksum', checksum);
expect(uploadResponse.status).toBe(202); expect(uploadResponse.status).toBe(202);
const jobId = uploadResponse.body.data.jobId; const jobId = uploadResponse.body.data.jobId;
expect(jobId).toBeDefined(); expect(jobId).toBeDefined();
// 5. Poll for job completion using the new utility // 5. Poll for job completion using the new utility (endpoint is /ai/jobs/:jobId/status)
const jobStatusResponse = await poll( const jobStatusResponse = await poll(
async () => { async () => {
const statusResponse = await getRequest() const statusResponse = await getRequest()
.get(`/api/v1/jobs/${jobId}`) .get(`/api/v1/ai/jobs/${jobId}/status`)
.set('Authorization', `Bearer ${authToken}`); .set('Authorization', `Bearer ${authToken}`);
return statusResponse.body; return statusResponse.body;
}, },