Compare commits

...

10 Commits

Author SHA1 Message Date
Gitea Actions
6da4b5e9d0 ci: Bump version to 0.11.7 [skip ci] 2026-01-19 03:28:57 +05:00
941626004e test fixes to align with latest tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m51s
2026-01-18 14:27:20 -08:00
Gitea Actions
67cfe39249 ci: Bump version to 0.11.6 [skip ci] 2026-01-19 03:00:22 +05:00
c24103d9a0 frontend direct testing result and fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s
2026-01-18 13:57:47 -08:00
Gitea Actions
3e85f839fe ci: Bump version to 0.11.5 [skip ci] 2026-01-18 15:57:52 +05:00
63a0dde0f8 fix unit tests after frontend tests ran
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m21s
2026-01-18 02:56:25 -08:00
Gitea Actions
94f45d9726 ci: Bump version to 0.11.4 [skip ci] 2026-01-18 14:36:55 +05:00
136a9ce3f3 Add ADR-054 for Bugsink to Gitea issue synchronization and frontend testing summary for 2026-01-18
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m3s
- Introduced ADR-054 detailing the implementation of an automated sync worker to create Gitea issues from unresolved Bugsink errors.
- Documented architecture, queue configuration, Redis schema, and implementation phases for the sync feature.
- Added frontend testing summary for 2026-01-18, covering multiple sessions of API testing, fixes applied, and Bugsink error tracking status.
- Included detailed API reference and common validation errors encountered during testing.
2026-01-18 01:35:00 -08:00
Gitea Actions
e65151c3df ci: Bump version to 0.11.3 [skip ci] 2026-01-18 10:49:14 +05:00
3d91d59b9c refactor: update API response handling across multiple queries to ensure compliance with ADR-028
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m53s
- Removed direct return of json.data in favor of structured error handling.
- Implemented checks for success and data array in useActivityLogQuery, useBestSalePricesQuery, useBrandsQuery, useCategoriesQuery, useFlyerItemsForFlyersQuery, useFlyerItemsQuery, useFlyersQuery, useLeaderboardQuery, useMasterItemsQuery, usePriceHistoryQuery, useShoppingListsQuery, useSuggestedCorrectionsQuery, and useWatchedItemsQuery.
- Updated unit tests to reflect changes in expected behavior when API response does not conform to the expected structure.
- Updated package.json to use the latest version of @sentry/vite-plugin.
- Adjusted vite.config.ts for local development SSL configuration.
- Added self-signed SSL certificate and key for local development.
2026-01-17 21:45:51 -08:00
59 changed files with 5696 additions and 1006 deletions

19
certs/localhost.crt Normal file
View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUHhZUK1vmww2wCepWPuVcU6d27hMwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDExODAyMzM0NFoXDTI3MDEx
ODAyMzM0NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAuUJGtSZzd+ZpLi+efjrkxJJNfVxVz2VLhknNM2WKeOYx
JTK/VaTYq5hrczy6fEUnMhDAJCgEPUFlOK3vn1gFJKNMN8m7arkLVk6PYtrx8CTw
w78Q06FLITr6hR0vlJNpN4MsmGxYwUoUpn1j5JdfZF7foxNAZRiwoopf7ZJxltDu
PIuFjmVZqdzR8c6vmqIqdawx/V6sL9fizZr+CDH3oTsTUirn2qM+1ibBtPDiBvfX
omUsr6MVOcTtvnMvAdy9NfV88qwF7MEWBGCjXkoT1bKCLD8hjn8l7GjRmPcmMFE2
GqWEvfJiFkBK0CgSHYEUwzo0UtVNeQr0k0qkDRub6QIDAQABo1MwUTAdBgNVHQ4E
FgQU5VeD67yFLV0QNYbHaJ6u9cM6UbkwHwYDVR0jBBgwFoAU5VeD67yFLV0QNYbH
aJ6u9cM6UbkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABueA
8ujAD+yjeP5dTgqQH1G0hlriD5LmlJYnktaLarFU+y+EZlRFwjdORF/vLPwSG+y7
CLty/xlmKKQop70QzQ5jtJcsWzUjww8w1sO3AevfZlIF3HNhJmt51ihfvtJ7DVCv
CNyMeYO0pBqRKwOuhbG3EtJgyV7MF8J25UEtO4t+GzX3jcKKU4pWP+kyLBVfeDU3
MQuigd2LBwBQQFxZdpYpcXVKnAJJlHZIt68ycO1oSBEJO9fIF0CiAlC6ITxjtYtz
oCjd6cCLKMJiC6Zg7t1Q17vGl+FdGyQObSsiYsYO9N3CVaeDdpyGCH0Rfa0+oZzu
a5U9/l1FHlvpX980bw==
-----END CERTIFICATE-----

28
certs/localhost.key Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC5Qka1JnN35mku
L55+OuTEkk19XFXPZUuGSc0zZYp45jElMr9VpNirmGtzPLp8RScyEMAkKAQ9QWU4
re+fWAUko0w3ybtquQtWTo9i2vHwJPDDvxDToUshOvqFHS+Uk2k3gyyYbFjBShSm
fWPkl19kXt+jE0BlGLCiil/tknGW0O48i4WOZVmp3NHxzq+aoip1rDH9Xqwv1+LN
mv4IMfehOxNSKufaoz7WJsG08OIG99eiZSyvoxU5xO2+cy8B3L019XzyrAXswRYE
YKNeShPVsoIsPyGOfyXsaNGY9yYwUTYapYS98mIWQErQKBIdgRTDOjRS1U15CvST
SqQNG5vpAgMBAAECggEAAnv0Dw1Mv+rRy4ZyxtObEVPXPRzoxnDDXzHP4E16BTye
Fc/4pSBUIAUn2bPvLz0/X8bMOa4dlDcIv7Eu9Pvns8AY70vMaUReA80fmtHVD2xX
1PCT0X3InnxRAYKstSIUIGs+aHvV5Z+iJ8F82soOStN1MU56h+JLWElL5deCPHq3
tLZT8wM9aOZlNG72kJ71+DlcViahynQj8+VrionOLNjTJ2Jv/ByjM3GMIuSdBrgd
Sl4YAcdn6ontjJGoTgI+e+qkBAPwMZxHarNGQgbS0yNVIJe7Lq4zIKHErU/ZSmpD
GzhdVNzhrjADNIDzS7G+pxtz+aUxGtmRvOyopy8GAQKBgQDEPp2mRM+uZVVT4e1j
pkKO1c3O8j24I5mGKwFqhhNs3qGy051RXZa0+cQNx63GokXQan9DIXzc/Il7Y72E
z9bCFbcSWnlP8dBIpWiJm+UmqLXRyY4N8ecNnzL5x+Tuxm5Ij+ixJwXgdz/TLNeO
MBzu+Qy738/l/cAYxwcF7mR7AQKBgQDxq1F95HzCxBahRU9OGUO4s3naXqc8xKCC
m3vbbI8V0Exse2cuiwtlPPQWzTPabLCJVvCGXNru98sdeOu9FO9yicwZX0knOABK
QfPyDeITsh2u0C63+T9DNn6ixI/T68bTs7DHawEYbpS7bR50BnbHbQrrOAo6FSXF
yC7+Te+o6QKBgQCXEWSmo/4D0Dn5Usg9l7VQ40GFd3EPmUgLwntal0/I1TFAyiom
gpcLReIogXhCmpSHthO1h8fpDfZ/p+4ymRRHYBQH6uHMKugdpEdu9zVVpzYgArp5
/afSEqVZJwoSzWoELdQA23toqiPV2oUtDdiYFdw5nDccY1RHPp8nb7amAQKBgQDj
f4DhYDxKJMmg21xCiuoDb4DgHoaUYA0xpii8cL9pq4KmBK0nVWFO1kh5Robvsa2m
PB+EfNjkaIPepLxWbOTUEAAASoDU2JT9UoTQcl1GaUAkFnpEWfBB14TyuNMkjinH
lLpvn72SQFbm8VvfoU4jgfTrZP/LmajLPR1v6/IWMQKBgBh9qvOTax/GugBAWNj3
ZvF99rHOx0rfotEdaPcRN66OOiSWILR9yfMsTvwt1V0VEj7OqO9juMRFuIyB57gd
Hs/zgbkuggqjr1dW9r22P/UpzpodAEEN2d52RSX8nkMOkH61JXlH2MyRX65kdExA
VkTDq6KwomuhrU3z0+r/MSOn
-----END PRIVATE KEY-----

271
docs/BUGSINK-SYNC.md Normal file
View File

@@ -0,0 +1,271 @@
# Bugsink to Gitea Issue Synchronization
This document describes the automated workflow for syncing Bugsink error tracking issues to Gitea tickets.
## Overview
The sync system automatically creates Gitea issues from unresolved Bugsink errors, ensuring all application errors are tracked and assignable.
**Key Points:**
- Runs **only on test/staging server** (not production)
- Syncs **all 6 Bugsink projects** (including production errors)
- Creates Gitea issues with full error context
- Marks synced issues as resolved in Bugsink
- Uses Redis db 15 for sync state tracking
## Architecture
```
TEST/STAGING SERVER
┌─────────────────────────────────────────────────┐
│ │
│ BullMQ Queue ──▶ Sync Worker ──▶ Redis DB 15 │
│ (bugsink-sync) (15min) (sync state) │
│ │ │
└──────────────────────┼───────────────────────────┘
┌─────────────┴─────────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Bugsink │ │ Gitea │
│ (read) │ │ (write) │
└─────────┘ └─────────┘
```
## Bugsink Projects
| Project Slug | Type | Environment | Label Mapping |
| --------------------------------- | -------- | ----------- | ----------------------------------- |
| flyer-crawler-backend | Backend | Production | bug:backend + env:production |
| flyer-crawler-backend-test | Backend | Test | bug:backend + env:test |
| flyer-crawler-frontend | Frontend | Production | bug:frontend + env:production |
| flyer-crawler-frontend-test | Frontend | Test | bug:frontend + env:test |
| flyer-crawler-infrastructure | Infra | Production | bug:infrastructure + env:production |
| flyer-crawler-test-infrastructure | Infra | Test | bug:infrastructure + env:test |
## Gitea Labels
| Label | Color | ID |
| ------------------ | ------------------ | --- |
| bug:frontend | #e11d48 (Red) | 8 |
| bug:backend | #ea580c (Orange) | 9 |
| bug:infrastructure | #7c3aed (Purple) | 10 |
| env:production | #dc2626 (Dark Red) | 11 |
| env:test | #2563eb (Blue) | 12 |
| env:development | #6b7280 (Gray) | 13 |
| source:bugsink | #10b981 (Green) | 14 |
## Environment Variables
Add these to **test environment only** (`deploy-to-test.yml`):
```bash
# Bugsink API
BUGSINK_URL=https://bugsink.projectium.com
BUGSINK_API_TOKEN=<from Bugsink Settings > API Keys>
# Gitea API
GITEA_URL=https://gitea.projectium.com
GITEA_API_TOKEN=<personal access token with repo scope>
GITEA_OWNER=torbo
GITEA_REPO=flyer-crawler.projectium.com
# Sync Control
BUGSINK_SYNC_ENABLED=true # Only set true in test env
BUGSINK_SYNC_INTERVAL=15 # Minutes between sync runs
```
## Gitea Secrets to Add
Add these secrets in Gitea repository settings (Settings > Secrets):
| Secret Name | Value | Environment |
| ---------------------- | ---------------------- | ----------- |
| `BUGSINK_API_TOKEN` | API token from Bugsink | Test only |
| `GITEA_SYNC_TOKEN` | Personal access token | Test only |
| `BUGSINK_SYNC_ENABLED` | `true` | Test only |
## Redis Configuration
| Database | Purpose |
| -------- | ------------------------ |
| 0 | BullMQ production queues |
| 1 | BullMQ test queues |
| 15 | Bugsink sync state |
**Key Pattern:**
```
bugsink:synced:{issue_uuid}
```
**Value (JSON):**
```json
{
"gitea_issue_number": 42,
"synced_at": "2026-01-17T10:30:00Z",
"project": "flyer-crawler-frontend-test",
"title": "[TypeError] t.map is not a function"
}
```
## Sync Workflow
1. **Trigger**: Every 15 minutes (or manual via admin API)
2. **Fetch**: List unresolved issues from all 6 Bugsink projects
3. **Check**: Skip issues already in Redis sync state
4. **Create**: Create Gitea issue with labels and full context
5. **Record**: Store sync mapping in Redis db 15
6. **Resolve**: Mark issue as resolved in Bugsink
## Issue Template
Created Gitea issues follow this format:
```markdown
## Error Details
| Field | Value |
| ------------ | ----------------------- |
| **Type** | TypeError |
| **Message** | t.map is not a function |
| **Platform** | javascript |
| **Level** | error |
## Occurrence Statistics
- **First Seen**: 2026-01-13 18:24:22 UTC
- **Last Seen**: 2026-01-16 05:03:02 UTC
- **Total Occurrences**: 4
## Request Context
- **URL**: GET https://flyer-crawler-test.projectium.com/
## Stacktrace
<details>
<summary>Click to expand</summary>
[Full stacktrace]
</details>
---
**Bugsink Issue**: https://bugsink.projectium.com/issues/{id}
**Project**: flyer-crawler-frontend-test
```
## Admin Endpoints
### Manual Sync Trigger
```bash
POST /api/admin/bugsink/sync
Authorization: Bearer <admin_jwt>
# Response
{
"success": true,
"data": {
"synced": 3,
"skipped": 12,
"failed": 0,
"duration_ms": 2340
}
}
```
### Sync Status
```bash
GET /api/admin/bugsink/sync/status
Authorization: Bearer <admin_jwt>
# Response
{
"success": true,
"data": {
"enabled": true,
"last_run": "2026-01-17T10:30:00Z",
"next_run": "2026-01-17T10:45:00Z",
"total_synced": 47
}
}
```
## Files to Create
| File | Purpose |
| -------------------------------------- | --------------------- |
| `src/services/bugsinkSync.server.ts` | Core sync logic |
| `src/services/bugsinkClient.server.ts` | Bugsink HTTP client |
| `src/services/giteaClient.server.ts` | Gitea HTTP client |
| `src/types/bugsink.ts` | TypeScript interfaces |
| `src/routes/admin/bugsink-sync.ts` | Admin endpoints |
## Files to Modify
| File | Changes |
| ------------------------------------- | ------------------------- |
| `src/services/queues.server.ts` | Add `bugsinkSyncQueue` |
| `src/services/workers.server.ts` | Add sync worker |
| `src/config/env.ts` | Add bugsink config schema |
| `.env.example` | Document new variables |
| `.gitea/workflows/deploy-to-test.yml` | Pass secrets |
## Implementation Phases
### Phase 1: Core Infrastructure
- [ ] Add env vars to `env.ts` schema
- [ ] Create BugsinkClient service
- [ ] Create GiteaClient service
- [ ] Add Redis db 15 connection
### Phase 2: Sync Logic
- [ ] Create BugsinkSyncService
- [ ] Add bugsink-sync queue
- [ ] Add sync worker
- [ ] Create TypeScript types
### Phase 3: Integration
- [ ] Add admin endpoints
- [ ] Update deploy-to-test.yml
- [ ] Add Gitea secrets
- [ ] End-to-end testing
## Troubleshooting
### Sync not running
1. Check `BUGSINK_SYNC_ENABLED` is `true`
2. Verify worker is running: `GET /api/admin/workers/status`
3. Check Bull Board: `/api/admin/jobs`
### Duplicate issues created
1. Check Redis db 15 connectivity
2. Verify sync state keys exist: `redis-cli -n 15 KEYS "bugsink:*"`
### Issues not resolving in Bugsink
1. Verify `BUGSINK_API_TOKEN` has write permissions
2. Check worker logs for API errors
### Missing stacktrace in Gitea issue
1. Source maps may not be uploaded
2. Bugsink API may have returned partial data
3. Check worker logs for fetch errors
## Related Documentation
- [ADR-054: Bugsink-Gitea Sync](./adr/0054-bugsink-gitea-issue-sync.md)
- [ADR-006: Background Job Processing](./adr/0006-background-job-processing-and-task-queues.md)
- [ADR-015: Error Tracking](./adr/0015-application-performance-monitoring-and-error-tracking.md)

View File

@@ -0,0 +1,337 @@
# ADR-054: Bugsink to Gitea Issue Synchronization
**Date**: 2026-01-17
**Status**: Proposed
## Context
The application uses Bugsink (Sentry-compatible self-hosted error tracking) to capture runtime errors across 6 projects:
| Project | Type | Environment |
| --------------------------------- | -------------- | ------------ |
| flyer-crawler-backend | Backend | Production |
| flyer-crawler-backend-test | Backend | Test/Staging |
| flyer-crawler-frontend | Frontend | Production |
| flyer-crawler-frontend-test | Frontend | Test/Staging |
| flyer-crawler-infrastructure | Infrastructure | Production |
| flyer-crawler-test-infrastructure | Infrastructure | Test/Staging |
Currently, errors remain in Bugsink until manually reviewed. There is no automated workflow to:
1. Create trackable tickets for errors
2. Assign errors to developers
3. Track resolution progress
4. Prevent errors from being forgotten
## Decision
Implement an automated background worker that synchronizes unresolved Bugsink issues to Gitea as trackable tickets. The sync worker will:
1. **Run only on the test/staging server** (not production, not dev container)
2. **Poll all 6 Bugsink projects** for unresolved issues
3. **Create Gitea issues** with full error context
4. **Mark synced issues as resolved** in Bugsink (to prevent re-polling)
5. **Track sync state in Redis** to ensure idempotency
### Why Test/Staging Only?
- The sync worker is a background service that needs API tokens for both Bugsink and Gitea
- Running on test/staging provides a single sync point without duplicating infrastructure
- All 6 Bugsink projects (including production) are synced from this one worker
- Production server stays focused on serving users, not running sync jobs
## Architecture
### Component Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ TEST/STAGING SERVER │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ BullMQ Queue │───▶│ Sync Worker │───▶│ Redis DB 15 │ │
│ │ bugsink-sync │ │ (15min repeat) │ │ Sync State │ │
│ └──────────────────┘ └────────┬─────────┘ └───────────────┘ │
│ │ │
└───────────────────────────────────┼──────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Bugsink │ │ Gitea │
│ (6 projects) │ │ (1 repo) │
└──────────────┘ └──────────────┘
```
### Queue Configuration
| Setting | Value | Rationale |
| --------------- | ---------------------- | -------------------------------------------- |
| Queue Name | `bugsink-sync` | Follows existing naming pattern |
| Repeat Interval | 15 minutes | Balances responsiveness with API rate limits |
| Retry Attempts | 3 | Standard retry policy |
| Backoff | Exponential (30s base) | Handles temporary API failures |
| Concurrency | 1 | Serial processing prevents race conditions |
### Redis Database Allocation
| Database | Usage | Owner |
| -------- | ------------------- | --------------- |
| 0 | BullMQ (Production) | Existing queues |
| 1 | BullMQ (Test) | Existing queues |
| 2-14 | Reserved | Future use |
| 15 | Bugsink Sync State | This feature |
### Redis Key Schema
```
bugsink:synced:{bugsink_issue_id}
└─ Value: JSON {
gitea_issue_number: number,
synced_at: ISO timestamp,
project: string,
title: string
}
```
### Gitea Labels
The following labels have been created in `torbo/flyer-crawler.projectium.com`:
| Label | ID | Color | Purpose |
| -------------------- | --- | ------------------ | ---------------------------------- |
| `bug:frontend` | 8 | #e11d48 (Red) | Frontend JavaScript/React errors |
| `bug:backend` | 9 | #ea580c (Orange) | Backend Node.js/API errors |
| `bug:infrastructure` | 10 | #7c3aed (Purple) | Infrastructure errors (Redis, PM2) |
| `env:production` | 11 | #dc2626 (Dark Red) | Production environment |
| `env:test` | 12 | #2563eb (Blue) | Test/staging environment |
| `env:development` | 13 | #6b7280 (Gray) | Development environment |
| `source:bugsink` | 14 | #10b981 (Green) | Auto-synced from Bugsink |
### Label Mapping
| Bugsink Project | Bug Label | Env Label |
| --------------------------------- | ------------------ | -------------- |
| flyer-crawler-backend | bug:backend | env:production |
| flyer-crawler-backend-test | bug:backend | env:test |
| flyer-crawler-frontend | bug:frontend | env:production |
| flyer-crawler-frontend-test | bug:frontend | env:test |
| flyer-crawler-infrastructure | bug:infrastructure | env:production |
| flyer-crawler-test-infrastructure | bug:infrastructure | env:test |
All synced issues also receive the `source:bugsink` label.
## Implementation Details
### New Files
| File | Purpose |
| -------------------------------------- | ------------------------------------------- |
| `src/services/bugsinkSync.server.ts` | Core synchronization logic |
| `src/services/bugsinkClient.server.ts` | HTTP client for Bugsink API |
| `src/services/giteaClient.server.ts` | HTTP client for Gitea API |
| `src/types/bugsink.ts` | TypeScript interfaces for Bugsink responses |
| `src/routes/admin/bugsink-sync.ts` | Admin endpoints for manual trigger |
### Modified Files
| File | Changes |
| ------------------------------------- | ------------------------------------- |
| `src/services/queues.server.ts` | Add `bugsinkSyncQueue` definition |
| `src/services/workers.server.ts` | Add sync worker implementation |
| `src/config/env.ts` | Add bugsink sync configuration schema |
| `.env.example` | Document new environment variables |
| `.gitea/workflows/deploy-to-test.yml` | Pass sync-related secrets |
### Environment Variables
```bash
# Bugsink Configuration
BUGSINK_URL=https://bugsink.projectium.com
BUGSINK_API_TOKEN=77deaa5e... # From Bugsink Settings > API Keys
# Gitea Configuration
GITEA_URL=https://gitea.projectium.com
GITEA_API_TOKEN=... # Personal access token with repo scope
GITEA_OWNER=torbo
GITEA_REPO=flyer-crawler.projectium.com
# Sync Control
BUGSINK_SYNC_ENABLED=false # Set true only in test environment
BUGSINK_SYNC_INTERVAL=15 # Minutes between sync runs
```
### Gitea Issue Template
```markdown
## Error Details
| Field | Value |
| ------------ | --------------- |
| **Type** | {error_type} |
| **Message** | {error_message} |
| **Platform** | {platform} |
| **Level** | {level} |
## Occurrence Statistics
- **First Seen**: {first_seen}
- **Last Seen**: {last_seen}
- **Total Occurrences**: {count}
## Request Context
- **URL**: {request_url}
- **Additional Context**: {context}
## Stacktrace
<details>
<summary>Click to expand</summary>
{stacktrace}
</details>
---
**Bugsink Issue**: {bugsink_url}
**Project**: {project_slug}
**Trace ID**: {trace_id}
```
### Sync Workflow
```
1. Worker triggered (every 15 min or manual)
2. For each of 6 Bugsink projects:
a. List issues with status='unresolved'
b. For each issue:
i. Check Redis for existing sync record
ii. If already synced → skip
iii. Fetch issue details + stacktrace
iv. Create Gitea issue with labels
v. Store sync record in Redis
vi. Mark issue as 'resolved' in Bugsink
3. Log summary (synced: N, skipped: N, failed: N)
```
### Idempotency Guarantees
1. **Redis check before creation**: Prevents duplicate Gitea issues
2. **Atomic Redis write after Gitea create**: Ensures state consistency
3. **Query only unresolved issues**: Resolved issues won't appear in polls
4. **No TTL on Redis keys**: Permanent sync history
## Consequences
### Positive
1. **Visibility**: All application errors become trackable tickets
2. **Accountability**: Errors can be assigned to developers
3. **History**: Complete audit trail of when errors were discovered and resolved
4. **Integration**: Errors appear alongside feature work in Gitea
5. **Automation**: No manual error triage required
### Negative
1. **API Dependencies**: Requires both Bugsink and Gitea APIs to be available
2. **Token Management**: Additional secrets to manage in CI/CD
3. **Potential Noise**: High-frequency errors could create many tickets (mitigated by Bugsink's issue grouping)
4. **Single Point**: Sync only runs on test server (if test server is down, no sync occurs)
### Risks & Mitigations
| Risk | Mitigation |
| ----------------------- | ------------------------------------------------- |
| Bugsink API rate limits | 15-minute polling interval |
| Gitea API rate limits | Sequential processing with delays |
| Redis connection issues | Reuse existing connection patterns |
| Duplicate issues | Redis tracking + idempotent checks |
| Missing stacktrace | Graceful degradation (create issue without trace) |
## Admin Interface
### Manual Sync Endpoint
```
POST /api/admin/bugsink/sync
Authorization: Bearer {admin_jwt}
Response:
{
"success": true,
"data": {
"synced": 3,
"skipped": 12,
"failed": 0,
"duration_ms": 2340
}
}
```
### Sync Status Endpoint
```
GET /api/admin/bugsink/sync/status
Authorization: Bearer {admin_jwt}
Response:
{
"success": true,
"data": {
"enabled": true,
"last_run": "2026-01-17T10:30:00Z",
"next_run": "2026-01-17T10:45:00Z",
"total_synced": 47,
"projects": [
{ "slug": "flyer-crawler-backend", "synced_count": 12 },
...
]
}
}
```
## Implementation Phases
### Phase 1: Core Infrastructure
- Add environment variables to `env.ts` schema
- Create `BugsinkClient` service (HTTP client)
- Create `GiteaClient` service (HTTP client)
- Add Redis db 15 connection for sync tracking
### Phase 2: Sync Logic
- Create `BugsinkSyncService` with sync logic
- Add `bugsink-sync` queue to `queues.server.ts`
- Add sync worker to `workers.server.ts`
- Create TypeScript types for API responses
### Phase 3: Integration
- Add admin endpoints for manual sync trigger
- Update `deploy-to-test.yml` with new secrets
- Add secrets to Gitea repository settings
- Test end-to-end in staging environment
### Phase 4: Documentation
- Update CLAUDE.md with sync information
- Create operational runbook for sync issues
## Future Enhancements
1. **Bi-directional sync**: Update Bugsink when Gitea issue is closed
2. **Smart deduplication**: Detect similar errors across projects
3. **Priority mapping**: High occurrence count → high priority label
4. **Slack/Discord notifications**: Alert on new critical errors
5. **Metrics dashboard**: Track error trends over time
## References
- [ADR-006: Background Job Processing](./0006-background-job-processing-and-task-queues.md)
- [ADR-015: Application Performance Monitoring](./0015-application-performance-monitoring-and-error-tracking.md)
- [Bugsink API Documentation](https://bugsink.com/docs/api/)
- [Gitea API Documentation](https://docs.gitea.io/en-us/api-usage/)

View File

@@ -0,0 +1,349 @@
# Frontend Test Automation Plan
**Date**: 2026-01-18
**Status**: Awaiting Approval
**Related**: [2026-01-18-frontend-tests.md](../tests/2026-01-18-frontend-tests.md)
## Executive Summary
This plan formalizes the automated testing of 35+ API endpoints manually tested on 2026-01-18. The testing covered 7 major areas including end-to-end user flows, edge cases, queue behavior, authentication, performance, real-time features, and data integrity.
**Recommendation**: Most tests should be added as **integration tests** (Supertest-based), with select critical flows as **E2E tests**. This aligns with ADR-010 and ADR-040's guidance on testing economics.
---
## Analysis of Manual Tests vs Existing Coverage
### Current Test Coverage
| Test Type | Existing Files | Existing Tests |
| ----------- | -------------- | -------------- |
| Integration | 21 files | ~150+ tests |
| E2E | 9 files | ~40+ tests |
### Gap Analysis
| Manual Test Area | Existing Coverage | Gap | Priority |
| -------------------------- | ------------------------- | --------------------------- | -------- |
| Budget API | budget.integration.test | Partial - add validation | Medium |
| Deals API | None | **New file needed** | Low |
| Reactions API | None | **New file needed** | Low |
| Gamification API | gamification.integration | Good coverage | None |
| Recipe API | recipe.integration.test | Add fork error, comment | Medium |
| Receipt API | receipt.integration.test | Good coverage | None |
| UPC API | upc.integration.test | Good coverage | None |
| Price History API | price.integration.test | Good coverage | None |
| Personalization API | public.routes.integration | Good coverage | None |
| Admin Routes | admin.integration.test | Add queue/trigger endpoints | Medium |
| Edge Cases (Area 2) | Scattered | **Consolidate/add** | High |
| Queue/Worker (Area 3) | Partial | Add admin trigger tests | Medium |
| Auth Edge Cases (Area 4) | auth.integration.test | Add token malformation | Medium |
| Performance (Area 5) | None | **Not recommended** | Skip |
| Real-time/Polling (Area 6) | notification.integration | Add job status polling | Low |
| Data Integrity (Area 7) | Scattered | **Consolidate** | High |
---
## Implementation Plan
### Phase 1: New Integration Test Files (Priority: High)
#### 1.1 Create `deals.integration.test.ts`
**Rationale**: Routes were unmounted until this testing session; no tests exist.
```typescript
// Tests to add:
describe('Deals API', () => {
it('GET /api/deals/best-watched-prices requires auth');
it('GET /api/deals/best-watched-prices returns watched items for user');
it('Returns empty array when no watched items');
});
```
**Estimated effort**: 30 minutes
#### 1.2 Create `reactions.integration.test.ts`
**Rationale**: Routes were unmounted until this testing session; no tests exist.
```typescript
// Tests to add:
describe('Reactions API', () => {
it('GET /api/reactions/summary/:targetType/:targetId returns counts');
it('POST /api/reactions/toggle requires auth');
it('POST /api/reactions/toggle toggles reaction on/off');
it('Returns validation error for invalid target_type');
it('Returns validation error for non-string entity_id');
});
```
**Estimated effort**: 45 minutes
#### 1.3 Create `edge-cases.integration.test.ts`
**Rationale**: Consolidate edge case tests discovered during manual testing.
```typescript
// Tests to add:
describe('Edge Cases', () => {
describe('File Upload Validation', () => {
it('Accepts small files');
it('Processes corrupt file with IMAGE_CONVERSION_FAILED');
it('Rejects wrong checksum format');
it('Rejects short checksum');
});
describe('Input Sanitization', () => {
it('Handles XSS payloads in shopping list names (stores as-is)');
it('Handles unicode/emoji in text fields');
it('Rejects null bytes in JSON');
it('Handles very long input strings');
});
describe('Authorization Boundaries', () => {
it('Cross-user access returns 404 (not 403)');
it('SQL injection in query params is safely handled');
});
});
```
**Estimated effort**: 1.5 hours
#### 1.4 Create `data-integrity.integration.test.ts`
**Rationale**: Consolidate FK/cascade/constraint tests.
```typescript
// Tests to add:
describe('Data Integrity', () => {
describe('Cascade Deletes', () => {
it('User deletion cascades to shopping lists, budgets, notifications');
it('Shopping list deletion cascades to items');
it('Admin cannot delete own account');
});
describe('FK Constraints', () => {
it('Rejects invalid FK references via API');
it('Rejects invalid FK references via direct DB');
});
describe('Unique Constraints', () => {
it('Duplicate email returns CONFLICT');
it('Duplicate flyer checksum is handled');
});
describe('CHECK Constraints', () => {
it('Budget period rejects invalid values');
it('Budget amount rejects negative values');
});
});
```
**Estimated effort**: 2 hours
---
### Phase 2: Extend Existing Integration Tests (Priority: Medium)
#### 2.1 Extend `budget.integration.test.ts`
Add validation edge cases discovered during manual testing:
```typescript
// Tests to add:
it('Rejects period="yearly" (only weekly/monthly allowed)');
it('Rejects negative amount_cents');
it('Rejects invalid date format');
it('Returns 404 for update on non-existent budget');
it('Returns 404 for delete on non-existent budget');
```
**Estimated effort**: 30 minutes
#### 2.2 Extend `admin.integration.test.ts`
Add queue and trigger endpoint tests:
```typescript
// Tests to add:
describe('Queue Management', () => {
it('GET /api/admin/queues/status returns all queue counts');
it('POST /api/admin/trigger/analytics-report enqueues job');
it('POST /api/admin/trigger/weekly-analytics enqueues job');
it('POST /api/admin/trigger/daily-deal-check enqueues job');
it('POST /api/admin/jobs/:queue/:id/retry retries failed job');
it('POST /api/admin/system/clear-cache clears Redis cache');
it('Returns validation error for invalid queue name');
it('Returns 404 for retry on non-existent job');
});
```
**Estimated effort**: 1 hour
#### 2.3 Extend `auth.integration.test.ts`
Add token malformation edge cases:
```typescript
// Tests to add:
describe('Token Edge Cases', () => {
it('Empty Bearer token returns Unauthorized');
it('Token without dots returns Unauthorized');
it('Token with 2 parts returns Unauthorized');
it('Token with invalid signature returns Unauthorized');
it('Lowercase "bearer" scheme is accepted');
it('Basic auth scheme returns Unauthorized');
it('Tampered token payload returns Unauthorized');
});
describe('Login Security', () => {
it('Wrong password and non-existent user return same error');
it('Forgot password returns same response for existing/non-existing');
});
```
**Estimated effort**: 45 minutes
#### 2.4 Extend `recipe.integration.test.ts`
Add fork error case and comment tests:
```typescript
// Tests to add:
it('Fork fails for seed recipes (null user_id)');
it('POST /api/recipes/:id/comments adds comment');
it('GET /api/recipes/:id/comments returns comments');
```
**Estimated effort**: 30 minutes
#### 2.5 Extend `notification.integration.test.ts`
Add job status polling tests:
```typescript
// Tests to add:
describe('Job Status Polling', () => {
it('GET /api/ai/jobs/:id/status returns completed job');
it('GET /api/ai/jobs/:id/status returns failed job with error');
it('GET /api/ai/jobs/:id/status returns 404 for non-existent');
it('Job status endpoint works without auth (public)');
});
```
**Estimated effort**: 30 minutes
---
### Phase 3: E2E Tests (Priority: Low-Medium)
Per ADR-040, E2E tests should be limited to critical user flows. The existing E2E tests cover the main flows well. However, we should consider:
#### 3.1 Do NOT Add
- Performance tests (handle via monitoring, not E2E)
- Pagination tests (integration level is sufficient)
- Cache behavior tests (integration level is sufficient)
#### 3.2 Consider Adding (Optional)
**Budget flow E2E** - If budget management becomes a critical feature:
```typescript
// budget-journey.e2e.test.ts
describe('Budget Journey', () => {
it('User creates budget → tracks spending → sees analysis');
});
```
**Recommendation**: Defer unless budget becomes a core value proposition.
---
### Phase 4: Documentation Updates
#### 4.1 Update ADR-010
Add the newly discovered API gotchas to the testing documentation:
- `entity_id` must be STRING in reactions
- `customItemName` (camelCase) in shopping list items
- `scan_source` must be `manual_entry`, not `manual`
#### 4.2 Update CLAUDE.md
Add API reference section for correct endpoint calls (already captured in test doc).
---
## Tests NOT Recommended
Per ADR-040 (Testing Economics), the following tests from the manual session should NOT be automated:
| Test Area | Reason |
| --------------------------- | ------------------------------------------------- |
| Performance benchmarks | Use APM/monitoring tools instead (see ADR-015) |
| Concurrent request handling | Connection pool behavior is framework-level |
| Cache hit/miss timing | Observable via Redis metrics, not test assertions |
| Response time consistency | Better suited for production monitoring |
| WebSocket/SSE | Not implemented - polling is the architecture |
---
## Implementation Timeline
| Phase | Description | Effort | Priority |
| --------- | ------------------------------ | ------------ | -------- |
| 1.1 | deals.integration.test.ts | 30 min | High |
| 1.2 | reactions.integration.test.ts | 45 min | High |
| 1.3 | edge-cases.integration.test.ts | 1.5 hours | High |
| 1.4 | data-integrity.integration.ts | 2 hours | High |
| 2.1 | Extend budget tests | 30 min | Medium |
| 2.2 | Extend admin tests | 1 hour | Medium |
| 2.3 | Extend auth tests | 45 min | Medium |
| 2.4 | Extend recipe tests | 30 min | Medium |
| 2.5 | Extend notification tests | 30 min | Medium |
| 4.x | Documentation updates | 30 min | Low |
| **Total** | | **~8 hours** | |
---
## Verification Strategy
For each new test file, verify by running:
```bash
# In dev container
npm run test:integration -- --run src/tests/integration/<file>.test.ts
```
All tests should:
1. Pass consistently (no flaky tests)
2. Run in isolation (no shared state)
3. Clean up test data (use `cleanupDb()`)
4. Follow existing patterns in the codebase
---
## Risks and Mitigations
| Risk | Mitigation |
| ------------------------------------ | --------------------------------------------------- |
| Test flakiness from async operations | Use proper waitFor/polling utilities |
| Database state leakage between tests | Strict cleanup in afterEach/afterAll |
| Queue state affecting test isolation | Drain/pause queues in tests that interact with them |
| Port conflicts | Use dedicated test port (3099) |
---
## Approval Request
Please review and approve this plan. Upon approval, implementation will proceed in priority order (Phase 1 first).
**Questions for clarification**:
1. Should the deals/reactions routes remain mounted, or was that a temporary fix?
2. Is the recipe fork failure for seed recipes expected behavior or a bug to fix?
3. Any preference on splitting Phase 1 into multiple PRs vs one large PR?

File diff suppressed because it is too large Load Diff

215
package-lock.json generated
View File

@@ -1,19 +1,18 @@
{
"name": "flyer-crawler",
"version": "0.11.2",
"version": "0.11.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.11.2",
"version": "0.11.7",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",
"@google/genai": "^1.30.0",
"@sentry/node": "^10.32.1",
"@sentry/react": "^10.32.1",
"@sentry/vite-plugin": "^3.3.1",
"@tanstack/react-query": "^5.90.12",
"@types/connect-timeout": "^1.9.0",
"bcrypt": "^5.1.1",
@@ -56,6 +55,7 @@
"zxing-wasm": "^2.2.4"
},
"devDependencies": {
"@sentry/vite-plugin": "^4.6.2",
"@tailwindcss/postcss": "4.1.17",
"@tanstack/react-query-devtools": "^5.91.2",
"@testcontainers/postgresql": "^11.8.1",
@@ -1012,6 +1012,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -1026,6 +1027,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1035,8 +1037,8 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1066,6 +1068,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1075,6 +1078,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
@@ -1091,6 +1095,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
@@ -1107,6 +1112,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1116,6 +1122,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1125,6 +1132,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -1138,6 +1146,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
@@ -1165,6 +1174,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1174,6 +1184,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1183,6 +1194,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1192,6 +1204,7 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
@@ -1205,6 +1218,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
@@ -1262,6 +1276,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -1276,6 +1291,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -1294,6 +1310,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -1349,7 +1366,6 @@
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.15.0.tgz",
"integrity": "sha512-bb/j6VMq2cfPoE/ZiUO7AcYTL0IjtxvKxkYV0zu+i1pc+JEv3ct4BItCII57knJR/YjZKGmdfr079KJFvzXC5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@bull-board/api": "6.15.0"
}
@@ -1442,7 +1458,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1486,7 +1501,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3050,6 +3064,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3060,6 +3075,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -3070,6 +3086,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -3079,12 +3096,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -3576,7 +3595,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -3598,7 +3616,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.3.0.tgz",
"integrity": "sha512-hGcsT0qDP7Il1L+qT3JFpiGl1dCjF794Bb4yCRCYdr7XC0NwHtOF3ngF86Gk6TUnsakbyQsDQ0E/S4CU0F4d4g==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
@@ -3611,7 +3628,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.3.0.tgz",
"integrity": "sha512-PcmxJQzs31cfD0R2dE91YGFcLxOSN4Bxz7gez5UwSUjCai8BwH/GI5HchfVshHkWdTkUs0qcaPJgVHKXUp7I3A==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -3627,7 +3643,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz",
"integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"import-in-the-middle": "^2.0.0",
@@ -4041,7 +4056,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.3.0.tgz",
"integrity": "sha512-shlr2l5g+87J8wqYlsLyaUsgKVRO7RtX70Ckd5CtDOWtImZgaUDmf4Z2ozuSKQLM2wPDR0TE/3bPVBNJtRm/cQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.3.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -4058,7 +4072,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.3.0.tgz",
"integrity": "sha512-B0TQ2e9h0ETjpI+eGmCz8Ojb+lnYms0SE3jFwEKrN/PK4aSVHU28AAmnOoBmfub+I3jfgPwvDJgomBA5a7QehQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.3.0",
"@opentelemetry/resources": "2.3.0",
@@ -4076,7 +4089,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz",
"integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=14"
}
@@ -4624,9 +4636,10 @@
}
},
"node_modules/@sentry/babel-plugin-component-annotate": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.6.1.tgz",
"integrity": "sha512-zmvUa4RpzDG3LQJFpGCE8lniz8Rk1Wa6ZvvK+yEH+snZeaHHRbSnAQBMR607GOClP+euGHNO2YtaY4UAdNTYbg==",
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.2.tgz",
"integrity": "sha512-6VTjLJXtIHKwxMmThtZKwi1+hdklLNzlbYH98NhbH22/Vzb/c6BlSD2b5A0NGN9vFB807rD4x4tuP+Su7BxQXQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -4649,17 +4662,18 @@
}
},
"node_modules/@sentry/bundler-plugin-core": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.6.1.tgz",
"integrity": "sha512-/ubWjPwgLep84sUPzHfKL2Ns9mK9aQrEX4aBFztru7ygiJidKJTxYGtvjh4dL2M1aZ0WRQYp+7PF6+VKwdZXcQ==",
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.2.tgz",
"integrity": "sha512-JkOc3JkVzi/fbXsFp8R9uxNKmBrPRaU4Yu4y1i3ihWfugqymsIYaN0ixLENZbGk2j4xGHIk20PAJzBJqBMTHew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "3.6.1",
"@sentry/cli": "^2.49.0",
"@sentry/babel-plugin-component-annotate": "4.6.2",
"@sentry/cli": "^2.57.0",
"dotenv": "^16.3.1",
"find-up": "^5.0.0",
"glob": "^9.3.2",
"glob": "^10.5.0",
"magic-string": "0.30.8",
"unplugin": "1.0.1"
},
@@ -4668,18 +4682,21 @@
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
"version": "9.3.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz",
"integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"minimatch": "^8.0.2",
"minipass": "^4.2.4",
"path-scurry": "^1.6.1"
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -4689,12 +4706,14 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@sentry/bundler-plugin-core/node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
"integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -4703,34 +4722,11 @@
"node": ">=12"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz",
"integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/minipass": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz",
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -4743,19 +4739,11 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/path-scurry/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/@sentry/cli": {
"version": "2.58.4",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.4.tgz",
"integrity": "sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==",
"dev": true,
"hasInstallScript": true,
"license": "FSL-1.1-MIT",
"dependencies": {
@@ -4786,6 +4774,7 @@
"version": "2.58.4",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz",
"integrity": "sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==",
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -4802,6 +4791,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -4820,6 +4810,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -4839,6 +4830,7 @@
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -4857,6 +4849,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -4875,6 +4868,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -4892,6 +4886,7 @@
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -4908,6 +4903,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
@@ -5033,12 +5029,13 @@
}
},
"node_modules/@sentry/vite-plugin": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.6.1.tgz",
"integrity": "sha512-x8WMdv2K2HcGS2ezEUIEZXpT/fNeWQ9rsEeF0K9DfKXK8Z9lzRmCr6TVA6I9+yW39Is+1/0cv1Rsu0LhO7lHzg==",
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-4.6.2.tgz",
"integrity": "sha512-hK9N50LlTaPlb2P1r87CFupU7MJjvtrp+Js96a2KDdiP8ViWnw4Gsa/OvA0pkj2wAFXFeBQMLS6g/SktTKG54w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "3.6.1",
"@sentry/bundler-plugin-core": "4.6.2",
"unplugin": "1.0.1"
},
"engines": {
@@ -5988,7 +5985,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.16"
},
@@ -6123,7 +6119,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -6347,7 +6344,6 @@
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -6601,7 +6597,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6612,7 +6607,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6789,7 +6783,6 @@
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
@@ -7144,7 +7137,6 @@
"integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.16",
"fflate": "^0.8.2",
@@ -7212,7 +7204,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7326,6 +7317,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -7339,6 +7331,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -7953,6 +7946,7 @@
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -8005,6 +7999,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8145,6 +8140,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -8157,6 +8153,7 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -8172,7 +8169,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8417,6 +8413,7 @@
"version": "1.0.30001761",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
"integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -8477,6 +8474,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -8501,6 +8499,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -8875,6 +8874,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -9062,8 +9062,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
@@ -9569,12 +9568,14 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -9637,6 +9638,7 @@
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -9947,6 +9949,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -9977,7 +9980,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10384,7 +10386,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -10656,6 +10657,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -10719,6 +10721,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
@@ -11177,6 +11180,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -11983,6 +11987,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -12076,6 +12081,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12130,6 +12136,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -12175,6 +12182,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -12676,6 +12684,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -12758,6 +12767,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -12800,6 +12810,7 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -13343,6 +13354,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
@@ -13584,6 +13596,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -13613,6 +13626,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -14235,6 +14249,7 @@
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
@@ -14265,6 +14280,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14786,6 +14802,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
@@ -14801,6 +14818,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
@@ -14995,6 +15013,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15088,7 +15107,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -15185,7 +15203,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15396,7 +15413,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15484,6 +15500,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -15499,6 +15516,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -15511,7 +15529,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/process": {
"version": "0.11.10",
@@ -15563,6 +15582,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -15678,6 +15698,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/pump": {
@@ -15783,7 +15804,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -15793,7 +15813,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -15830,7 +15849,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -15951,6 +15969,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -15963,6 +15982,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -16058,8 +16078,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -17854,6 +17873,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -17941,7 +17961,6 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -18105,7 +18124,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18192,6 +18210,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz",
"integrity": "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.8.1",
@@ -18214,6 +18233,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -18348,7 +18368,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -18424,7 +18443,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -18533,6 +18551,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz",
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -18542,6 +18561,7 @@
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz",
"integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==",
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-encoding": {
@@ -18861,6 +18881,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
@@ -18912,6 +18933,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -19000,7 +19022,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.11.2",
"version": "0.11.7",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",
@@ -33,7 +33,6 @@
"@google/genai": "^1.30.0",
"@sentry/node": "^10.32.1",
"@sentry/react": "^10.32.1",
"@sentry/vite-plugin": "^3.3.1",
"@tanstack/react-query": "^5.90.12",
"@types/connect-timeout": "^1.9.0",
"bcrypt": "^5.1.1",
@@ -76,6 +75,7 @@
"zxing-wasm": "^2.2.4"
},
"devDependencies": {
"@sentry/vite-plugin": "^4.6.2",
"@tailwindcss/postcss": "4.1.17",
"@tanstack/react-query-devtools": "^5.91.2",
"@testcontainers/postgresql": "^11.8.1",

View File

@@ -35,6 +35,8 @@ import healthRouter from './src/routes/health.routes';
import upcRouter from './src/routes/upc.routes';
import inventoryRouter from './src/routes/inventory.routes';
import receiptRouter from './src/routes/receipt.routes';
import dealsRouter from './src/routes/deals.routes';
import reactionsRouter from './src/routes/reactions.routes';
import { errorHandler } from './src/middleware/errorHandler';
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
import type { UserProfile } from './src/types';
@@ -278,9 +280,25 @@ app.use('/api/upc', upcRouter);
app.use('/api/inventory', inventoryRouter);
// 13. Receipt scanning routes.
app.use('/api/receipts', receiptRouter);
// 14. Deals and best prices routes.
app.use('/api/deals', dealsRouter);
// 15. Reactions/social features routes.
app.use('/api/reactions', reactionsRouter);
// --- Error Handling and Server Startup ---
// Catch-all 404 handler for unmatched routes.
// Returns JSON instead of HTML for API consistency.
app.use((req: Request, res: Response) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `Cannot ${req.method} ${req.path}`,
},
});
});
// Sentry Error Handler (ADR-015) - captures errors and sends to Bugsink.
// Must come BEFORE the custom error handler but AFTER all routes.
app.use(sentryMiddleware.errorHandler);

View File

@@ -3,15 +3,15 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Leaderboard from './Leaderboard';
import * as apiClient from '../services/apiClient';
import { LeaderboardUser } from '../types';
import { createMockLeaderboardUser } from '../tests/utils/mockFactories';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
import { useLeaderboardQuery } from '../hooks/queries/useLeaderboardQuery';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
// Mock the hook directly
vi.mock('../hooks/queries/useLeaderboardQuery');
const mockedApiClient = vi.mocked(apiClient);
const mockedUseLeaderboardQuery = vi.mocked(useLeaderboardQuery);
// Mock lucide-react icons to prevent rendering errors in the test environment
vi.mock('lucide-react', () => ({
@@ -36,29 +36,38 @@ const mockLeaderboardData: LeaderboardUser[] = [
describe('Leaderboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: loading state
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: true,
error: null,
} as any);
});
it('should display a loading message initially', () => {
// Mock a pending promise that never resolves to keep it in the loading state
mockedApiClient.fetchLeaderboard.mockReturnValue(new Promise(() => {}));
renderWithProviders(<Leaderboard />);
expect(screen.getByText('Loading Leaderboard...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(null, { status: 500 }));
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Request failed with status 500'),
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
// The query hook throws an error with the status code when JSON parsing fails
expect(screen.getByText('Error: Request failed with status 500')).toBeInTheDocument();
});
});
it('should display a generic error for unknown error types', async () => {
// Use an actual Error object since the component displays error.message
mockedApiClient.fetchLeaderboard.mockRejectedValue(new Error('A string error'));
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('A string error'),
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -68,7 +77,11 @@ describe('Leaderboard', () => {
});
it('should display a message when the leaderboard is empty', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -79,9 +92,11 @@ describe('Leaderboard', () => {
});
it('should render the leaderboard with user data on successful fetch', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
mockedUseLeaderboardQuery.mockReturnValue({
data: mockLeaderboardData,
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -104,9 +119,11 @@ describe('Leaderboard', () => {
});
it('should render the correct rank icons', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
mockedUseLeaderboardQuery.mockReturnValue({
data: mockLeaderboardData,
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -123,9 +140,11 @@ describe('Leaderboard', () => {
const dataWithMissingNames: LeaderboardUser[] = [
createMockLeaderboardUser({ user_id: 'user-anon', full_name: null, points: 500, rank: '5' }),
];
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(dataWithMissingNames)),
);
mockedUseLeaderboardQuery.mockReturnValue({
data: dataWithMissingNames,
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {

View File

@@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { PriceHistoryChart } from './PriceHistoryChart';
import { useUserData } from '../../hooks/useUserData';
import * as apiClient from '../../services/apiClient';
import { usePriceHistoryQuery } from '../../hooks/queries/usePriceHistoryQuery';
import type { MasterGroceryItem, HistoricalPriceDataPoint } from '../../types';
import {
createMockMasterGroceryItem,
@@ -12,13 +12,14 @@ import {
} from '../../tests/utils/mockFactories';
import { QueryWrapper } from '../../tests/utils/renderWithProviders';
// Mock the apiClient
vi.mock('../../services/apiClient');
// Mock the useUserData hook
vi.mock('../../hooks/useUserData');
const mockedUseUserData = useUserData as Mock;
// Mock the usePriceHistoryQuery hook
vi.mock('../../hooks/queries/usePriceHistoryQuery');
const mockedUsePriceHistoryQuery = usePriceHistoryQuery as Mock;
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
// Mock the logger
@@ -108,6 +109,13 @@ describe('PriceHistoryChart', () => {
isLoading: false,
error: null,
});
// Default mock for usePriceHistoryQuery (empty/loading false)
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
});
it('should render a placeholder when there are no watched items', () => {
@@ -126,13 +134,21 @@ describe('PriceHistoryChart', () => {
});
it('should display a loading state while fetching data', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {}));
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: true,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
expect(screen.getByText('Loading Price History...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('API is down'));
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('API is down'),
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -142,9 +158,11 @@ describe('PriceHistoryChart', () => {
});
it('should display a message if no historical data is returned', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify([])),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -157,14 +175,16 @@ describe('PriceHistoryChart', () => {
});
it('should render the chart with data on successful fetch', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: mockPriceHistory,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
// Check that the API was called with the correct item IDs
expect(apiClient.fetchHistoricalPriceData).toHaveBeenCalledWith([1, 2]);
// Check that the hook was called with the correct item IDs
expect(mockedUsePriceHistoryQuery).toHaveBeenCalledWith([1, 2], true);
// Check that the chart components are rendered
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
@@ -188,15 +208,17 @@ describe('PriceHistoryChart', () => {
isLoading: true, // Test the isLoading state from the useUserData hook
error: null,
});
vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {}));
// Even if price history is loading or not, user data loading takes precedence in UI
renderWithQuery(<PriceHistoryChart />);
expect(screen.getByText('Loading Price History...')).toBeInTheDocument();
});
it('should clear the chart when the watchlist becomes empty', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: mockPriceHistory,
isLoading: false,
error: null,
});
const { rerender } = renderWithQuery(<PriceHistoryChart />);
// Initial render with items
@@ -225,7 +247,7 @@ describe('PriceHistoryChart', () => {
});
it('should filter out items with only one data point', async () => {
const dataWithSinglePoint: HistoricalPriceDataPoint[] = [
const dataWithSinglePoint = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -242,9 +264,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 350,
}), // Almond Milk only has one point
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithSinglePoint)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithSinglePoint,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -254,7 +278,7 @@ describe('PriceHistoryChart', () => {
});
it('should process data to only keep the lowest price for a given day', async () => {
const dataWithDuplicateDate: HistoricalPriceDataPoint[] = [
const dataWithDuplicateDate = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -271,9 +295,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 99,
}),
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithDuplicateDate)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithDuplicateDate,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -288,7 +314,7 @@ describe('PriceHistoryChart', () => {
});
it('should filter out data points with a price of zero', async () => {
const dataWithZeroPrice: HistoricalPriceDataPoint[] = [
const dataWithZeroPrice = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -305,9 +331,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 105,
}),
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithZeroPrice)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithZeroPrice,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -330,9 +358,11 @@ describe('PriceHistoryChart', () => {
{ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: null }, // Missing price
{ master_item_id: 999, summary_date: '2024-10-01', avg_price_in_cents: 100 }, // ID not in watchlist
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(malformedData)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: malformedData,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -346,7 +376,7 @@ describe('PriceHistoryChart', () => {
});
it('should ignore higher prices for the same day', async () => {
const dataWithHigherPrice: HistoricalPriceDataPoint[] = [
const dataWithHigherPrice = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -363,9 +393,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 100,
}),
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithHigherPrice)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithHigherPrice,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -377,8 +409,11 @@ describe('PriceHistoryChart', () => {
});
it('should handle non-Error objects thrown during fetch', async () => {
// Use an actual Error object since the component displays error.message
vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('Fetch failed'));
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Fetch failed'),
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {

View File

@@ -34,8 +34,12 @@ export const useActivityLogQuery = (limit: number = 20, offset: number = 0) => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
// Activity log changes frequently, keep stale time short
staleTime: 1000 * 30, // 30 seconds

View File

@@ -32,8 +32,12 @@ export const useBestSalePricesQuery = (enabled: boolean = true) => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
enabled,
// Prices update when flyers change, keep fresh for 2 minutes

View File

@@ -28,8 +28,12 @@ export const useBrandsQuery = (enabled: boolean = true) => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
enabled,
staleTime: 1000 * 60 * 5, // 5 minutes - brands don't change frequently

View File

@@ -27,8 +27,12 @@ export const useCategoriesQuery = () => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
staleTime: 1000 * 60 * 60, // 1 hour - categories rarely change
});

View File

@@ -38,8 +38,12 @@ export const useFlyerItemsForFlyersQuery = (flyerIds: number[], enabled: boolean
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
enabled: enabled && flyerIds.length > 0,
// Flyer items don't change frequently once created

View File

@@ -117,9 +117,9 @@ describe('useFlyerItemsQuery', () => {
expect(result.current.data).toEqual([]);
});
it('should handle response without data property (fallback)', async () => {
// Edge case: API returns unexpected format without data property
// The hook falls back to returning the raw json object
it('should return empty array when response lacks success/data structure (ADR-028)', async () => {
// ADR-028: API must return { success: true, data: [...] }
// Non-compliant responses return empty array to prevent .map() errors
const legacyItems = [{ item_id: 1, name: 'Legacy Item' }];
mockedApiClient.fetchFlyerItems.mockResolvedValue({
ok: true,
@@ -130,7 +130,7 @@ describe('useFlyerItemsQuery', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Falls back to raw response when .data is undefined
expect(result.current.data).toEqual(legacyItems);
// Returns empty array when response doesn't match ADR-028 format
expect(result.current.data).toEqual([]);
});
});

View File

@@ -36,8 +36,12 @@ export const useFlyerItemsQuery = (flyerId: number | undefined) => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
// Only run the query if we have a valid flyer ID
enabled: !!flyerId,

View File

@@ -33,8 +33,12 @@ export const useFlyersQuery = (limit: number = 20, offset: number = 0) => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
// Keep data fresh for 2 minutes since flyers don't change frequently
staleTime: 1000 * 60 * 2,

View File

@@ -30,8 +30,12 @@ export const useLeaderboardQuery = (limit: number = 10, enabled: boolean = true)
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
enabled,
staleTime: 1000 * 60 * 2, // 2 minutes - leaderboard can change moderately

View File

@@ -32,8 +32,12 @@ export const useMasterItemsQuery = () => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
// Master items change infrequently, keep data fresh for 10 minutes
staleTime: 1000 * 60 * 10,

View File

@@ -35,8 +35,12 @@ export const usePriceHistoryQuery = (masterItemIds: number[], enabled: boolean =
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
enabled: enabled && masterItemIds.length > 0,
staleTime: 1000 * 60 * 10, // 10 minutes - historical data doesn't change frequently

View File

@@ -32,8 +32,12 @@ export const useShoppingListsQuery = (enabled: boolean) => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
enabled,
// Keep data fresh for 1 minute since users actively manage shopping lists

View File

@@ -27,8 +27,12 @@ export const useSuggestedCorrectionsQuery = () => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
staleTime: 1000 * 60, // 1 minute - corrections change moderately
});

View File

@@ -32,8 +32,12 @@ export const useWatchedItemsQuery = (enabled: boolean) => {
}
const json = await response.json();
// API returns { success: true, data: [...] }, extract the data array
return json.data ?? json;
// ADR-028: API returns { success: true, data: [...] }
// If success is false or data is not an array, return empty array to prevent .map() errors
if (!json.success || !Array.isArray(json.data)) {
return [];
}
return json.data;
},
enabled,
// Keep data fresh for 1 minute since users actively manage watched items

View File

@@ -1,8 +1,6 @@
// src/hooks/useActiveDeals.test.tsx
import { renderHook, waitFor, act } from '@testing-library/react';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useActiveDeals } from './useActiveDeals';
import * as apiClient from '../services/apiClient';
import type { Flyer, MasterGroceryItem, FlyerItem } from '../types';
import {
createMockFlyer,
@@ -12,9 +10,8 @@ import {
} from '../tests/utils/mockFactories';
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
import { useFlyerItemsForFlyersQuery } from './queries/useFlyerItemsForFlyersQuery';
import { useFlyerItemCountQuery } from './queries/useFlyerItemCountQuery';
// Mock the hooks to avoid Missing Context errors
vi.mock('./useFlyers', () => ({
@@ -25,7 +22,12 @@ vi.mock('../hooks/useUserData', () => ({
useUserData: () => mockUseUserData(),
}));
const mockedApiClient = vi.mocked(apiClient);
// Mock the query hooks
vi.mock('./queries/useFlyerItemsForFlyersQuery');
vi.mock('./queries/useFlyerItemCountQuery');
const mockedUseFlyerItemsForFlyersQuery = vi.mocked(useFlyerItemsForFlyersQuery);
const mockedUseFlyerItemCountQuery = vi.mocked(useFlyerItemCountQuery);
// Set a consistent "today" for testing flyer validity to make tests deterministic
const TODAY = new Date('2024-01-15T12:00:00.000Z');
@@ -33,9 +35,6 @@ const TODAY = new Date('2024-01-15T12:00:00.000Z');
describe('useActiveDeals Hook', () => {
// Use fake timers to control the current date in tests
beforeEach(() => {
// FIX: Only fake the 'Date' object.
// This allows `new Date()` to be mocked (via setSystemTime) while keeping
// `setTimeout`/`setInterval` native so `waitFor` doesn't hang.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(TODAY);
vi.clearAllMocks();
@@ -58,6 +57,18 @@ describe('useActiveDeals Hook', () => {
isLoading: false,
error: null,
});
// Default mocks for query hooks
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
mockedUseFlyerItemCountQuery.mockReturnValue({
data: { count: 0 },
isLoading: false,
error: null,
} as any);
});
afterEach(() => {
@@ -124,20 +135,18 @@ describe('useActiveDeals Hook', () => {
];
it('should return loading state initially and then calculated data', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(mockFlyerItems)),
);
mockedUseFlyerItemCountQuery.mockReturnValue({
data: { count: 10 },
isLoading: false,
error: null,
} as any);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: mockFlyerItems,
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// The hook runs the effect almost immediately. We shouldn't strictly assert false
// because depending on render timing, it might already be true.
// We mainly care that it eventually resolves.
// Wait for the hook's useEffect to run and complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(10);
@@ -147,25 +156,18 @@ describe('useActiveDeals Hook', () => {
});
it('should correctly filter for valid flyers and make API calls with their IDs', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 0 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
// Only the valid flyer (id: 1) should be used in the API calls
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
// The second argument is `enabled` which should be true
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([1], true);
expect(mockedUseFlyerItemsForFlyersQuery).toHaveBeenCalledWith([1], true);
expect(result.current.isLoading).toBe(false);
});
});
it('should not fetch flyer items if there are no watched items', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockUseUserData.mockReturnValue({
watchedItems: [],
shoppingLists: [],
@@ -173,16 +175,16 @@ describe('useActiveDeals Hook', () => {
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
}); // Override for this test
});
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(10);
expect(result.current.activeDeals).toEqual([]);
// The key assertion: fetchFlyerItemsForFlyers should not be called
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
// The enabled flag (2nd arg) should be false for items query
expect(mockedUseFlyerItemsForFlyersQuery).toHaveBeenCalledWith([1], false);
// Count query should still be enabled if there are valid flyers
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([1], true);
});
});
@@ -204,16 +206,20 @@ describe('useActiveDeals Hook', () => {
expect(result.current.totalActiveItems).toBe(0);
expect(result.current.activeDeals).toEqual([]);
// No API calls should be made if there are no valid flyers
expect(mockedApiClient.countFlyerItemsForFlyers).not.toHaveBeenCalled();
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
// API calls should be made with empty array, or enabled=false depending on implementation
// In useActiveDeals.tsx: validFlyerIds.length > 0 is the condition
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([], false);
expect(mockedUseFlyerItemsForFlyersQuery).toHaveBeenCalledWith([], false);
});
});
it('should set an error state if counting items fails', async () => {
const apiError = new Error('Network Failure');
mockedApiClient.countFlyerItemsForFlyers.mockRejectedValue(apiError);
// Also mock fetchFlyerItemsForFlyers to avoid interference from the other query
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseFlyerItemCountQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: apiError,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -225,17 +231,16 @@ describe('useActiveDeals Hook', () => {
it('should set an error state if fetching items fails', async () => {
const apiError = new Error('Item fetch failed');
// Mock the count to succeed but the item fetch to fail
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockRejectedValue(apiError);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: apiError,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
// This covers the `|| errorItems?.message` part of the error logic
expect(result.current.error).toBe(
'Could not fetch active deals or totals: Item fetch failed',
);
@@ -243,12 +248,16 @@ describe('useActiveDeals Hook', () => {
});
it('should correctly map flyer items to DealItem format', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(mockFlyerItems)),
);
mockedUseFlyerItemCountQuery.mockReturnValue({
data: { count: 10 },
isLoading: false,
error: null,
} as any);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: mockFlyerItems,
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -261,7 +270,7 @@ describe('useActiveDeals Hook', () => {
quantity: 'lb',
storeName: 'Valid Store',
master_item_name: 'Apples',
unit_price: null, // Expect null as the hook ensures undefined is converted to null
unit_price: null,
});
expect(deal).toEqual(expectedDeal);
});
@@ -276,7 +285,7 @@ describe('useActiveDeals Hook', () => {
valid_from: '2024-01-10',
valid_to: '2024-01-20',
});
(flyerWithoutStore as any).store = null; // Explicitly set to null
(flyerWithoutStore as any).store = null;
const itemInFlyerWithoutStore = createMockFlyerItem({
flyer_item_id: 3,
@@ -289,27 +298,21 @@ describe('useActiveDeals Hook', () => {
});
mockUseFlyers.mockReturnValue({ ...mockUseFlyers(), flyers: [flyerWithoutStore] });
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 1 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify([itemInFlyerWithoutStore])),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [itemInFlyerWithoutStore],
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.activeDeals).toHaveLength(1);
// This covers the `|| 'Unknown Store'` fallback logic
expect(result.current.activeDeals[0].storeName).toBe('Unknown Store');
});
});
it('should filter out items that do not match watched items or have no master ID', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 5 })),
);
const mixedItems: FlyerItem[] = [
// Watched item (Master ID 101 is in mockWatchedItems)
createMockFlyerItem({
@@ -345,9 +348,11 @@ describe('useActiveDeals Hook', () => {
}),
];
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(mixedItems)),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: mixedItems,
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -360,40 +365,18 @@ describe('useActiveDeals Hook', () => {
});
it('should return true for isLoading while API calls are pending', async () => {
// Create promises we can control
let resolveCount: (value: Response) => void;
const countPromise = new Promise<Response>((resolve) => {
resolveCount = resolve;
});
let resolveItems: (value: Response) => void;
const itemsPromise = new Promise<Response>((resolve) => {
resolveItems = resolve;
});
mockedApiClient.countFlyerItemsForFlyers.mockReturnValue(countPromise);
mockedApiClient.fetchFlyerItemsForFlyers.mockReturnValue(itemsPromise);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// Wait for the effect to trigger the API call and set loading to true
await waitFor(() => expect(result.current.isLoading).toBe(true));
// Resolve promises
await act(async () => {
resolveCount!(new Response(JSON.stringify({ count: 5 })));
resolveItems!(new Response(JSON.stringify([])));
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isLoading).toBe(true);
});
it('should re-filter active deals when watched items change (client-side filtering)', async () => {
// With TanStack Query, changing watchedItems does NOT trigger a new API call
// because the query key is based on flyerIds, not watchedItems.
// The filtering happens client-side via useMemo. This is more efficient.
const allFlyerItems: FlyerItem[] = [
createMockFlyerItem({
flyer_item_id: 1,
@@ -415,12 +398,11 @@ describe('useActiveDeals Hook', () => {
}),
];
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 2 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(allFlyerItems)),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: allFlyerItems,
isLoading: false,
error: null,
} as any);
const { result, rerender } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -433,9 +415,6 @@ describe('useActiveDeals Hook', () => {
expect(result.current.activeDeals).toHaveLength(1);
expect(result.current.activeDeals[0].item).toBe('Red Apples');
// API should have been called exactly once
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1);
// Now add Bread to watched items
const newWatchedItems = [
...mockWatchedItems,
@@ -462,9 +441,6 @@ describe('useActiveDeals Hook', () => {
const dealItems = result.current.activeDeals.map((d) => d.item);
expect(dealItems).toContain('Red Apples');
expect(dealItems).toContain('Fresh Bread');
// The API should NOT be called again - data is already cached
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1);
});
it('should include flyers valid exactly on the start or end date', async () => {
@@ -518,16 +494,10 @@ describe('useActiveDeals Hook', () => {
refetchFlyers: vi.fn(),
});
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 0 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
// Should call with IDs 10, 11, 12. Should NOT include 13.
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([10, 11, 12]);
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([10, 11, 12], true);
});
});
@@ -544,12 +514,11 @@ describe('useActiveDeals Hook', () => {
quantity: undefined,
});
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 1 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify([incompleteItem])),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [incompleteItem],
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });

View File

@@ -153,7 +153,7 @@ describe('useAuth Hook and AuthProvider', () => {
expect(result.current.userProfile).toBeNull();
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'[AuthProvider] Token was present but profile is null. Signing out.',
'[AuthProvider] Token was present but validation failed. Signing out.',
);
});

View File

@@ -1,17 +1,15 @@
// src/pages/MyDealsPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import MyDealsPage from './MyDealsPage';
import * as apiClient from '../services/apiClient';
import { useBestSalePricesQuery } from '../hooks/queries/useBestSalePricesQuery';
import type { WatchedItemDeal } from '../types';
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
vi.mock('../hooks/queries/useBestSalePricesQuery');
const mockedUseBestSalePricesQuery = useBestSalePricesQuery as Mock;
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
@@ -26,66 +24,65 @@ vi.mock('lucide-react', () => ({
describe('MyDealsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: loading false, empty data
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
});
it('should display a loading message initially', () => {
// Mock a pending promise
mockedApiClient.fetchBestSalePrices.mockReturnValue(new Promise(() => {}));
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: true,
error: null,
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Loading your deals...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue(
new Response(null, { status: 500, statusText: 'Server Error' }),
);
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
// The query hook throws an error with status code when JSON parsing fails on non-ok response
expect(screen.getByText('Request failed with status 500')).toBeInTheDocument();
it('should display an error message if the API call fails', () => {
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Request failed with status 500'),
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Request failed with status 500')).toBeInTheDocument();
});
it('should handle network errors and log them', async () => {
const networkError = new Error('Network connection failed');
mockedApiClient.fetchBestSalePrices.mockRejectedValue(networkError);
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Network connection failed')).toBeInTheDocument();
it('should handle network errors and log them', () => {
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Network connection failed'),
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Network connection failed')).toBeInTheDocument();
});
it('should handle unknown errors and log them', async () => {
// Mock a rejection with an Error object - TanStack Query passes through Error objects
mockedApiClient.fetchBestSalePrices.mockRejectedValue(new Error('Unknown failure'));
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Unknown failure')).toBeInTheDocument();
it('should handle unknown errors and log them', () => {
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Unknown failure'),
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Unknown failure')).toBeInTheDocument();
});
it('should display a message when no deals are found', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue(
new Response(JSON.stringify([]), {
headers: { 'Content-Type': 'application/json' },
}),
);
it('should display a message when no deals are found', () => {
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(
screen.getByText('No deals found for your watched items right now.'),
).toBeInTheDocument();
});
expect(
screen.getByText('No deals found for your watched items right now.'),
).toBeInTheDocument();
});
it('should render the list of deals on successful fetch', async () => {
it('should render the list of deals on successful fetch', () => {
const mockDeals: WatchedItemDeal[] = [
createMockWatchedItemDeal({
master_item_id: 1,
@@ -104,20 +101,18 @@ describe('MyDealsPage', () => {
valid_to: '2024-10-22',
}),
];
mockedApiClient.fetchBestSalePrices.mockResolvedValue(
new Response(JSON.stringify(mockDeals), {
headers: { 'Content-Type': 'application/json' },
}),
);
mockedUseBestSalePricesQuery.mockReturnValue({
data: mockDeals,
isLoading: false,
error: null,
});
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Organic Bananas')).toBeInTheDocument();
expect(screen.getByText('$0.99')).toBeInTheDocument();
expect(screen.getByText('Almond Milk')).toBeInTheDocument();
expect(screen.getByText('$3.49')).toBeInTheDocument();
expect(screen.getByText('Green Grocer')).toBeInTheDocument();
});
expect(screen.getByText('Organic Bananas')).toBeInTheDocument();
expect(screen.getByText('$0.99')).toBeInTheDocument();
expect(screen.getByText('Almond Milk')).toBeInTheDocument();
expect(screen.getByText('$3.49')).toBeInTheDocument();
expect(screen.getByText('Green Grocer')).toBeInTheDocument();
});
});

View File

@@ -11,20 +11,33 @@ import {
createMockUser,
} from '../tests/utils/mockFactories';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
import { useUserProfileData } from '../hooks/useUserProfileData';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
vi.mock('../hooks/useUserProfileData');
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
vi.mock('../services/notificationService', () => ({
notifySuccess: vi.fn(),
notifyError: vi.fn(),
}));
import { notifyError } from '../services/notificationService';
vi.mock('../components/AchievementsList', () => ({
AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => (
<div data-testid="achievements-list-mock">Achievements Count: {achievements.length}</div>
AchievementsList: ({
achievements,
}: {
achievements: (UserAchievement & Achievement)[] | null;
}) => (
<div data-testid="achievements-list-mock">Achievements Count: {achievements?.length || 0}</div>
),
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedUseUserProfileData = vi.mocked(useUserProfileData);
const mockedNotifyError = vi.mocked(notifyError);
// --- Mock Data ---
const mockProfile: UserProfile = createMockUserProfile({
@@ -47,206 +60,109 @@ const mockAchievements: (UserAchievement & Achievement)[] = [
}),
];
const mockSetProfile = vi.fn();
describe('UserProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation: Success state
mockedUseUserProfileData.mockReturnValue({
profile: mockProfile,
setProfile: mockSetProfile,
achievements: mockAchievements,
isLoading: false,
error: null,
});
});
// ... (Keep existing tests for loading message, error handling, rendering, etc.) ...
it('should display a loading message initially', () => {
mockedApiClient.getAuthenticatedUserProfile.mockReturnValue(new Promise(() => {}));
mockedApiClient.getUserAchievements.mockReturnValue(new Promise(() => {}));
mockedUseUserProfileData.mockReturnValue({
profile: null,
setProfile: mockSetProfile,
achievements: [],
isLoading: true,
error: null,
});
renderWithQuery(<UserProfilePage />);
expect(screen.getByText('Loading profile...')).toBeInTheDocument();
});
it('should display an error message if fetching profile fails', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Network Error'));
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Network Error')).toBeInTheDocument();
it('should display an error message if fetching profile fails', () => {
mockedUseUserProfileData.mockReturnValue({
profile: null,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: 'Network Error',
});
renderWithQuery(<UserProfilePage />);
expect(screen.getByText('Error: Network Error')).toBeInTheDocument();
});
it('should display an error message if fetching profile returns a non-ok response', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify({ message: 'Auth Failed' }), { status: 401 }),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
it('should render the profile and achievements on successful fetch', () => {
renderWithQuery(<UserProfilePage />);
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
expect(screen.getByText('150 Points')).toBeInTheDocument();
expect(screen.getByAltText('User Avatar')).toHaveAttribute('src', mockProfile.avatar_url);
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent('Achievements Count: 1');
});
await waitFor(() => {
// The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Auth Failed')).toBeInTheDocument();
it('should render a fallback message if profile is null after loading', () => {
mockedUseUserProfileData.mockReturnValue({
profile: null,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: null,
});
});
it('should display an error message if fetching achievements returns a non-ok response', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify({ message: 'Server Busy' }), { status: 503 }),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
// The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Server Busy')).toBeInTheDocument();
});
expect(screen.getByText('Could not load user profile.')).toBeInTheDocument();
});
it('should display an error message if fetching achievements fails', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockRejectedValue(new Error('Achievements service down'));
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Achievements service down')).toBeInTheDocument();
});
});
it('should handle unknown errors during fetch', async () => {
// Use an actual Error object since the hook extracts error.message
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Unknown error'));
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Unknown error')).toBeInTheDocument();
});
});
it('should handle null achievements data gracefully on fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
// Mock a successful response but with a null body for achievements
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
// The mock achievements list should show 0 achievements because the component
// should handle the null response and pass an empty array to the list.
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
'Achievements Count: 0',
);
});
});
it('should render the profile and achievements on successful fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
expect(screen.getByText('150 Points')).toBeInTheDocument();
expect(screen.getByAltText('User Avatar')).toHaveAttribute('src', mockProfile.avatar_url);
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
'Achievements Count: 1',
);
});
});
it('should render a fallback message if profile is null after loading', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(null)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
expect(await screen.findByText('Could not load user profile.')).toBeInTheDocument();
});
it('should display a fallback avatar if the user has no avatar_url', async () => {
// Create a mock profile with a null avatar_url and a specific name for the seed
it('should display a fallback avatar if the user has no avatar_url', () => {
const profileWithoutAvatar = { ...mockProfile, avatar_url: null, full_name: 'No Avatar User' };
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(profileWithoutAvatar)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseUserProfileData.mockReturnValue({
profile: profileWithoutAvatar,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: null,
});
renderWithQuery(<UserProfilePage />);
// Wait for the component to render with the fetched data
await waitFor(() => {
const avatarImage = screen.getByAltText('User Avatar');
// JSDOM might not URL-encode spaces in the src attribute in the same way a browser does.
// We adjust the expectation to match the literal string returned by getAttribute.
const expectedSrc = 'https://api.dicebear.com/8.x/initials/svg?seed=No Avatar User';
console.log('[TEST LOG] Actual Avatar Src:', avatarImage.getAttribute('src'));
expect(avatarImage).toHaveAttribute('src', expectedSrc);
});
const avatarImage = screen.getByAltText('User Avatar');
const expectedSrc = 'https://api.dicebear.com/8.x/initials/svg?seed=No Avatar User';
expect(avatarImage).toHaveAttribute('src', expectedSrc);
});
it('should use email for avatar seed if full_name is missing', async () => {
it('should use email for avatar seed if full_name is missing', () => {
const profileNoName = { ...mockProfile, full_name: null, avatar_url: null };
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(profileNoName)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
mockedUseUserProfileData.mockReturnValue({
profile: profileNoName,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: null,
});
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
const avatar = screen.getByAltText('User Avatar');
// seed should be the email
expect(avatar.getAttribute('src')).toContain(`seed=${profileNoName.user.email}`);
});
const avatar = screen.getByAltText('User Avatar');
expect(avatar.getAttribute('src')).toContain(`seed=${profileNoName.user.email}`);
});
it('should trigger file input click when avatar is clicked', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
it('should trigger file input click when avatar is clicked', () => {
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const clickSpy = vi.spyOn(fileInput, 'click');
const avatarContainer = screen.getByAltText('User Avatar');
fireEvent.click(avatarContainer);
expect(clickSpy).toHaveBeenCalled();
});
describe('Name Editing', () => {
beforeEach(() => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
});
it('should allow editing and saving the user name', async () => {
const updatedProfile = { ...mockProfile, full_name: 'Updated Name' };
mockedApiClient.updateUserProfile.mockResolvedValue(
@@ -254,8 +170,6 @@ describe('UserProfilePage', () => {
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'Updated Name' } });
@@ -265,17 +179,14 @@ describe('UserProfilePage', () => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith({
full_name: 'Updated Name',
});
expect(screen.getByRole('heading', { name: 'Updated Name' })).toBeInTheDocument();
expect(mockSetProfile).toHaveBeenCalled();
});
});
it('should allow canceling the name edit', async () => {
it('should allow canceling the name edit', () => {
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
});
@@ -285,7 +196,6 @@ describe('UserProfilePage', () => {
new Response(JSON.stringify({ message: 'Validation failed' }), { status: 400 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
@@ -293,136 +203,33 @@ describe('UserProfilePage', () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('Validation failed');
});
});
it('should show a default error if saving the name fails with a non-ok response and no message', async () => {
mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify({}), { status: 400 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'Invalid Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
// This covers the `|| 'Failed to update name.'` part of the error throw
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to update name.',
);
});
});
it('should handle non-ok response with null body when saving name', async () => {
// This tests the case where the server returns an error status but an empty/null body.
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
// The component should fall back to the default error message.
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to update name.',
);
});
});
it('should handle unknown errors when saving name', async () => {
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'An unknown error occurred.',
);
expect(mockedNotifyError).toHaveBeenCalledWith('Validation failed');
});
});
});
describe('Avatar Upload', () => {
beforeEach(() => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
});
it('should upload a new avatar and update the image source', async () => {
it('should upload a new avatar and update the profile', async () => {
const updatedProfile = { ...mockProfile, avatar_url: 'https://example.com/new-avatar.png' };
// Log when the mock is called
mockedApiClient.uploadAvatar.mockImplementation((file) => {
console.log('[TEST LOG] uploadAvatar mock called with:', file.name);
// Add a slight delay to ensure "isUploading" state can be observed
return new Promise((resolve) => {
setTimeout(() => {
console.log('[TEST LOG] uploadAvatar mock resolving...');
resolve(new Response(JSON.stringify(updatedProfile)));
}, 100);
});
});
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(JSON.stringify(updatedProfile)));
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
// Mock the hidden file input
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
console.log('[TEST LOG] Firing file change event...');
fireEvent.change(fileInput, { target: { files: [file] } });
// DEBUG: Print current DOM state if spinner is not found immediately
// const spinner = screen.queryByTestId('avatar-upload-spinner');
// if (!spinner) {
// console.log('[TEST LOG] Spinner NOT found immediately after event.');
// // screen.debug(); // Uncomment to see DOM
// } else {
// console.log('[TEST LOG] Spinner FOUND immediately.');
// }
// Wait for the spinner to appear
console.log('[TEST LOG] Waiting for spinner...');
await screen.findByTestId('avatar-upload-spinner');
console.log('[TEST LOG] Spinner found.');
// Wait for the upload to complete and the UI to update.
await waitFor(() => {
expect(mockedApiClient.uploadAvatar).toHaveBeenCalledWith(file);
expect(screen.getByAltText('User Avatar')).toHaveAttribute(
'src',
updatedProfile.avatar_url,
);
expect(screen.queryByTestId('avatar-upload-spinner')).not.toBeInTheDocument();
expect(mockSetProfile).toHaveBeenCalled();
});
});
it('should not attempt to upload if no file is selected', async () => {
it('should not attempt to upload if no file is selected', () => {
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
// Simulate user canceling the file dialog
fireEvent.change(fileInput, { target: { files: null } });
// Assert that no API call was made
expect(mockedApiClient.uploadAvatar).not.toHaveBeenCalled();
});
@@ -431,96 +238,13 @@ describe('UserProfilePage', () => {
new Response(JSON.stringify({ message: 'File too large' }), { status: 413 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'large.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('File too large');
});
});
it('should show a default error if avatar upload returns a non-ok response and no message', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(JSON.stringify({}), { status: 413 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'large.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
// This covers the `|| 'Failed to upload avatar.'` part of the error throw
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to upload avatar.',
);
});
});
it('should handle non-ok response with null body when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to upload avatar.',
);
});
});
it('should handle unknown errors when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'error.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'An unknown error occurred.',
);
});
});
it('should show an error if a non-image file is selected for upload', async () => {
// Mock the API client to return a non-OK response, simulating server-side validation failure
mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(
JSON.stringify({
message: 'Invalid file type. Only images (png, jpeg, gif) are allowed.',
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
// Create a mock file that is NOT an image (e.g., a PDF)
const nonImageFile = new File(['some text content'], 'document.pdf', {
type: 'application/pdf',
});
fireEvent.change(fileInput, { target: { files: [nonImageFile] } });
await waitFor(() => {
expect(mockedApiClient.uploadAvatar).toHaveBeenCalledWith(nonImageFile);
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Invalid file type. Only images (png, jpeg, gif) are allowed.',
);
expect(screen.queryByTestId('avatar-upload-spinner')).not.toBeInTheDocument();
expect(mockedNotifyError).toHaveBeenCalledWith('File too large');
});
});
});

View File

@@ -5,14 +5,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import toast from 'react-hot-toast';
import { AdminBrandManager } from './AdminBrandManager';
import * as apiClient from '../../../services/apiClient';
import { useBrandsQuery } from '../../../hooks/queries/useBrandsQuery';
import { createMockBrand } from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient
// Must explicitly call vi.mock() for apiClient and the hook
vi.mock('../../../services/apiClient');
vi.mock('../../../hooks/queries/useBrandsQuery');
const mockedApiClient = vi.mocked(apiClient);
const mockedUseBrandsQuery = vi.mocked(useBrandsQuery);
const mockedToast = vi.mocked(toast, true);
const mockBrands = [
createMockBrand({ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null }),
createMockBrand({
@@ -26,70 +30,66 @@ const mockBrands = [
describe('AdminBrandManager', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: loading false, empty data
mockedUseBrandsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
});
it('should render a loading state initially', () => {
console.log('TEST START: should render a loading state initially');
// Mock a promise that never resolves to keep the component in a loading state.
console.log('TEST SETUP: Mocking fetchAllBrands with a non-resolving promise.');
mockedApiClient.fetchAllBrands.mockReturnValue(new Promise(() => {}));
mockedUseBrandsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Checking for the loading text.');
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
console.log('TEST SUCCESS: Loading text is visible.');
console.log('TEST END: should render a loading state initially');
});
it('should render an error message if fetching brands fails', async () => {
console.log('TEST START: should render an error message if fetching brands fails');
const errorMessage = 'Network Error';
console.log(`TEST SETUP: Mocking fetchAllBrands to reject with: ${errorMessage}`);
mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error'));
mockedUseBrandsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Network Error'),
} as any);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for error message to be displayed.');
await waitFor(() => {
expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument();
console.log('TEST SUCCESS: Error message found in the document.');
});
console.log('TEST END: should render an error message if fetching brands fails');
});
it('should render the list of brands when data is fetched successfully', async () => {
console.log('TEST START: should render the list of brands when data is fetched successfully');
// Use mockImplementation to return a new Response object on each call,
// preventing "Body has already been read" errors.
console.log('TEST SETUP: Mocking fetchAllBrands to resolve with mockBrands.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for brand list to render.');
await waitFor(() => {
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
expect(screen.getByText('No Frills')).toBeInTheDocument();
expect(screen.getByText('(Sobeys)')).toBeInTheDocument();
expect(screen.getByAltText('Compliments logo')).toBeInTheDocument();
expect(screen.getByText('No Logo')).toBeInTheDocument();
console.log('TEST SUCCESS: All brand elements found in the document.');
});
console.log('TEST END: should render the list of brands when data is fetched successfully');
});
it('should handle successful logo upload', async () => {
console.log('TEST START: should handle successful logo upload');
console.log('TEST SETUP: Mocking fetchAllBrands and uploadBrandLogo for success.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () =>
new Response(JSON.stringify({ logoUrl: 'https://example.com/new-logo.png' }), {
@@ -98,41 +98,34 @@ describe('AdminBrandManager', () => {
);
mockedToast.loading.mockReturnValue('toast-1');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
// Use the new accessible label to find the correct input.
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event on input for "No Frills".');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for upload to complete and UI to update.');
await waitFor(() => {
expect(mockedApiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file);
expect(mockedToast.loading).toHaveBeenCalledWith('Uploading logo...');
expect(mockedToast.success).toHaveBeenCalledWith('Logo updated successfully!', {
id: 'toast-1',
});
// Check if the UI updates with the new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
'src',
'https://example.com/new-logo.png',
);
console.log('TEST SUCCESS: All assertions for successful upload passed.');
});
console.log('TEST END: should handle successful logo upload');
});
it('should handle failed logo upload with a non-Error object', async () => {
console.log('TEST START: should handle failed logo upload with a non-Error object');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
// Reject with a string instead of an Error object to test the fallback error handling
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockRejectedValue('A string error');
mockedToast.loading.mockReturnValue('toast-non-error');
@@ -145,104 +138,88 @@ describe('AdminBrandManager', () => {
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
// This assertion verifies that the `String(e)` part of the catch block is executed.
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: A string error', {
id: 'toast-non-error',
});
});
console.log('TEST END: should handle failed logo upload with a non-Error object');
});
it('should handle failed logo upload', async () => {
console.log('TEST START: should handle failed logo upload');
console.log('TEST SETUP: Mocking fetchAllBrands for success and uploadBrandLogo for failure.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockRejectedValue(new Error('Upload failed'));
mockedToast.loading.mockReturnValue('toast-2');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event on input for "No Frills".');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for error toast to be called.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Upload failed', {
id: 'toast-2',
});
console.log('TEST SUCCESS: Error toast was called with the correct message.');
});
console.log('TEST END: should handle failed logo upload');
});
it('should show an error toast for invalid file type', async () => {
console.log('TEST START: should show an error toast for invalid file type');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['text'], 'document.txt', { type: 'text/plain' });
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event with invalid file type.');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for validation error toast.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith(
'Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.',
);
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
console.log('TEST SUCCESS: Validation toast shown and upload API not called.');
});
console.log('TEST END: should show an error toast for invalid file type');
});
it('should show an error toast for oversized file', async () => {
console.log('TEST START: should show an error toast for oversized file');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['a'.repeat(3 * 1024 * 1024)], 'large.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event with oversized file.');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for size validation error toast.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.');
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
console.log('TEST SUCCESS: Size validation toast shown and upload API not called.');
});
console.log('TEST END: should show an error toast for oversized file');
});
it('should show an error toast if upload fails with a non-ok response', async () => {
console.log('TEST START: should handle non-ok response from upload API');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
// Mock a failed response (e.g., 400 Bad Request)
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockResolvedValue(
new Response('Invalid image format', { status: 400 }),
);
@@ -260,51 +237,49 @@ describe('AdminBrandManager', () => {
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Invalid image format', {
id: 'toast-3',
});
console.log('TEST SUCCESS: Error toast shown for non-ok response.');
});
console.log('TEST END: should handle non-ok response from upload API');
});
it('should show an error toast if no file is selected', async () => {
console.log('TEST START: should show an error toast if no file is selected');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const input = screen.getByLabelText('Upload logo for No Frills');
// Simulate canceling the file picker by firing a change event with an empty file list.
console.log('TEST ACTION: Firing file change event with an empty file list.');
fireEvent.change(input, { target: { files: [] } });
console.log('TEST ASSERTION: Waiting for the "no file selected" error toast.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Please select a file to upload.');
console.log('TEST SUCCESS: Error toast shown when no file is selected.');
});
console.log('TEST END: should show an error toast if no file is selected');
});
it('should render an empty table if no brands are found', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify([]), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
// Only the header row should be present
expect(screen.getAllByRole('row')).toHaveLength(1);
});
});
it('should use status code in error message if response body is empty on upload failure', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () => new Response(null, { status: 500, statusText: 'Internal Server Error' }),
);
@@ -326,9 +301,12 @@ describe('AdminBrandManager', () => {
});
it('should only update the target brand logo and leave others unchanged', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () => new Response(JSON.stringify({ logoUrl: 'new-logo.png' }), { status: 200 }),
);
@@ -337,17 +315,12 @@ describe('AdminBrandManager', () => {
renderWithProviders(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
// Brand 1: No Frills (initially null logo)
// Brand 2: Compliments (initially has logo)
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills'); // Brand 1
const input = screen.getByLabelText('Upload logo for No Frills');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
// Brand 1 should have new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'new-logo.png');
// Brand 2 should still have original logo
expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
'src',
'https://example.com/compliments.png',

View File

@@ -65,6 +65,13 @@ const activityLogSchema = z.object({
}),
});
const usersListSchema = z.object({
query: z.object({
limit: optionalNumeric({ integer: true, positive: true, max: 100 }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});
const jobRetrySchema = z.object({
params: z.object({
queueName: z.enum([
@@ -712,21 +719,35 @@ router.put(
* get:
* tags: [Admin]
* summary: Get all users
* description: Retrieve a list of all users. Requires admin role.
* description: Retrieve a list of all users with optional pagination. Requires admin role.
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* maximum: 100
* description: Maximum number of users to return. If omitted, returns all users.
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* description: Number of users to skip
* responses:
* 200:
* description: List of all users
* description: List of users with total count
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - admin role required
*/
router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
router.get('/users', validateRequest(usersListSchema), async (req, res, next: NextFunction) => {
try {
const users = await db.adminRepo.getAllUsers(req.log);
sendSuccess(res, users);
const { limit, offset } = usersListSchema.shape.query.parse(req.query);
const result = await db.adminRepo.getAllUsers(req.log, limit, offset);
sendSuccess(res, result);
} catch (error) {
req.log.error({ error }, 'Error fetching users');
next(error);
@@ -1298,6 +1319,43 @@ router.post(
},
);
/**
* @openapi
* /admin/trigger/token-cleanup:
* post:
* tags: [Admin]
* summary: Trigger token cleanup
* description: Manually trigger the expired token cleanup job. Requires admin role.
* security:
* - bearerAuth: []
* responses:
* 202:
* description: Job enqueued successfully
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - admin role required
*/
router.post(
'/trigger/token-cleanup',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
req.log.info(
`[Admin] Manual trigger for token cleanup received from user: ${userProfile.user.user_id}`,
);
try {
const jobId = await backgroundJobService.triggerTokenCleanup();
sendSuccess(res, { message: 'Successfully enqueued token cleanup job.', jobId }, 202);
} catch (error) {
req.log.error({ error }, 'Error enqueuing token cleanup job');
next(error);
}
},
);
/**
* @openapi
* /admin/system/clear-cache:

View File

@@ -122,10 +122,10 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
createMockAdminUserView({ user_id: '1', email: 'user1@test.com', role: 'user' }),
createMockAdminUserView({ user_id: '2', email: 'user2@test.com', role: 'admin' }),
];
vi.mocked(adminRepo.getAllUsers).mockResolvedValue(mockUsers);
vi.mocked(adminRepo.getAllUsers).mockResolvedValue({ users: mockUsers, total: 2 });
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockUsers);
expect(response.body.data).toEqual({ users: mockUsers, total: 2 });
expect(adminRepo.getAllUsers).toHaveBeenCalledTimes(1);
});

View File

@@ -158,7 +158,11 @@ const searchWebSchema = z.object({
body: z.object({ query: requiredString('A search query is required.') }),
});
const uploadToDisk = createUploadMiddleware({ storageType: 'flyer' });
const uploadToDisk = createUploadMiddleware({
storageType: 'flyer',
fileSize: 50 * 1024 * 1024, // 50MB limit for flyer uploads
fileFilter: 'image',
});
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
router.use((req: Request, res: Response, next: NextFunction) => {

View File

@@ -38,14 +38,17 @@ describe('Personalization Routes (/api/personalization)', () => {
describe('GET /master-items', () => {
it('should return a list of master items', async () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue({
items: mockItems,
total: 1,
});
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockItems);
expect(response.body.data).toEqual({ items: mockItems, total: 1 });
});
it('should return 500 if the database call fails', async () => {
@@ -113,7 +116,10 @@ describe('Personalization Routes (/api/personalization)', () => {
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /master-items', async () => {
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue({
items: [],
total: 0,
});
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('X-Test-Rate-Limit-Enable', 'true');

View File

@@ -5,6 +5,7 @@ import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { publicReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
import { optionalNumeric } from '../utils/zodUtils';
const router = Router();
@@ -13,16 +14,37 @@ const router = Router();
// to maintain a consistent validation pattern across the application.
const emptySchema = z.object({});
// Schema for master-items with optional pagination
const masterItemsSchema = z.object({
query: z.object({
limit: optionalNumeric({ integer: true, positive: true, max: 500 }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});
/**
* @openapi
* /personalization/master-items:
* get:
* tags: [Personalization]
* summary: Get master items list
* description: Get the master list of all grocery items. Response is cached for 1 hour.
* description: Get the master list of all grocery items with optional pagination. Response is cached for 1 hour.
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* maximum: 500
* description: Maximum number of items to return. If omitted, returns all items.
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* description: Number of items to skip
* responses:
* 200:
* description: List of all master grocery items
* description: List of master grocery items with total count
* content:
* application/json:
* schema:
@@ -31,17 +53,20 @@ const emptySchema = z.object({});
router.get(
'/master-items',
publicReadLimiter,
validateRequest(emptySchema),
validateRequest(masterItemsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Parse and apply defaults from schema
const { limit, offset } = masterItemsSchema.shape.query.parse(req.query);
// LOGGING: Track how often this heavy DB call is actually made vs served from cache
req.log.info('Fetching master items list from database...');
req.log.info({ limit, offset }, 'Fetching master items list from database...');
// Optimization: This list changes rarely. Instruct clients to cache it for 1 hour (3600s).
res.set('Cache-Control', 'public, max-age=3600');
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
sendSuccess(res, masterItems);
const result = await db.personalizationRepo.getAllMasterItems(req.log, limit, offset);
sendSuccess(res, result);
} catch (error) {
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
next(error);

View File

@@ -239,6 +239,50 @@ router.get(
},
);
/**
* @openapi
* /users/notifications/unread-count:
* get:
* tags: [Users]
* summary: Get unread notification count
* description: Get the count of unread notifications for the authenticated user. Optimized for navbar badge UI.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Unread notification count
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* count:
* type: integer
* example: 5
* 401:
* description: Unauthorized - invalid or missing token
*/
router.get(
'/notifications/unread-count',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userProfile = req.user as UserProfile;
const count = await db.notificationRepo.getUnreadCount(userProfile.user.user_id, req.log);
sendSuccess(res, { count });
} catch (error) {
req.log.error({ error }, 'Error fetching unread notification count');
next(error);
}
},
);
/**
* @openapi
* /users/notifications/mark-all-read:

View File

@@ -160,10 +160,11 @@ export class AIService {
this.logger = logger;
this.logger.info('---------------- [AIService] Constructor Start ----------------');
// Use mock AI in test and staging environments (no real API calls, no GEMINI_API_KEY needed)
// Use mock AI in test, staging, and development environments (no real API calls, no GEMINI_API_KEY needed)
const isTestEnvironment =
process.env.NODE_ENV === 'test' ||
process.env.NODE_ENV === 'staging' ||
process.env.NODE_ENV === 'development' ||
!!process.env.VITEST_POOL_ID;
if (aiClient) {

View File

@@ -8,7 +8,7 @@ import type { Notification, WatchedItemDeal } from '../types';
// Import types for repositories from their source files
import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
import { analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue } from './queueService.server';
type UserDealGroup = {
userProfile: { user_id: string; email: string; full_name: string | null };
@@ -54,6 +54,16 @@ export class BackgroundJobService {
return job.id;
}
public async triggerTokenCleanup(): Promise<string> {
const timestamp = new Date().toISOString();
const jobId = `manual-token-cleanup-${Date.now()}`;
const job = await tokenCleanupQueue.add('cleanup-tokens', { timestamp }, { jobId });
if (!job.id) {
throw new Error('Failed to enqueue token cleanup job: No job ID returned');
}
return job.id;
}
/**
* Prepares the data for an email notification job based on a user's deals.
* @param user The user to whom the email will be sent.
@@ -107,7 +117,10 @@ export class BackgroundJobService {
private async _processDealsForUser({
userProfile,
deals,
}: UserDealGroup): Promise<Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> | null> {
}: UserDealGroup): Promise<Omit<
Notification,
'notification_id' | 'is_read' | 'created_at' | 'updated_at'
> | null> {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,

View File

@@ -668,12 +668,17 @@ describe('Admin DB Service', () => {
const mockUsers: AdminUserView[] = [
createMockAdminUserView({ user_id: '1', email: 'test@test.com' }),
];
mockDb.query.mockResolvedValue({ rows: mockUsers });
// Mock count query
mockDb.query.mockResolvedValueOnce({ rows: [{ count: '1' }] });
// Mock users query
mockDb.query.mockResolvedValueOnce({ rows: mockUsers });
const result = await adminRepo.getAllUsers(mockLogger);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('FROM public.users u JOIN public.profiles p'),
undefined,
);
expect(result).toEqual(mockUsers);
expect(result).toEqual({ users: mockUsers, total: 1 });
});
it('should throw an error if the database query fails', async () => {

View File

@@ -627,14 +627,33 @@ export class AdminRepository {
}
}
async getAllUsers(logger: Logger): Promise<AdminUserView[]> {
async getAllUsers(
logger: Logger,
limit?: number,
offset?: number,
): Promise<{ users: AdminUserView[]; total: number }> {
try {
const query = `
// Get total count
const countRes = await this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.users');
const total = parseInt(countRes.rows[0].count, 10);
// Build query with optional pagination
let query = `
SELECT u.user_id, u.email, u.created_at, p.role, p.full_name, p.avatar_url
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC;
`;
const res = await this.db.query<AdminUserView>(query);
return res.rows;
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC`;
const params: number[] = [];
if (limit !== undefined) {
query += ` LIMIT $${params.length + 1}`;
params.push(limit);
}
if (offset !== undefined) {
query += ` OFFSET $${params.length + 1}`;
params.push(offset);
}
const res = await this.db.query<AdminUserView>(query, params.length > 0 ? params : undefined);
return { users: res.rows, total };
} catch (error) {
handleDbError(
error,

View File

@@ -34,10 +34,16 @@ export class NotificationRepository {
);
return res.rows[0];
} catch (error) {
handleDbError(error, logger, 'Database error in createNotification', { userId, content, linkUrl }, {
fkMessage: 'The specified user does not exist.',
defaultMessage: 'Failed to create notification.',
});
handleDbError(
error,
logger,
'Database error in createNotification',
{ userId, content, linkUrl },
{
fkMessage: 'The specified user does not exist.',
defaultMessage: 'Failed to create notification.',
},
);
}
}
@@ -74,10 +80,16 @@ export class NotificationRepository {
await this.db.query(query, [userIds, contents, linkUrls]);
} catch (error) {
handleDbError(error, logger, 'Database error in createBulkNotifications', { notifications }, {
fkMessage: 'One or more of the specified users do not exist.',
defaultMessage: 'Failed to create bulk notifications.',
});
handleDbError(
error,
logger,
'Database error in createBulkNotifications',
{ notifications },
{
fkMessage: 'One or more of the specified users do not exist.',
defaultMessage: 'Failed to create bulk notifications.',
},
);
}
}
@@ -118,6 +130,32 @@ export class NotificationRepository {
}
}
/**
* Gets the count of unread notifications for a specific user.
* This is optimized for the navbar badge UI.
* @param userId The ID of the user.
* @returns A promise that resolves to the count of unread notifications.
*/
async getUnreadCount(userId: string, logger: Logger): Promise<number> {
try {
const res = await this.db.query<{ count: string }>(
`SELECT COUNT(*) FROM public.notifications WHERE user_id = $1 AND is_read = false`,
[userId],
);
return parseInt(res.rows[0].count, 10);
} catch (error) {
handleDbError(
error,
logger,
'Database error in getUnreadCount',
{ userId },
{
defaultMessage: 'Failed to get unread notification count.',
},
);
}
}
/**
* Marks all unread notifications for a user as read.
* @param userId The ID of the user whose notifications should be marked as read.
@@ -130,9 +168,15 @@ export class NotificationRepository {
[userId],
);
} catch (error) {
handleDbError(error, logger, 'Database error in markAllNotificationsAsRead', { userId }, {
defaultMessage: 'Failed to mark notifications as read.',
});
handleDbError(
error,
logger,
'Database error in markAllNotificationsAsRead',
{ userId },
{
defaultMessage: 'Failed to mark notifications as read.',
},
);
}
}
@@ -183,9 +227,15 @@ export class NotificationRepository {
);
return res.rowCount ?? 0;
} catch (error) {
handleDbError(error, logger, 'Database error in deleteOldNotifications', { daysOld }, {
defaultMessage: 'Failed to delete old notifications.',
});
handleDbError(
error,
logger,
'Database error in deleteOldNotifications',
{ daysOld },
{
defaultMessage: 'Failed to delete old notifications.',
},
);
}
}
}

View File

@@ -5,7 +5,10 @@ import type { Pool, PoolClient } from 'pg';
import { withTransaction } from './connection.db';
import { PersonalizationRepository } from './personalization.db';
import type { MasterGroceryItem, UserAppliance, DietaryRestriction, Appliance } from '../../types';
import { createMockMasterGroceryItem, createMockUserAppliance } from '../../tests/utils/mockFactories';
import {
createMockMasterGroceryItem,
createMockUserAppliance,
} from '../../tests/utils/mockFactories';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./personalization.db');
@@ -50,7 +53,10 @@ describe('Personalization DB Service', () => {
const mockItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
];
mockQuery.mockResolvedValue({ rows: mockItems });
// Mock count query
mockQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] });
// Mock items query
mockQuery.mockResolvedValueOnce({ rows: mockItems });
const result = await personalizationRepo.getAllMasterItems(mockLogger);
@@ -64,14 +70,17 @@ describe('Personalization DB Service', () => {
// The query string in the implementation has a lot of whitespace from the template literal.
// This updated expectation matches the new query exactly.
expect(mockQuery).toHaveBeenCalledWith(expectedQuery);
expect(result).toEqual(mockItems);
expect(mockQuery).toHaveBeenCalledWith(expectedQuery, undefined);
expect(result).toEqual({ items: mockItems, total: 1 });
});
it('should return an empty array if no master items exist', async () => {
mockQuery.mockResolvedValue({ rows: [] });
// Mock count query
mockQuery.mockResolvedValueOnce({ rows: [{ count: '0' }] });
// Mock items query
mockQuery.mockResolvedValueOnce({ rows: [] });
const result = await personalizationRepo.getAllMasterItems(mockLogger);
expect(result).toEqual([]);
expect(result).toEqual({ items: [], total: 0 });
});
it('should throw an error if the database query fails', async () => {

View File

@@ -25,24 +25,58 @@ export class PersonalizationRepository {
}
/**
* Retrieves all master grocery items from the database.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
* Retrieves master grocery items from the database with optional pagination.
* @param logger The logger instance.
* @param limit Optional limit for pagination. If not provided, returns all items.
* @param offset Optional offset for pagination.
* @returns A promise that resolves to an object with items array and total count.
*/
async getAllMasterItems(logger: Logger): Promise<MasterGroceryItem[]> {
async getAllMasterItems(
logger: Logger,
limit?: number,
offset?: number,
): Promise<{ items: MasterGroceryItem[]; total: number }> {
try {
const query = `
// Get total count
const countRes = await this.db.query<{ count: string }>(
'SELECT COUNT(*) FROM public.master_grocery_items',
);
const total = parseInt(countRes.rows[0].count, 10);
// Build query with optional pagination
let query = `
SELECT
mgi.*,
c.name as category_name
FROM public.master_grocery_items mgi
LEFT JOIN public.categories c ON mgi.category_id = c.category_id
ORDER BY mgi.name ASC`;
const res = await this.db.query<MasterGroceryItem>(query);
return res.rows;
const params: number[] = [];
if (limit !== undefined) {
query += ` LIMIT $${params.length + 1}`;
params.push(limit);
}
if (offset !== undefined) {
query += ` OFFSET $${params.length + 1}`;
params.push(offset);
}
const res = await this.db.query<MasterGroceryItem>(
query,
params.length > 0 ? params : undefined,
);
return { items: res.rows, total };
} catch (error) {
handleDbError(error, logger, 'Database error in getAllMasterItems', {}, {
defaultMessage: 'Failed to retrieve master grocery items.',
});
handleDbError(
error,
logger,
'Database error in getAllMasterItems',
{},
{
defaultMessage: 'Failed to retrieve master grocery items.',
},
);
}
}
@@ -63,9 +97,15 @@ export class PersonalizationRepository {
const res = await this.db.query<MasterGroceryItem>(query, [userId]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getWatchedItems', { userId }, {
defaultMessage: 'Failed to retrieve watched items.',
});
handleDbError(
error,
logger,
'Database error in getWatchedItems',
{ userId },
{
defaultMessage: 'Failed to retrieve watched items.',
},
);
}
}
@@ -81,9 +121,15 @@ export class PersonalizationRepository {
[userId, masterItemId],
);
} catch (error) {
handleDbError(error, logger, 'Database error in removeWatchedItem', { userId, masterItemId }, {
defaultMessage: 'Failed to remove item from watchlist.',
});
handleDbError(
error,
logger,
'Database error in removeWatchedItem',
{ userId, masterItemId },
{
defaultMessage: 'Failed to remove item from watchlist.',
},
);
}
}
@@ -103,9 +149,15 @@ export class PersonalizationRepository {
);
return res.rows[0];
} catch (error) {
handleDbError(error, logger, 'Database error in findPantryItemOwner', { pantryItemId }, {
defaultMessage: 'Failed to retrieve pantry item owner from database.',
});
handleDbError(
error,
logger,
'Database error in findPantryItemOwner',
{ pantryItemId },
{
defaultMessage: 'Failed to retrieve pantry item owner from database.',
},
);
}
}
@@ -189,9 +241,15 @@ export class PersonalizationRepository {
>('SELECT * FROM public.get_best_sale_prices_for_all_users()');
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getBestSalePricesForAllUsers', {}, {
defaultMessage: 'Failed to get best sale prices for all users.',
});
handleDbError(
error,
logger,
'Database error in getBestSalePricesForAllUsers',
{},
{
defaultMessage: 'Failed to get best sale prices for all users.',
},
);
}
}
@@ -204,9 +262,15 @@ export class PersonalizationRepository {
const res = await this.db.query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getAppliances', {}, {
defaultMessage: 'Failed to get appliances.',
});
handleDbError(
error,
logger,
'Database error in getAppliances',
{},
{
defaultMessage: 'Failed to get appliances.',
},
);
}
}
@@ -221,9 +285,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getDietaryRestrictions', {}, {
defaultMessage: 'Failed to get dietary restrictions.',
});
handleDbError(
error,
logger,
'Database error in getDietaryRestrictions',
{},
{
defaultMessage: 'Failed to get dietary restrictions.',
},
);
}
}
@@ -242,9 +312,15 @@ export class PersonalizationRepository {
const res = await this.db.query<DietaryRestriction>(query, [userId]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getUserDietaryRestrictions', { userId }, {
defaultMessage: 'Failed to get user dietary restrictions.',
});
handleDbError(
error,
logger,
'Database error in getUserDietaryRestrictions',
{ userId },
{
defaultMessage: 'Failed to get user dietary restrictions.',
},
);
}
}
@@ -278,7 +354,10 @@ export class PersonalizationRepository {
logger,
'Database error in setUserDietaryRestrictions',
{ userId, restrictionIds },
{ fkMessage: 'One or more of the specified restriction IDs are invalid.', defaultMessage: 'Failed to set user dietary restrictions.' },
{
fkMessage: 'One or more of the specified restriction IDs are invalid.',
defaultMessage: 'Failed to set user dietary restrictions.',
},
);
}
}
@@ -309,10 +388,16 @@ export class PersonalizationRepository {
return newAppliances;
});
} catch (error) {
handleDbError(error, logger, 'Database error in setUserAppliances', { userId, applianceIds }, {
fkMessage: 'Invalid appliance ID',
defaultMessage: 'Failed to set user appliances.',
});
handleDbError(
error,
logger,
'Database error in setUserAppliances',
{ userId, applianceIds },
{
fkMessage: 'Invalid appliance ID',
defaultMessage: 'Failed to set user appliances.',
},
);
}
}
@@ -331,9 +416,15 @@ export class PersonalizationRepository {
const res = await this.db.query<Appliance>(query, [userId]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getUserAppliances', { userId }, {
defaultMessage: 'Failed to get user appliances.',
});
handleDbError(
error,
logger,
'Database error in getUserAppliances',
{ userId },
{
defaultMessage: 'Failed to get user appliances.',
},
);
}
}
@@ -350,9 +441,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in findRecipesFromPantry', { userId }, {
defaultMessage: 'Failed to find recipes from pantry.',
});
handleDbError(
error,
logger,
'Database error in findRecipesFromPantry',
{ userId },
{
defaultMessage: 'Failed to find recipes from pantry.',
},
);
}
}
@@ -374,9 +471,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in recommendRecipesForUser', { userId, limit }, {
defaultMessage: 'Failed to recommend recipes.',
});
handleDbError(
error,
logger,
'Database error in recommendRecipesForUser',
{ userId, limit },
{
defaultMessage: 'Failed to recommend recipes.',
},
);
}
}
@@ -393,9 +496,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getBestSalePricesForUser', { userId }, {
defaultMessage: 'Failed to get best sale prices.',
});
handleDbError(
error,
logger,
'Database error in getBestSalePricesForUser',
{ userId },
{
defaultMessage: 'Failed to get best sale prices.',
},
);
}
}
@@ -415,9 +524,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in suggestPantryItemConversions', { pantryItemId }, {
defaultMessage: 'Failed to suggest pantry item conversions.',
});
handleDbError(
error,
logger,
'Database error in suggestPantryItemConversions',
{ pantryItemId },
{
defaultMessage: 'Failed to suggest pantry item conversions.',
},
);
}
}
@@ -434,9 +549,15 @@ export class PersonalizationRepository {
); // This is a standalone function, no change needed here.
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getRecipesForUserDiets', { userId }, {
defaultMessage: 'Failed to get recipes compatible with user diet.',
});
handleDbError(
error,
logger,
'Database error in getRecipesForUserDiets',
{ userId },
{
defaultMessage: 'Failed to get recipes compatible with user diet.',
},
);
}
}
}

View File

@@ -37,7 +37,7 @@ describe('FlyerAiProcessor', () => {
extractCoreDataFromFlyerImage: vi.fn(),
} as unknown as AIService;
mockPersonalizationRepo = {
getAllMasterItems: vi.fn().mockResolvedValue([]),
getAllMasterItems: vi.fn().mockResolvedValue({ items: [], total: 0 }),
} as unknown as PersonalizationRepository;
service = new FlyerAiProcessor(mockAiService, mockPersonalizationRepo);
@@ -86,9 +86,9 @@ describe('FlyerAiProcessor', () => {
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
// Act & Assert
await expect(
service.extractAndValidateData(imagePaths, jobData, logger),
).rejects.toThrow(dbError);
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
dbError,
);
// Verify that the process stops before calling the AI service
expect(mockAiService.extractCoreDataFromFlyerImage).not.toHaveBeenCalled();
@@ -103,8 +103,20 @@ describe('FlyerAiProcessor', () => {
valid_to: '2024-01-07',
store_address: '123 Good St',
items: [
{ item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
{ item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' },
{
item: 'Priced Item 1',
price_in_cents: 199,
price_display: '$1.99',
quantity: '1',
category_name: 'A',
},
{
item: 'Priced Item 2',
price_in_cents: 299,
price_display: '$2.99',
quantity: '1',
category_name: 'B',
},
],
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
@@ -128,7 +140,9 @@ describe('FlyerAiProcessor', () => {
valid_to: null,
store_address: null,
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any);
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(
invalidResponse as any,
);
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
@@ -140,7 +154,15 @@ describe('FlyerAiProcessor', () => {
const jobData = createMockJobData({});
const mockAiResponse = {
store_name: null, // Missing store name
items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Grocery' }],
items: [
{
item: 'Test Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'each',
category_name: 'Grocery',
},
],
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: null,
@@ -187,9 +209,27 @@ describe('FlyerAiProcessor', () => {
valid_to: '2024-01-07',
store_address: '123 Test St',
items: [
{ item: 'Priced Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
{ item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'B' },
{ item: 'Unpriced Item 2', price_in_cents: null, price_display: 'FREE', quantity: '1', category_name: 'C' },
{
item: 'Priced Item',
price_in_cents: 199,
price_display: '$1.99',
quantity: '1',
category_name: 'A',
},
{
item: 'Unpriced Item 1',
price_in_cents: null,
price_display: 'See store',
quantity: '1',
category_name: 'B',
},
{
item: 'Unpriced Item 2',
price_in_cents: null,
price_display: 'FREE',
quantity: '1',
category_name: 'C',
},
], // 1/3 = 33% have price, which is < 50%
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
@@ -200,7 +240,9 @@ describe('FlyerAiProcessor', () => {
expect(result.needsReview).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ qualityIssues: ['Low price quality (33% of items have a price)'] }),
expect.objectContaining({
qualityIssues: ['Low price quality (33% of items have a price)'],
}),
expect.stringContaining('AI response has quality issues.'),
);
});
@@ -216,10 +258,34 @@ describe('FlyerAiProcessor', () => {
valid_to: '2024-01-07',
store_address: '123 Test St',
items: [
{ item: 'Priced Item 1', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' },
{ item: 'Priced Item 2', price_in_cents: 299, price_display: '$2.99', quantity: '1', category_name: 'B' },
{ item: 'Priced Item 3', price_in_cents: 399, price_display: '$3.99', quantity: '1', category_name: 'C' },
{ item: 'Unpriced Item 1', price_in_cents: null, price_display: 'See store', quantity: '1', category_name: 'D' },
{
item: 'Priced Item 1',
price_in_cents: 199,
price_display: '$1.99',
quantity: '1',
category_name: 'A',
},
{
item: 'Priced Item 2',
price_in_cents: 299,
price_display: '$2.99',
quantity: '1',
category_name: 'B',
},
{
item: 'Priced Item 3',
price_in_cents: 399,
price_display: '$3.99',
quantity: '1',
category_name: 'C',
},
{
item: 'Unpriced Item 1',
price_in_cents: null,
price_display: 'See store',
quantity: '1',
category_name: 'D',
},
], // 3/4 = 75% have price. This is > 50% (default) but < 80% (custom).
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
@@ -233,7 +299,9 @@ describe('FlyerAiProcessor', () => {
// Because 75% < 80%, it should be flagged for review.
expect(result.needsReview).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ qualityIssues: ['Low price quality (75% of items have a price)'] }),
expect.objectContaining({
qualityIssues: ['Low price quality (75% of items have a price)'],
}),
expect.stringContaining('AI response has quality issues.'),
);
});
@@ -243,9 +311,17 @@ describe('FlyerAiProcessor', () => {
const mockAiResponse = {
store_name: 'Test Store',
valid_from: null, // Missing date
valid_to: null, // Missing date
valid_to: null, // Missing date
store_address: '123 Test St',
items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }],
items: [
{
item: 'Test Item',
price_in_cents: 199,
price_display: '$1.99',
quantity: '1',
category_name: 'A',
},
],
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
const { logger } = await import('./logger.server');
@@ -264,7 +340,7 @@ describe('FlyerAiProcessor', () => {
const jobData = createMockJobData({});
const mockAiResponse = {
store_name: null, // Issue 1
items: [], // Issue 2
items: [], // Issue 2
valid_from: null, // Issue 3
valid_to: null,
store_address: null,
@@ -277,7 +353,14 @@ describe('FlyerAiProcessor', () => {
expect(result.needsReview).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
{ rawData: mockAiResponse, qualityIssues: ['Missing store name', 'No items were extracted', 'Missing both valid_from and valid_to dates'] },
{
rawData: mockAiResponse,
qualityIssues: [
'Missing store name',
'No items were extracted',
'Missing both valid_from and valid_to dates',
],
},
'AI response has quality issues. Flagging for review. Issues: Missing store name, No items were extracted, Missing both valid_from and valid_to dates',
);
});
@@ -291,7 +374,15 @@ describe('FlyerAiProcessor', () => {
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Test St',
items: [{ item: 'Test Item', price_in_cents: 199, price_display: '$1.99', quantity: '1', category_name: 'A' }],
items: [
{
item: 'Test Item',
price_in_cents: 199,
price_display: '$1.99',
quantity: '1',
category_name: 'A',
},
],
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
@@ -300,7 +391,11 @@ describe('FlyerAiProcessor', () => {
// Assert
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
imagePaths, [], undefined, '456 Fallback Ave', logger
imagePaths,
[],
undefined,
'456 Fallback Ave',
logger,
);
});
@@ -323,8 +418,22 @@ describe('FlyerAiProcessor', () => {
valid_to: '2025-01-07',
store_address: '123 Batch St',
items: [
{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 },
{ item: 'Item B', price_display: '$2', price_in_cents: 200, quantity: '1', category_name: 'Cat B', master_item_id: 2 },
{
item: 'Item A',
price_display: '$1',
price_in_cents: 100,
quantity: '1',
category_name: 'Cat A',
master_item_id: 1,
},
{
item: 'Item B',
price_display: '$2',
price_in_cents: 200,
quantity: '1',
category_name: 'Cat B',
master_item_id: 2,
},
],
};
@@ -334,7 +443,14 @@ describe('FlyerAiProcessor', () => {
valid_to: null,
store_address: null,
items: [
{ item: 'Item C', price_display: '$3', price_in_cents: 300, quantity: '1', category_name: 'Cat C', master_item_id: 3 },
{
item: 'Item C',
price_display: '$3',
price_in_cents: 300,
quantity: '1',
category_name: 'Cat C',
master_item_id: 3,
},
],
};
@@ -351,8 +467,22 @@ describe('FlyerAiProcessor', () => {
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(2);
// 2. Check the arguments for each call
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(1, imagePaths.slice(0, 4), [], undefined, undefined, logger);
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(2, imagePaths.slice(4, 5), [], undefined, undefined, logger);
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(
1,
imagePaths.slice(0, 4),
[],
undefined,
undefined,
logger,
);
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(
2,
imagePaths.slice(4, 5),
[],
undefined,
undefined,
logger,
);
// 3. Check the merged data
expect(result.data.store_name).toBe('Batch 1 Store'); // Metadata from the first batch
@@ -362,11 +492,13 @@ describe('FlyerAiProcessor', () => {
// 4. Check that items from both batches are merged
expect(result.data.items).toHaveLength(3);
expect(result.data.items).toEqual(expect.arrayContaining([
expect.objectContaining({ item: 'Item A' }),
expect.objectContaining({ item: 'Item B' }),
expect.objectContaining({ item: 'Item C' }),
]));
expect(result.data.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ item: 'Item A' }),
expect.objectContaining({ item: 'Item B' }),
expect.objectContaining({ item: 'Item C' }),
]),
);
// 5. Check that the job is not flagged for review
expect(result.needsReview).toBe(false);
@@ -376,7 +508,11 @@ describe('FlyerAiProcessor', () => {
// Arrange
const jobData = createMockJobData({});
const imagePaths = [
{ path: 'page1.jpg', mimetype: 'image/jpeg' }, { path: 'page2.jpg', mimetype: 'image/jpeg' }, { path: 'page3.jpg', mimetype: 'image/jpeg' }, { path: 'page4.jpg', mimetype: 'image/jpeg' }, { path: 'page5.jpg', mimetype: 'image/jpeg' },
{ path: 'page1.jpg', mimetype: 'image/jpeg' },
{ path: 'page2.jpg', mimetype: 'image/jpeg' },
{ path: 'page3.jpg', mimetype: 'image/jpeg' },
{ path: 'page4.jpg', mimetype: 'image/jpeg' },
{ path: 'page5.jpg', mimetype: 'image/jpeg' },
];
const mockAiResponseBatch1 = {
@@ -385,7 +521,14 @@ describe('FlyerAiProcessor', () => {
valid_to: '2025-01-07',
store_address: '123 Good St',
items: [
{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 },
{
item: 'Item A',
price_display: '$1',
price_in_cents: 100,
quantity: '1',
category_name: 'Cat A',
master_item_id: 1,
},
],
};
@@ -416,11 +559,45 @@ describe('FlyerAiProcessor', () => {
// Arrange
const jobData = createMockJobData({});
const imagePaths = [
{ path: 'page1.jpg', mimetype: 'image/jpeg' }, { path: 'page2.jpg', mimetype: 'image/jpeg' }, { path: 'page3.jpg', mimetype: 'image/jpeg' }, { path: 'page4.jpg', mimetype: 'image/jpeg' }, { path: 'page5.jpg', mimetype: 'image/jpeg' },
{ path: 'page1.jpg', mimetype: 'image/jpeg' },
{ path: 'page2.jpg', mimetype: 'image/jpeg' },
{ path: 'page3.jpg', mimetype: 'image/jpeg' },
{ path: 'page4.jpg', mimetype: 'image/jpeg' },
{ path: 'page5.jpg', mimetype: 'image/jpeg' },
];
const mockAiResponseBatch1 = { store_name: null, valid_from: '2025-01-01', valid_to: '2025-01-07', store_address: null, items: [{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 }] };
const mockAiResponseBatch2 = { store_name: 'Batch 2 Store', valid_from: '2025-01-02', valid_to: null, store_address: '456 Subsequent St', items: [{ item: 'Item C', price_display: '$3', price_in_cents: 300, quantity: '1', category_name: 'Cat C', master_item_id: 3 }] };
const mockAiResponseBatch1 = {
store_name: null,
valid_from: '2025-01-01',
valid_to: '2025-01-07',
store_address: null,
items: [
{
item: 'Item A',
price_display: '$1',
price_in_cents: 100,
quantity: '1',
category_name: 'Cat A',
master_item_id: 1,
},
],
};
const mockAiResponseBatch2 = {
store_name: 'Batch 2 Store',
valid_from: '2025-01-02',
valid_to: null,
store_address: '456 Subsequent St',
items: [
{
item: 'Item C',
price_display: '$3',
price_in_cents: 300,
quantity: '1',
category_name: 'Cat C',
master_item_id: 3,
},
],
};
vi.mocked(mockAiService.extractCoreDataFromFlyerImage)
.mockResolvedValueOnce(mockAiResponseBatch1)
@@ -453,7 +630,14 @@ describe('FlyerAiProcessor', () => {
valid_to: '2025-02-07',
store_address: '789 Single St',
items: [
{ item: 'Item X', price_display: '$10', price_in_cents: 1000, quantity: '1', category_name: 'Cat X', master_item_id: 10 },
{
item: 'Item X',
price_display: '$10',
price_in_cents: 1000,
quantity: '1',
category_name: 'Cat X',
master_item_id: 10,
},
],
};
@@ -468,9 +652,15 @@ describe('FlyerAiProcessor', () => {
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
// 2. Check the arguments for the single call.
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(imagePaths, [], undefined, undefined, logger);
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
imagePaths,
[],
undefined,
undefined,
logger,
);
// 3. Check that the final data matches the single batch's data.
expect(result.data).toEqual(mockAiResponse);
});
});
});

View File

@@ -139,7 +139,7 @@ export class FlyerAiProcessor {
logger.info(`Starting AI data extraction for ${imagePaths.length} pages.`);
const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.personalizationRepo.getAllMasterItems(logger);
const { items: masterItems } = await this.personalizationRepo.getAllMasterItems(logger);
logger.debug(`Retrieved ${masterItems.length} master items for AI matching.`);
// BATCHING LOGIC: Process images in chunks to avoid hitting AI payload/token limits.

View File

@@ -182,7 +182,10 @@ describe('FlyerProcessingService', () => {
);
vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue({
items: [],
total: 0,
});
});
beforeEach(() => {
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');

View File

@@ -318,4 +318,183 @@ describe('Admin API Routes Integration Tests', () => {
expect(response.status).toBe(404);
});
});
describe('Queue Management Routes', () => {
describe('GET /api/admin/queues/status', () => {
it('should return queue status for all queues', async () => {
const response = await request
.get('/api/admin/queues/status')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
// Should have data for each queue
if (response.body.data.length > 0) {
const firstQueue = response.body.data[0];
expect(firstQueue).toHaveProperty('name');
expect(firstQueue).toHaveProperty('counts');
}
});
it('should forbid regular users from viewing queue status', async () => {
const response = await request
.get('/api/admin/queues/status')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
expect(response.body.error.message).toBe('Forbidden: Administrator access required.');
});
});
describe('POST /api/admin/trigger/analytics-report', () => {
it('should enqueue an analytics report job', async () => {
const response = await request
.post('/api/admin/trigger/analytics-report')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('enqueued');
});
it('should forbid regular users from triggering analytics report', async () => {
const response = await request
.post('/api/admin/trigger/analytics-report')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/trigger/weekly-analytics', () => {
it('should enqueue a weekly analytics job', async () => {
const response = await request
.post('/api/admin/trigger/weekly-analytics')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('enqueued');
});
it('should forbid regular users from triggering weekly analytics', async () => {
const response = await request
.post('/api/admin/trigger/weekly-analytics')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/trigger/daily-deal-check', () => {
it('should enqueue a daily deal check job', async () => {
const response = await request
.post('/api/admin/trigger/daily-deal-check')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('enqueued');
});
it('should forbid regular users from triggering daily deal check', async () => {
const response = await request
.post('/api/admin/trigger/daily-deal-check')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/system/clear-cache', () => {
it('should clear the application cache', async () => {
const response = await request
.post('/api/admin/system/clear-cache')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('cleared');
});
it('should forbid regular users from clearing cache', async () => {
const response = await request
.post('/api/admin/system/clear-cache')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/jobs/:queue/:id/retry', () => {
it('should return validation error for invalid queue name', async () => {
const response = await request
.post('/api/admin/jobs/invalid-queue-name/1/retry')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should return 404 for non-existent job', async () => {
const response = await request
.post('/api/admin/jobs/flyer-processing/999999999/retry')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
});
it('should forbid regular users from retrying jobs', async () => {
const response = await request
.post('/api/admin/jobs/flyer-processing/1/retry')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
});
describe('GET /api/admin/users', () => {
it('should return all users for admin', async () => {
const response = await request
.get('/api/admin/users')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should forbid regular users from listing all users', async () => {
const response = await request
.get('/api/admin/users')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('GET /api/admin/review/flyers', () => {
it('should return pending review flyers for admin', async () => {
const response = await request
.get('/api/admin/review/flyers')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should forbid regular users from viewing pending flyers', async () => {
const response = await request
.get('/api/admin/review/flyers')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
});

View File

@@ -206,4 +206,170 @@ describe('Authentication API Integration', () => {
);
}, 15000); // Increase timeout to handle multiple sequential requests
});
describe('Token Edge Cases', () => {
it('should reject empty Bearer token', async () => {
const response = await request.get('/api/users/profile').set('Authorization', 'Bearer ');
expect(response.status).toBe(401);
});
it('should reject token without dots (invalid JWT structure)', async () => {
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Bearer notavalidtoken');
expect(response.status).toBe(401);
});
it('should reject token with only 2 parts (missing signature)', async () => {
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Bearer header.payload');
expect(response.status).toBe(401);
});
it('should reject token with invalid signature', async () => {
// Valid structure but tampered signature
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.invalidsig');
expect(response.status).toBe(401);
});
it('should accept lowercase "bearer" scheme (case-insensitive)', async () => {
// First get a valid token
const loginResponse = await request
.post('/api/auth/login')
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: false });
const token = loginResponse.body.data.token;
// Use lowercase "bearer"
const response = await request
.get('/api/users/profile')
.set('Authorization', `bearer ${token}`);
expect(response.status).toBe(200);
});
it('should reject Basic auth scheme', async () => {
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=');
expect(response.status).toBe(401);
});
it('should reject missing Authorization header', async () => {
const response = await request.get('/api/users/profile');
expect(response.status).toBe(401);
});
});
describe('Login Security', () => {
it('should return same error for wrong password and non-existent user', async () => {
// Wrong password for existing user
const wrongPassResponse = await request
.post('/api/auth/login')
.send({ email: testUserEmail, password: 'wrong-password', rememberMe: false });
// Non-existent user
const nonExistentResponse = await request
.post('/api/auth/login')
.send({ email: 'nonexistent@example.com', password: 'any-password', rememberMe: false });
// Both should return 401 with the same message
expect(wrongPassResponse.status).toBe(401);
expect(nonExistentResponse.status).toBe(401);
expect(wrongPassResponse.body.error.message).toBe(nonExistentResponse.body.error.message);
expect(wrongPassResponse.body.error.message).toBe('Incorrect email or password.');
});
it('should return same response for forgot-password on existing and non-existing email', async () => {
// Request for existing user
const existingResponse = await request
.post('/api/auth/forgot-password')
.send({ email: testUserEmail });
// Request for non-existing user
const nonExistingResponse = await request
.post('/api/auth/forgot-password')
.send({ email: 'nonexistent-user@example.com' });
// Both should return 200 with similar success message (prevents email enumeration)
expect(existingResponse.status).toBe(200);
expect(nonExistingResponse.status).toBe(200);
expect(existingResponse.body.success).toBe(true);
expect(nonExistingResponse.body.success).toBe(true);
});
it('should return validation error for missing login fields', async () => {
const response = await request.post('/api/auth/login').send({ email: testUserEmail }); // Missing password
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('Password Reset', () => {
it('should reject reset with invalid token', async () => {
const response = await request.post('/api/auth/reset-password').send({
token: 'invalid-reset-token',
newPassword: TEST_PASSWORD,
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('Registration Validation', () => {
it('should reject duplicate email registration', async () => {
const response = await request.post('/api/auth/register').send({
email: testUserEmail, // Already exists
password: TEST_PASSWORD,
full_name: 'Duplicate User',
});
expect(response.status).toBe(409); // CONFLICT
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('CONFLICT');
});
it('should reject invalid email format', async () => {
const response = await request.post('/api/auth/register').send({
email: 'not-an-email',
password: TEST_PASSWORD,
full_name: 'Invalid Email User',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should reject weak password', async () => {
const response = await request.post('/api/auth/register').send({
email: `weak-pass-${Date.now()}@example.com`,
password: '123456', // Too weak
full_name: 'Weak Password User',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('Refresh Token Edge Cases', () => {
it('should return error when refresh token cookie is missing', async () => {
const response = await request.post('/api/auth/refresh-token');
expect(response.status).toBe(401);
expect(response.body.error.message).toBe('Refresh token not found.');
});
});
});

View File

@@ -143,6 +143,67 @@ describe('Budget API Routes Integration Tests', () => {
expect(response.status).toBe(401);
});
it('should reject period="yearly" (only weekly/monthly allowed)', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Yearly Budget',
amount_cents: 100000,
period: 'yearly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should reject negative amount_cents', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Negative Budget',
amount_cents: -500,
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should reject invalid date format', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Invalid Date Budget',
amount_cents: 10000,
period: 'weekly',
start_date: '01-01-2025', // Wrong format, should be YYYY-MM-DD
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should require name field', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
amount_cents: 10000,
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('PUT /api/budgets/:id', () => {

View File

@@ -0,0 +1,388 @@
// src/tests/integration/data-integrity.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
import { getPool } from '../../services/db/connection.db';
/**
* @vitest-environment node
*
* Integration tests for data integrity: FK constraints, cascades, unique constraints, and CHECK constraints.
* These tests verify that database-level constraints are properly enforced.
*/
describe('Data Integrity Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let adminToken: string;
let adminUser: UserProfile;
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create an admin user for admin-level tests
const { user, token } = await createAndLoginUser({
email: `data-integrity-admin-${Date.now()}@example.com`,
fullName: 'Data Integrity Admin',
role: 'admin',
request,
});
adminUser = user;
adminToken = token;
});
afterAll(async () => {
vi.unstubAllEnvs();
// Clean up admin user
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [adminUser.user.user_id]);
});
describe('Cascade Deletes', () => {
it('should cascade delete shopping lists when user is deleted', async () => {
// Create a test user with shopping lists
const { token } = await createAndLoginUser({
email: `cascade-test-${Date.now()}@example.com`,
fullName: 'Cascade Test User',
request,
});
// Create some shopping lists
const listResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Cascade Test List' });
expect(listResponse.status).toBe(201);
const listId = listResponse.body.data.shopping_list_id;
// Verify list exists
const checkListBefore = await getPool().query(
'SELECT * FROM public.shopping_lists WHERE shopping_list_id = $1',
[listId],
);
expect(checkListBefore.rows.length).toBe(1);
// Delete the user account
const deleteResponse = await request
.delete('/api/users/account')
.set('Authorization', `Bearer ${token}`)
.send({ password: TEST_PASSWORD });
expect(deleteResponse.status).toBe(200);
// Verify list was cascade deleted
const checkListAfter = await getPool().query(
'SELECT * FROM public.shopping_lists WHERE shopping_list_id = $1',
[listId],
);
expect(checkListAfter.rows.length).toBe(0);
});
it('should cascade delete budgets when user is deleted', async () => {
// Create a test user with budgets
const { token } = await createAndLoginUser({
email: `budget-cascade-${Date.now()}@example.com`,
fullName: 'Budget Cascade User',
request,
});
// Create a budget
const budgetResponse = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Cascade Test Budget',
amount_cents: 10000,
period: 'weekly',
start_date: '2025-01-01',
});
expect(budgetResponse.status).toBe(201);
const budgetId = budgetResponse.body.data.budget_id;
// Verify budget exists
const checkBefore = await getPool().query(
'SELECT * FROM public.budgets WHERE budget_id = $1',
[budgetId],
);
expect(checkBefore.rows.length).toBe(1);
// Delete the user account
const deleteResponse = await request
.delete('/api/users/account')
.set('Authorization', `Bearer ${token}`)
.send({ password: TEST_PASSWORD });
expect(deleteResponse.status).toBe(200);
// Verify budget was cascade deleted
const checkAfter = await getPool().query(
'SELECT * FROM public.budgets WHERE budget_id = $1',
[budgetId],
);
expect(checkAfter.rows.length).toBe(0);
});
it('should cascade delete shopping list items when list is deleted', async () => {
// Create a test user
const { user, token } = await createAndLoginUser({
email: `item-cascade-${Date.now()}@example.com`,
fullName: 'Item Cascade User',
request,
});
// Create a shopping list
const listResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Item Cascade List' });
expect(listResponse.status).toBe(201);
const listId = listResponse.body.data.shopping_list_id;
// Add an item to the list
const itemResponse = await request
.post(`/api/users/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ customItemName: 'Test Item', quantity: 1 });
expect(itemResponse.status).toBe(201);
const itemId = itemResponse.body.data.shopping_list_item_id;
// Verify item exists
const checkItemBefore = await getPool().query(
'SELECT * FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
[itemId],
);
expect(checkItemBefore.rows.length).toBe(1);
// Delete the shopping list
const deleteResponse = await request
.delete(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
expect(deleteResponse.status).toBe(204);
// Verify item was cascade deleted
const checkItemAfter = await getPool().query(
'SELECT * FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
[itemId],
);
expect(checkItemAfter.rows.length).toBe(0);
// Clean up user
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
});
describe('Admin Self-Deletion Prevention', () => {
it('should prevent admin from deleting their own account via admin route', async () => {
const response = await request
.delete(`/api/admin/users/${adminUser.user.user_id}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.message).toContain('cannot delete');
});
});
describe('FK Constraint Enforcement', () => {
it('should return error when adding item with invalid shopping list ID', async () => {
// Create a test user
const { user, token } = await createAndLoginUser({
email: `fk-test-${Date.now()}@example.com`,
fullName: 'FK Test User',
request,
});
// Try to add item to non-existent list
const response = await request
.post('/api/users/shopping-lists/999999/items')
.set('Authorization', `Bearer ${token}`)
.send({ customItemName: 'Invalid List Item', quantity: 1 });
expect(response.status).toBe(404);
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
it('should enforce FK constraints at database level', async () => {
// Try to insert directly into DB with invalid FK
try {
await getPool().query(
`INSERT INTO public.shopping_list_items (shopping_list_id, custom_item_name, quantity)
VALUES (999999999, 'Direct Insert Test', 1)`,
);
// If we get here, the constraint didn't fire
expect.fail('Expected FK constraint violation');
} catch (error) {
// Expected - FK constraint should prevent this
expect(error).toBeDefined();
expect((error as Error).message).toContain('violates foreign key constraint');
}
});
});
describe('Unique Constraints', () => {
it('should return CONFLICT for duplicate email registration', async () => {
const email = `unique-test-${Date.now()}@example.com`;
// Register first user
const firstResponse = await request
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'First User' });
expect(firstResponse.status).toBe(201);
// Try to register second user with same email
const secondResponse = await request
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Second User' });
expect(secondResponse.status).toBe(409); // CONFLICT
expect(secondResponse.body.success).toBe(false);
expect(secondResponse.body.error.code).toBe('CONFLICT');
// Clean up first user
const userId = firstResponse.body.data.userprofile.user.user_id;
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [userId]);
});
});
describe('CHECK Constraints', () => {
it('should reject budget with invalid period via API', async () => {
const { user, token } = await createAndLoginUser({
email: `check-test-${Date.now()}@example.com`,
fullName: 'Check Constraint User',
request,
});
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Invalid Period Budget',
amount_cents: 10000,
period: 'yearly', // Invalid - only weekly/monthly allowed
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
it('should reject budget with negative amount via API', async () => {
const { user, token } = await createAndLoginUser({
email: `amount-check-${Date.now()}@example.com`,
fullName: 'Amount Check User',
request,
});
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Negative Amount Budget',
amount_cents: -100, // Invalid - must be positive
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
it('should enforce CHECK constraints at database level', async () => {
// Try to insert directly with invalid period
const { user, token: _ } = await createAndLoginUser({
email: `db-check-${Date.now()}@example.com`,
fullName: 'DB Check User',
request,
});
try {
await getPool().query(
`INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date)
VALUES ($1, 'Direct Insert', 10000, 'yearly', '2025-01-01')`,
[user.user.user_id],
);
// If we get here, the constraint didn't fire
expect.fail('Expected CHECK constraint violation');
} catch (error) {
// Expected - CHECK constraint should prevent this
expect(error).toBeDefined();
expect((error as Error).message).toContain('violates check constraint');
}
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
});
describe('NOT NULL Constraints', () => {
it('should require budget name via API', async () => {
const { user, token } = await createAndLoginUser({
email: `notnull-test-${Date.now()}@example.com`,
fullName: 'NotNull Test User',
request,
});
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
// name is missing - required field
amount_cents: 10000,
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
});
describe('Transaction Rollback', () => {
it('should rollback partial inserts on constraint violation', async () => {
const pool = getPool();
const client = await pool.connect();
try {
await client.query('BEGIN');
// First insert should work
const { user } = await createAndLoginUser({
email: `transaction-test-${Date.now()}@example.com`,
fullName: 'Transaction Test User',
request,
});
await client.query(
`INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, 'Transaction Test List') RETURNING shopping_list_id`,
[user.user.user_id],
);
// This should fail due to FK constraint
await client.query(
`INSERT INTO public.shopping_list_items (shopping_list_id, custom_item_name, quantity)
VALUES (999999999, 'Should Fail', 1)`,
);
await client.query('COMMIT');
expect.fail('Expected transaction to fail');
} catch {
await client.query('ROLLBACK');
// Expected - transaction should have rolled back
} finally {
client.release();
}
});
});
});

View File

@@ -0,0 +1,93 @@
// src/tests/integration/deals.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
/**
* @vitest-environment node
*
* Integration tests for the Deals API routes.
* These routes were previously unmounted and are now available at /api/deals.
*/
describe('Deals API Routes Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let authToken: string;
const createdUserIds: string[] = [];
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create a user for the tests
const { user, token } = await createAndLoginUser({
email: `deals-user-${Date.now()}@example.com`,
fullName: 'Deals Test User',
request,
});
authToken = token;
createdUserIds.push(user.user.user_id);
});
afterAll(async () => {
vi.unstubAllEnvs();
await cleanupDb({
userIds: createdUserIds,
});
});
describe('GET /api/deals/best-watched-prices', () => {
it('should require authentication', async () => {
const response = await request.get('/api/deals/best-watched-prices');
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});
it('should return watched item deals for authenticated user', async () => {
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should return empty array when user has no watched items', async () => {
// New test user with no watched items
const { token: newUserToken, user: newUser } = await createAndLoginUser({
email: `deals-no-watch-${Date.now()}@example.com`,
fullName: 'No Watch User',
request,
});
createdUserIds.push(newUser.user.user_id);
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${newUserToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
});
it('should reject invalid JWT token', async () => {
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', 'Bearer invalid.token.here');
expect(response.status).toBe(401);
});
it('should reject missing Bearer prefix', async () => {
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', authToken);
expect(response.status).toBe(401);
});
});
});

View File

@@ -0,0 +1,360 @@
// src/tests/integration/edge-cases.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
/**
* @vitest-environment node
*
* Integration tests for edge cases discovered during manual frontend testing.
* These tests cover file upload validation, input sanitization, and authorization boundaries.
*/
describe('Edge Cases Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let authToken: string;
let otherUserToken: string;
const createdUserIds: string[] = [];
const createdShoppingListIds: number[] = [];
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create primary test user
const { user, token } = await createAndLoginUser({
email: `edge-case-user-${Date.now()}@example.com`,
fullName: 'Edge Case Test User',
request,
});
authToken = token;
createdUserIds.push(user.user.user_id);
// Create secondary test user for cross-user tests
const { user: user2, token: token2 } = await createAndLoginUser({
email: `edge-case-other-${Date.now()}@example.com`,
fullName: 'Other Test User',
request,
});
otherUserToken = token2;
createdUserIds.push(user2.user.user_id);
});
afterAll(async () => {
vi.unstubAllEnvs();
await cleanupDb({
userIds: createdUserIds,
shoppingListIds: createdShoppingListIds,
});
});
describe('File Upload Validation', () => {
describe('Checksum Validation', () => {
it('should reject missing checksum', async () => {
// Create a small valid PNG
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
if (!fs.existsSync(testImagePath)) {
// Skip if test asset doesn't exist
return;
}
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyerFile', testImagePath);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.message).toContain('checksum');
});
it('should reject invalid checksum format (non-hex)', async () => {
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
if (!fs.existsSync(testImagePath)) {
return;
}
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyerFile', testImagePath)
.field('checksum', 'not-a-valid-hex-checksum-at-all!!!!');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should reject short checksum (not 64 characters)', async () => {
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
if (!fs.existsSync(testImagePath)) {
return;
}
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyerFile', testImagePath)
.field('checksum', 'abc123'); // Too short
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('File Type Validation', () => {
it('should require flyerFile field', async () => {
const checksum = crypto.randomBytes(32).toString('hex');
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.field('checksum', checksum);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.message).toContain('file');
});
});
});
describe('Input Sanitization', () => {
describe('Shopping List Names', () => {
it('should accept unicode characters and emojis', async () => {
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Grocery List 🛒 日本語 émoji' });
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Grocery List 🛒 日本語 émoji');
if (response.body.data.shopping_list_id) {
createdShoppingListIds.push(response.body.data.shopping_list_id);
}
});
it('should store XSS payloads as-is (frontend must escape)', async () => {
const xssPayload = '<script>alert("xss")</script>';
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: xssPayload });
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
// The payload is stored as-is - frontend is responsible for escaping
expect(response.body.data.name).toBe(xssPayload);
if (response.body.data.shopping_list_id) {
createdShoppingListIds.push(response.body.data.shopping_list_id);
}
});
it('should reject null bytes in JSON', async () => {
// Null bytes in JSON should be rejected by the JSON parser
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.set('Content-Type', 'application/json')
.send('{"name":"test\u0000value"}');
// JSON parser may reject this or sanitize it
expect([400, 201]).toContain(response.status);
});
});
});
describe('Authorization Boundaries', () => {
describe('Cross-User Resource Access', () => {
it("should return 404 (not 403) for accessing another user's shopping list", async () => {
// Create a shopping list as the primary user
const createResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Private List' });
expect(createResponse.status).toBe(201);
const listId = createResponse.body.data.shopping_list_id;
createdShoppingListIds.push(listId);
// Try to access it as the other user
const accessResponse = await request
.get(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${otherUserToken}`);
// Should return 404 to hide resource existence
expect(accessResponse.status).toBe(404);
expect(accessResponse.body.success).toBe(false);
expect(accessResponse.body.error.code).toBe('NOT_FOUND');
});
it("should return 404 when trying to update another user's shopping list", async () => {
// Create a shopping list as the primary user
const createResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Another Private List' });
expect(createResponse.status).toBe(201);
const listId = createResponse.body.data.shopping_list_id;
createdShoppingListIds.push(listId);
// Try to update it as the other user
const updateResponse = await request
.put(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${otherUserToken}`)
.send({ name: 'Hacked List' });
// Should return 404 to hide resource existence
expect(updateResponse.status).toBe(404);
});
it("should return 404 when trying to delete another user's shopping list", async () => {
// Create a shopping list as the primary user
const createResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Delete Test List' });
expect(createResponse.status).toBe(201);
const listId = createResponse.body.data.shopping_list_id;
createdShoppingListIds.push(listId);
// Try to delete it as the other user
const deleteResponse = await request
.delete(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${otherUserToken}`);
// Should return 404 to hide resource existence
expect(deleteResponse.status).toBe(404);
});
});
describe('SQL Injection Prevention', () => {
it('should safely handle SQL injection in query params', async () => {
// Attempt SQL injection in limit param
const response = await request
.get('/api/personalization/master-items')
.query({ limit: '10; DROP TABLE users; --' });
// Should either return normal data or a validation error, not crash
expect([200, 400]).toContain(response.status);
expect(response.body).toBeDefined();
});
it('should safely handle SQL injection in search params', async () => {
// Attempt SQL injection in flyer search
const response = await request.get('/api/flyers').query({
search: "'; DROP TABLE flyers; --",
});
// Should handle safely
expect([200, 400]).toContain(response.status);
});
});
});
describe('API Error Handling', () => {
it('should return 404 for non-existent resources with clear message', async () => {
const response = await request
.get('/api/flyers/99999999')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('NOT_FOUND');
});
it('should return validation error for malformed JSON body', async () => {
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.set('Content-Type', 'application/json')
.send('{ invalid json }');
expect(response.status).toBe(400);
});
it('should return validation error for missing required fields', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({}); // Empty body
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should return validation error for invalid data types', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Test Budget',
amount_cents: 'not-a-number', // Should be number
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('Concurrent Operations', () => {
it('should handle concurrent writes without data loss', async () => {
// Create 5 shopping lists concurrently
const promises = Array.from({ length: 5 }, (_, i) =>
request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: `Concurrent List ${i + 1}` }),
);
const results = await Promise.all(promises);
// All should succeed
results.forEach((response) => {
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
if (response.body.data.shopping_list_id) {
createdShoppingListIds.push(response.body.data.shopping_list_id);
}
});
// Verify all lists were created
const listResponse = await request
.get('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`);
expect(listResponse.status).toBe(200);
const lists = listResponse.body.data;
const concurrentLists = lists.filter((l: { name: string }) =>
l.name.startsWith('Concurrent List'),
);
expect(concurrentLists.length).toBe(5);
});
it('should handle concurrent reads without errors', async () => {
// Make 10 concurrent read requests
const promises = Array.from({ length: 10 }, () =>
request.get('/api/personalization/master-items'),
);
const results = await Promise.all(promises);
// All should succeed
results.forEach((response) => {
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
});
});
});

View File

@@ -145,4 +145,87 @@ describe('Notification API Routes Integration Tests', () => {
expect(Number(finalUnreadCountRes.rows[0].count)).toBe(0);
});
});
describe('Job Status Polling', () => {
describe('GET /api/ai/jobs/:id/status', () => {
it('should return 404 for non-existent job', async () => {
const response = await request.get('/api/ai/jobs/nonexistent-job-id/status');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('NOT_FOUND');
});
it('should be accessible without authentication (public endpoint)', async () => {
// This verifies that job status can be polled without auth
// This is important for UX where users may poll status from frontend
const response = await request.get('/api/ai/jobs/test-job-123/status');
// Should return 404 (job not found) rather than 401 (unauthorized)
expect(response.status).toBe(404);
expect(response.body.error.code).toBe('NOT_FOUND');
});
});
});
describe('DELETE /api/users/notifications/:notificationId', () => {
it('should delete a specific notification', async () => {
// First create a notification to delete
const createResult = await getPool().query(
`INSERT INTO public.notifications (user_id, content, is_read, link_url)
VALUES ($1, 'Notification to delete', false, '/test')
RETURNING notification_id`,
[testUser.user.user_id],
);
const notificationId = createResult.rows[0].notification_id;
const response = await request
.delete(`/api/users/notifications/${notificationId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(204);
// Verify it was deleted
const verifyResult = await getPool().query(
`SELECT * FROM public.notifications WHERE notification_id = $1`,
[notificationId],
);
expect(verifyResult.rows.length).toBe(0);
});
it('should return 404 for non-existent notification', async () => {
const response = await request
.delete('/api/users/notifications/999999')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(404);
});
it("should prevent deleting another user's notification", async () => {
// Create another user
const { user: otherUser, token: otherToken } = await createAndLoginUser({
email: `notification-other-${Date.now()}@example.com`,
fullName: 'Other Notification User',
request,
});
createdUserIds.push(otherUser.user.user_id);
// Create a notification for the original user
const createResult = await getPool().query(
`INSERT INTO public.notifications (user_id, content, is_read, link_url)
VALUES ($1, 'Private notification', false, '/test')
RETURNING notification_id`,
[testUser.user.user_id],
);
const notificationId = createResult.rows[0].notification_id;
// Try to delete it as the other user
const response = await request
.delete(`/api/users/notifications/${notificationId}`)
.set('Authorization', `Bearer ${otherToken}`);
// Should return 404 (not 403) to hide existence
expect(response.status).toBe(404);
});
});
});

View File

@@ -0,0 +1,243 @@
// src/tests/integration/reactions.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import { getPool } from '../../services/db/connection.db';
/**
* @vitest-environment node
*
* Integration tests for the Reactions API routes.
* These routes were previously unmounted and are now available at /api/reactions.
*/
describe('Reactions API Routes Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let authToken: string;
let testRecipeId: number;
const createdUserIds: string[] = [];
const createdReactionIds: number[] = [];
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create a user for the tests
const { user, token } = await createAndLoginUser({
email: `reactions-user-${Date.now()}@example.com`,
fullName: 'Reactions Test User',
request,
});
authToken = token;
createdUserIds.push(user.user.user_id);
// Get an existing recipe ID from the seed data to use for reactions
const recipeResult = await getPool().query(`SELECT recipe_id FROM public.recipes LIMIT 1`);
if (recipeResult.rows.length > 0) {
testRecipeId = recipeResult.rows[0].recipe_id;
} else {
// Create a minimal recipe if none exist
const newRecipe = await getPool().query(
`INSERT INTO public.recipes (title, description, instructions, prep_time_minutes, cook_time_minutes, servings)
VALUES ('Test Recipe for Reactions', 'A test recipe', 'Test instructions', 10, 20, 4)
RETURNING recipe_id`,
);
testRecipeId = newRecipe.rows[0].recipe_id;
}
});
afterAll(async () => {
vi.unstubAllEnvs();
// Clean up reactions created during tests
if (createdReactionIds.length > 0) {
await getPool().query('DELETE FROM public.reactions WHERE reaction_id = ANY($1::int[])', [
createdReactionIds,
]);
}
await cleanupDb({
userIds: createdUserIds,
});
});
describe('GET /api/reactions', () => {
it('should return reactions (public endpoint)', async () => {
const response = await request.get('/api/reactions');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should filter reactions by entityType', async () => {
const response = await request.get('/api/reactions').query({ entityType: 'recipe' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should filter reactions by entityId', async () => {
const response = await request
.get('/api/reactions')
.query({ entityType: 'recipe', entityId: String(testRecipeId) });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
});
describe('GET /api/reactions/summary', () => {
it('should return reaction summary for an entity', async () => {
const response = await request
.get('/api/reactions/summary')
.query({ entityType: 'recipe', entityId: String(testRecipeId) });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
// Summary should have reaction counts
expect(response.body.data).toBeDefined();
});
it('should return 400 when entityType is missing', async () => {
const response = await request
.get('/api/reactions/summary')
.query({ entityId: String(testRecipeId) });
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should return 400 when entityId is missing', async () => {
const response = await request.get('/api/reactions/summary').query({ entityType: 'recipe' });
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('POST /api/reactions/toggle', () => {
it('should require authentication', async () => {
const response = await request.post('/api/reactions/toggle').send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'like',
});
expect(response.status).toBe(401);
});
it('should add a reaction when none exists', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'like',
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toBe('Reaction added.');
expect(response.body.data.reaction).toBeDefined();
// Track for cleanup
if (response.body.data.reaction?.reaction_id) {
createdReactionIds.push(response.body.data.reaction.reaction_id);
}
});
it('should remove the reaction when toggled again', async () => {
// First add the reaction
const addResponse = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'love', // Use different type to not conflict
});
expect(addResponse.status).toBe(201);
if (addResponse.body.data.reaction?.reaction_id) {
createdReactionIds.push(addResponse.body.data.reaction.reaction_id);
}
// Then toggle it off
const removeResponse = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'love',
});
expect(removeResponse.status).toBe(200);
expect(removeResponse.body.success).toBe(true);
expect(removeResponse.body.data.message).toBe('Reaction removed.');
});
it('should return 400 for missing entity_type', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_id: String(testRecipeId),
reaction_type: 'like',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should return 400 for missing entity_id', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
reaction_type: 'like',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should return 400 for missing reaction_type', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should accept entity_id as string (required format)', async () => {
// entity_id must be a string per the Zod schema
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'helpful',
});
// Should succeed (201 for add, 200 for remove)
expect([200, 201]).toContain(response.status);
expect(response.body.success).toBe(true);
if (response.body.data.reaction?.reaction_id) {
createdReactionIds.push(response.body.data.reaction.reaction_id);
}
});
});
});

View File

@@ -232,6 +232,88 @@ describe('Recipe API Routes Integration Tests', () => {
createdRecipeIds.push(forkedRecipe.recipe_id);
});
it('should allow forking seed recipes (null user_id)', async () => {
// First, find or create a seed recipe (one with null user_id)
let seedRecipeId: number;
const seedRecipeResult = await getPool().query(
`SELECT recipe_id FROM public.recipes WHERE user_id IS NULL LIMIT 1`,
);
if (seedRecipeResult.rows.length > 0) {
seedRecipeId = seedRecipeResult.rows[0].recipe_id;
} else {
// Create a seed recipe if none exist
const createSeedResult = await getPool().query(
`INSERT INTO public.recipes (name, instructions, user_id, status, description)
VALUES ('Seed Recipe for Fork Test', 'Seed recipe instructions.', NULL, 'public', 'A seed recipe.')
RETURNING recipe_id`,
);
seedRecipeId = createSeedResult.rows[0].recipe_id;
createdRecipeIds.push(seedRecipeId);
}
// Fork the seed recipe - this should succeed
const response = await request
.post(`/api/recipes/${seedRecipeId}/fork`)
.set('Authorization', `Bearer ${authToken}`);
// Forking should work - seed recipes should be forkable
expect(response.status).toBe(201);
const forkedRecipe: Recipe = response.body.data;
expect(forkedRecipe.original_recipe_id).toBe(seedRecipeId);
expect(forkedRecipe.user_id).toBe(testUser.user.user_id);
// Track for cleanup
createdRecipeIds.push(forkedRecipe.recipe_id);
});
describe('GET /api/recipes/:recipeId/comments', () => {
it('should return comments for a recipe', async () => {
// First add a comment
await request
.post(`/api/recipes/${testRecipe.recipe_id}/comments`)
.set('Authorization', `Bearer ${authToken}`)
.send({ content: 'Test comment for GET request' });
// Now fetch comments
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBeGreaterThan(0);
// Verify comment structure
const comment = response.body.data[0];
expect(comment).toHaveProperty('recipe_comment_id');
expect(comment).toHaveProperty('content');
expect(comment).toHaveProperty('user_id');
expect(comment).toHaveProperty('recipe_id');
});
it('should return empty array for recipe with no comments', async () => {
// Create a recipe specifically with no comments
const createRes = await request
.post('/api/users/recipes')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Recipe With No Comments',
instructions: 'No comments here.',
description: 'Testing empty comments.',
});
const noCommentsRecipe: Recipe = createRes.body.data;
createdRecipeIds.push(noCommentsRecipe.recipe_id);
// Fetch comments for this recipe
const response = await request.get(`/api/recipes/${noCommentsRecipe.recipe_id}/comments`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
});
});
describe('POST /api/recipes/suggest', () => {
it('should return a recipe suggestion based on ingredients', async () => {
const ingredients = ['chicken', 'rice', 'broccoli'];

View File

@@ -20,12 +20,20 @@ describe('rateLimit utils', () => {
expect(shouldSkipRateLimit(req)).toBe(false);
});
it('should return false (do not skip) when NODE_ENV is "development"', async () => {
it('should return true (skip) when NODE_ENV is "development"', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = createMockRequest({ headers: {} });
expect(shouldSkipRateLimit(req)).toBe(false);
expect(shouldSkipRateLimit(req)).toBe(true);
});
it('should return true (skip) when NODE_ENV is "staging"', async () => {
vi.stubEnv('NODE_ENV', 'staging');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = createMockRequest({ headers: {} });
expect(shouldSkipRateLimit(req)).toBe(true);
});
it('should return true (skip) when NODE_ENV is "test" and header is missing', async () => {
@@ -55,5 +63,15 @@ describe('rateLimit utils', () => {
});
expect(shouldSkipRateLimit(req)).toBe(true);
});
it('should return false (do not skip) when NODE_ENV is "development" and header is "true"', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = createMockRequest({
headers: { 'x-test-rate-limit-enable': 'true' },
});
expect(shouldSkipRateLimit(req)).toBe(false);
});
});
});
});

View File

@@ -1,7 +1,10 @@
// src/utils/rateLimit.ts
import { Request } from 'express';
const isTestEnv = process.env.NODE_ENV === 'test';
const isTestEnv =
process.env.NODE_ENV === 'test' ||
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'staging';
/**
* Helper to determine if rate limiting should be skipped.
@@ -10,4 +13,4 @@ const isTestEnv = process.env.NODE_ENV === 'test';
export const shouldSkipRateLimit = (req: Request) => {
if (!isTestEnv) return false;
return req.headers['x-test-rate-limit-enable'] !== 'true';
};
};

View File

@@ -4,15 +4,16 @@ import { z } from 'zod';
/**
* A Zod schema for a required, non-empty string.
* @param message The error message to display if the string is empty or missing.
* @param maxLength Optional maximum length (defaults to 255).
* @returns A Zod string schema.
*/
export const requiredString = (message: string) =>
export const requiredString = (message: string, maxLength = 255) =>
z.preprocess(
// If the value is null or undefined, preprocess it to an empty string.
// This ensures that the subsequent `.min(1)` check will catch missing required fields.
(val) => val ?? '',
// Now, validate that the (potentially preprocessed) value is a string that, after trimming, has at least 1 character.
z.string().trim().min(1, message),
z.string().trim().min(1, message).max(maxLength, `Must be ${maxLength} characters or less.`),
);
/**
@@ -76,7 +77,7 @@ export const optionalNumeric = (
// the .optional() and .default() logic for null inputs. We want null to be
// treated as "not provided", just like undefined.
const schema = z.preprocess((val) => (val === null ? undefined : val), optionalNumberSchema);
if (options.default !== undefined) return schema.default(options.default);
return schema;
@@ -89,7 +90,6 @@ export const optionalNumeric = (
*/
export const optionalDate = (message?: string) => z.string().date(message).optional();
/**
* Creates a Zod schema for an optional boolean query parameter that is coerced from a string.
* Handles 'true', '1' as true and 'false', '0' as false.

View File

@@ -1,9 +1,23 @@
// vite.config.ts
import path from 'path';
// import fs from 'fs'; // Unused when nginx handles SSL
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { sentryVitePlugin } from '@sentry/vite-plugin';
// HTTPS configuration for local development (optional, disabled when using nginx proxy)
// const httpsConfig = (() => {
// const keyPath = '/app/certs/localhost.key';
// const certPath = '/app/certs/localhost.crt';
// if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
// return {
// key: fs.readFileSync(keyPath),
// cert: fs.readFileSync(certPath),
// };
// }
// return undefined;
// })();
// Ensure NODE_ENV is set to 'test' for all Vitest runs.
process.env.NODE_ENV = 'test';
@@ -59,8 +73,9 @@ export default defineConfig({
: []),
],
server: {
port: 3000,
port: 5173, // Internal port, nginx proxies on 3000
host: '0.0.0.0',
// https: httpsConfig, // Disabled - nginx handles SSL
},
resolve: {
alias: {