test fixes and doc work
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m50s
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m50s
This commit is contained in:
49
CLAUDE.md
49
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
367
docs/adr/0057-test-remediation-post-api-versioning.md
Normal file
367
docs/adr/0057-test-remediation-post-api-versioning.md
Normal 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 |
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|||||||
424
src/components/NotificationBell.test.tsx
Normal file
424
src/components/NotificationBell.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
776
src/components/NotificationToastHandler.test.tsx
Normal file
776
src/components/NotificationToastHandler.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
395
src/features/store/StoreCard.test.tsx
Normal file
395
src/features/store/StoreCard.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
311
src/hooks/useEventBus.test.ts
Normal file
311
src/hooks/useEventBus.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
560
src/hooks/useOnboardingTour.test.ts
Normal file
560
src/hooks/useOnboardingTour.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user