Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3efa3c756 | ||
| fb8fd57bb6 | |||
|
|
0a90d9d590 | ||
| 6ab473f5f0 | |||
|
|
c46efe1474 | ||
| 25d6b76f6d | |||
|
|
9ffcc9d65d | ||
| 1285702210 | |||
|
|
d38b751b40 | ||
| e122d55ced | |||
|
|
af9992f773 | ||
| 3912139273 | |||
| b5f7f5e4d1 |
@@ -76,7 +76,10 @@
|
||||
"Bash(timeout 60 podman machine start:*)",
|
||||
"Bash(podman build:*)",
|
||||
"Bash(podman network rm:*)",
|
||||
"Bash(npm run lint)"
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npm run typecheck:*)",
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(npm run test:unit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ ESLint is configured with:
|
||||
- React hooks rules via `eslint-plugin-react-hooks`
|
||||
- React Refresh support for HMR
|
||||
- Prettier compatibility via `eslint-config-prettier`
|
||||
- **Relaxed rules for test files** (see below)
|
||||
|
||||
```javascript
|
||||
// eslint.config.js (ESLint v9 flat config)
|
||||
@@ -73,6 +74,37 @@ export default tseslint.config(
|
||||
);
|
||||
```
|
||||
|
||||
### Relaxed Linting Rules for Test Files
|
||||
|
||||
**Decision Date**: 2026-01-09
|
||||
|
||||
**Status**: Active (revisit when product nears final release)
|
||||
|
||||
The following ESLint rules are relaxed for test files (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`):
|
||||
|
||||
| Rule | Setting | Rationale |
|
||||
| ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `@typescript-eslint/no-explicit-any` | `off` | Mocking complexity often requires `any`; strict typing in tests adds friction without proportional benefit |
|
||||
|
||||
**Rationale**:
|
||||
|
||||
1. **Tests are not production code** - The primary goal of tests is verifying behavior, not type safety of the test code itself
|
||||
2. **Mocking complexity** - Mocking libraries often require type gymnastics; `any` simplifies creating partial mocks and test doubles
|
||||
3. **Testing edge cases** - Sometimes tests intentionally pass invalid types to verify error handling
|
||||
4. **Development velocity** - Strict typing in tests slows down test writing without proportional benefit during active development
|
||||
|
||||
**Future Consideration**: This decision should be revisited when the product is nearing its final stages. At that point, stricter linting in tests may be warranted to ensure long-term maintainability.
|
||||
|
||||
```javascript
|
||||
// eslint.config.js - Test file overrides
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
The pre-commit hook runs lint-staged automatically:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2025-12-14
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Adopted
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Implemented
|
||||
|
||||
## Context
|
||||
|
||||
@@ -99,16 +99,44 @@ interface ApiErrorResponse {
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ❌ Not yet implemented
|
||||
- ✅ Created `src/utils/apiResponse.ts` with helper functions (`sendSuccess`, `sendPaginated`, `sendError`, `sendNoContent`, `sendMessage`, `calculatePagination`)
|
||||
- ✅ Created `src/types/api.ts` with response type definitions (`ApiSuccessResponse`, `ApiErrorResponse`, `PaginationMeta`, `ErrorCode`)
|
||||
- ✅ Updated `src/middleware/errorHandler.ts` to use standard error format
|
||||
- ✅ Migrated all route files to use standardized responses:
|
||||
- `health.routes.ts`
|
||||
- `flyer.routes.ts`
|
||||
- `deals.routes.ts`
|
||||
- `budget.routes.ts`
|
||||
- `personalization.routes.ts`
|
||||
- `price.routes.ts`
|
||||
- `reactions.routes.ts`
|
||||
- `stats.routes.ts`
|
||||
- `system.routes.ts`
|
||||
- `gamification.routes.ts`
|
||||
- `recipe.routes.ts`
|
||||
- `auth.routes.ts`
|
||||
- `user.routes.ts`
|
||||
- `admin.routes.ts`
|
||||
- `ai.routes.ts`
|
||||
|
||||
### What Needs To Be Done
|
||||
### Error Codes
|
||||
|
||||
1. Create `src/utils/apiResponse.ts` with helper functions
|
||||
2. Create `src/types/api.ts` with response type definitions
|
||||
3. Update `errorHandler.ts` to use standard error format
|
||||
4. Create migration guide for existing endpoints
|
||||
5. Update 2-3 routes as examples
|
||||
6. Document pattern in this ADR
|
||||
The following error codes are defined in `src/types/api.ts`:
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
| ------------------------ | ----------- | ----------------------------------- |
|
||||
| `VALIDATION_ERROR` | 400 | Request validation failed |
|
||||
| `BAD_REQUEST` | 400 | Malformed request |
|
||||
| `UNAUTHORIZED` | 401 | Authentication required |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions |
|
||||
| `NOT_FOUND` | 404 | Resource not found |
|
||||
| `CONFLICT` | 409 | Resource conflict (e.g., duplicate) |
|
||||
| `RATE_LIMITED` | 429 | Too many requests |
|
||||
| `PAYLOAD_TOO_LARGE` | 413 | Request body too large |
|
||||
| `INTERNAL_ERROR` | 500 | Server error |
|
||||
| `NOT_IMPLEMENTED` | 501 | Feature not yet implemented |
|
||||
| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable |
|
||||
| `EXTERNAL_SERVICE_ERROR` | 502 | External service failure |
|
||||
|
||||
## Example Usage
|
||||
|
||||
|
||||
145
docs/adr/adr-implementation-tracker.md
Normal file
145
docs/adr/adr-implementation-tracker.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# ADR Implementation Tracker
|
||||
|
||||
This document tracks the implementation status and estimated effort for all Architectural Decision Records (ADRs).
|
||||
|
||||
## Effort Estimation Guide
|
||||
|
||||
| Rating | Description | Typical Duration |
|
||||
| ------ | ------------------------------------------- | ----------------- |
|
||||
| S | Small - Simple, isolated changes | 1-2 hours |
|
||||
| M | Medium - Multiple files, some testing | Half day to 1 day |
|
||||
| L | Large - Significant refactoring, many files | 1-3 days |
|
||||
| XL | Extra Large - Major architectural change | 1+ weeks |
|
||||
|
||||
## Implementation Status Overview
|
||||
|
||||
| Status | Count |
|
||||
| ---------------------------- | ----- |
|
||||
| Accepted (Fully Implemented) | 22 |
|
||||
| Partially Implemented | 2 |
|
||||
| Proposed (Not Started) | 15 |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation Status
|
||||
|
||||
### Category 1: Foundational / Core Infrastructure
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ---------------------------------------------------------------- | ----------------------- | -------- | ------ | ------------------------------ |
|
||||
| [ADR-002](./0002-standardized-transaction-management.md) | Transaction Management | Accepted | - | Fully implemented |
|
||||
| [ADR-007](./0007-configuration-and-secrets-management.md) | Configuration & Secrets | Accepted | - | Fully implemented |
|
||||
| [ADR-020](./0020-health-checks-and-liveness-readiness-probes.md) | Health Checks | Accepted | - | Fully implemented |
|
||||
| [ADR-030](./0030-graceful-degradation-and-circuit-breaker.md) | Circuit Breaker | Proposed | L | New resilience patterns needed |
|
||||
|
||||
### Category 2: Data Management
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| --------------------------------------------------------------- | ------------------------ | -------- | ------ | ------------------------------ |
|
||||
| [ADR-009](./0009-caching-strategy-for-read-heavy-operations.md) | Caching Strategy | Accepted | - | Fully implemented |
|
||||
| [ADR-013](./0013-database-schema-migration-strategy.md) | Schema Migrations v1 | Proposed | M | Superseded by ADR-023 |
|
||||
| [ADR-019](./0019-data-backup-and-recovery-strategy.md) | Backup & Recovery | Accepted | - | Fully implemented |
|
||||
| [ADR-023](./0023-database-schema-migration-strategy.md) | Schema Migrations v2 | Proposed | L | Requires tooling setup |
|
||||
| [ADR-031](./0031-data-retention-and-privacy-compliance.md) | Data Retention & Privacy | Proposed | XL | Legal/compliance review needed |
|
||||
|
||||
### Category 3: API & Integration
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ------------------------------------------------------------------- | ------------------------ | ----------- | ------ | ------------------------------------- |
|
||||
| [ADR-003](./0003-standardized-input-validation-using-middleware.md) | Input Validation | Accepted | - | Fully implemented |
|
||||
| [ADR-008](./0008-api-versioning-strategy.md) | API Versioning | Proposed | L | Major URL/routing changes |
|
||||
| [ADR-018](./0018-api-documentation-strategy.md) | API Documentation | Proposed | M | OpenAPI/Swagger setup |
|
||||
| [ADR-022](./0022-real-time-notification-system.md) | Real-time Notifications | Proposed | XL | WebSocket infrastructure |
|
||||
| [ADR-028](./0028-api-response-standardization.md) | Response Standardization | Implemented | L | Completed (routes, middleware, tests) |
|
||||
|
||||
### Category 4: Security & Compliance
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ----------------------------------------------------------------------- | --------------------- | -------- | ------ | -------------------------------- |
|
||||
| [ADR-001](./0001-standardized-error-handling.md) | Error Handling | Accepted | - | Fully implemented |
|
||||
| [ADR-011](./0011-advanced-authorization-and-access-control-strategy.md) | Authorization & RBAC | Proposed | XL | Policy engine, permission system |
|
||||
| [ADR-016](./0016-api-security-hardening.md) | Security Hardening | Accepted | - | Fully implemented |
|
||||
| [ADR-029](./0029-secret-rotation-and-key-management.md) | Secret Rotation | Proposed | L | Infrastructure changes needed |
|
||||
| [ADR-032](./0032-rate-limiting-strategy.md) | Rate Limiting | Accepted | - | Fully implemented |
|
||||
| [ADR-033](./0033-file-upload-and-storage-strategy.md) | File Upload & Storage | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 5: Observability & Monitoring
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------------------- | -------------------- | -------- | ------ | ----------------------- |
|
||||
| [ADR-004](./0004-standardized-application-wide-structured-logging.md) | Structured Logging | Accepted | - | Fully implemented |
|
||||
| [ADR-015](./0015-application-performance-monitoring-and-error-tracking.md) | APM & Error Tracking | Proposed | M | Third-party integration |
|
||||
|
||||
### Category 6: Deployment & Operations
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ----------------- | -------- | ------ | -------------------------- |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 7: Frontend / User Interface
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ------------------------------------------------------------------------ | ------------------- | -------- | ------ | ------------------------------------------- |
|
||||
| [ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md) | State Management | Accepted | - | Fully implemented |
|
||||
| [ADR-012](./0012-frontend-component-library-and-design-system.md) | Component Library | Partial | L | Core components done, design tokens pending |
|
||||
| [ADR-025](./0025-internationalization-and-localization-strategy.md) | i18n & l10n | Proposed | XL | All UI strings need extraction |
|
||||
| [ADR-026](./0026-standardized-client-side-structured-logging.md) | Client-Side Logging | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 8: Development Workflow & Quality
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ----------------------------------------------------------------------------- | -------------------- | -------- | ------ | ----------------- |
|
||||
| [ADR-010](./0010-testing-strategy-and-standards.md) | Testing Strategy | Accepted | - | Fully implemented |
|
||||
| [ADR-021](./0021-code-formatting-and-linting-unification.md) | Formatting & Linting | Accepted | - | Fully implemented |
|
||||
| [ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md) | Naming Conventions | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 9: Architecture Patterns
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------- | -------------------- | -------- | ------ | ----------------- |
|
||||
| [ADR-034](./0034-repository-pattern-standards.md) | Repository Pattern | Accepted | - | Fully implemented |
|
||||
| [ADR-035](./0035-service-layer-architecture.md) | Service Layer | Accepted | - | Fully implemented |
|
||||
| [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) | Event Bus | Accepted | - | Fully implemented |
|
||||
| [ADR-039](./0039-dependency-injection-pattern.md) | Dependency Injection | Accepted | - | Fully implemented |
|
||||
|
||||
---
|
||||
|
||||
## Work Still To Be Completed (Priority Order)
|
||||
|
||||
These ADRs are proposed but not yet implemented, ordered by suggested implementation priority:
|
||||
|
||||
| Priority | ADR | Title | Effort | Rationale |
|
||||
| -------- | ------- | ------------------------ | ------ | ----------------------------------------------------- |
|
||||
| 1 | ADR-018 | API Documentation | M | Improves developer experience, enables SDK generation |
|
||||
| 2 | ADR-015 | APM & Error Tracking | M | Production visibility, debugging |
|
||||
| 3 | ADR-024 | Feature Flags | M | Safer deployments, A/B testing |
|
||||
| 4 | ADR-023 | Schema Migrations v2 | L | Database evolution support |
|
||||
| 5 | ADR-029 | Secret Rotation | L | Security improvement |
|
||||
| 6 | ADR-008 | API Versioning | L | Future API evolution |
|
||||
| 7 | ADR-030 | Circuit Breaker | L | Resilience improvement |
|
||||
| 8 | ADR-022 | Real-time Notifications | XL | Major feature enhancement |
|
||||
| 9 | ADR-011 | Authorization & RBAC | XL | Advanced permission system |
|
||||
| 10 | ADR-025 | i18n & l10n | XL | Multi-language support |
|
||||
| 11 | ADR-031 | Data Retention & Privacy | XL | Compliance requirements |
|
||||
|
||||
---
|
||||
|
||||
## Recent Implementation History
|
||||
|
||||
| Date | ADR | Change |
|
||||
| ---------- | ------- | --------------------------------------------------------------------------------------------- |
|
||||
| 2026-01-09 | ADR-026 | Fully implemented - all client-side components, hooks, and services now use structured logger |
|
||||
| 2026-01-09 | ADR-028 | Fully implemented - all routes, middleware, and tests updated |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Effort estimates** are rough guidelines and may vary based on current codebase state
|
||||
- **Dependencies** between ADRs should be considered when planning implementation order
|
||||
- This document should be updated when ADRs are implemented or status changes
|
||||
@@ -23,7 +23,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Proposed)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Proposed)
|
||||
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Proposed)
|
||||
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Proposed)
|
||||
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Implemented)
|
||||
|
||||
## 4. Security & Compliance
|
||||
|
||||
|
||||
@@ -30,6 +30,40 @@ export default tseslint.config(
|
||||
},
|
||||
// TypeScript files
|
||||
...tseslint.configs.recommended,
|
||||
// Allow underscore-prefixed variables to be unused (common convention for intentionally unused params)
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Relaxed rules for test files and test setup - see ADR-021 for rationale
|
||||
{
|
||||
files: [
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/tests/setup/**/*.ts',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
},
|
||||
},
|
||||
// Relaxed rules for type definition files - 'any' is often necessary for third-party library types
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier compatibility - must be last to override other formatting rules
|
||||
eslintConfigPrettier,
|
||||
);
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.72",
|
||||
"version": "0.9.78",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.72",
|
||||
"version": "0.9.78",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.9.72",
|
||||
"version": "0.9.78",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -125,7 +125,11 @@ vi.mock('pdfjs-dist', () => ({
|
||||
// Mock the new config module
|
||||
vi.mock('./config', () => ({
|
||||
default: {
|
||||
app: { version: '20250101-1200:abc1234:1.0.0', commitMessage: 'Initial commit', commitUrl: '#' },
|
||||
app: {
|
||||
version: '20250101-1200:abc1234:1.0.0',
|
||||
commitMessage: 'Initial commit',
|
||||
commitUrl: '#',
|
||||
},
|
||||
google: { mapsEmbedApiKey: 'mock-key' },
|
||||
},
|
||||
}));
|
||||
@@ -450,7 +454,9 @@ describe('App Component', () => {
|
||||
fireEvent.click(screen.getByText('Open Voice Assistant'));
|
||||
|
||||
console.log('[TEST DEBUG] Waiting for voice-assistant-mock');
|
||||
expect(await screen.findByTestId('voice-assistant-mock', {}, { timeout: 3000 })).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByTestId('voice-assistant-mock', {}, { timeout: 3000 }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
fireEvent.click(screen.getByText('Close Voice Assistant'));
|
||||
@@ -598,11 +604,15 @@ describe('App Component', () => {
|
||||
updateProfile: vi.fn(),
|
||||
});
|
||||
// Mock the login function to simulate a successful login. Signature: (token, profile)
|
||||
const mockLoginSuccess = vi.fn(async (_token: string, _profile?: UserProfile) => {
|
||||
const _mockLoginSuccess = vi.fn(async (_token: string, _profile?: UserProfile) => {
|
||||
// Simulate fetching profile after login
|
||||
const profileResponse = await mockedApiClient.getAuthenticatedUserProfile();
|
||||
const userProfileData: UserProfile = await profileResponse.json();
|
||||
mockUseAuth.mockReturnValue({ ...mockUseAuth(), userProfile: userProfileData, authStatus: 'AUTHENTICATED' });
|
||||
mockUseAuth.mockReturnValue({
|
||||
...mockUseAuth(),
|
||||
userProfile: userProfileData,
|
||||
authStatus: 'AUTHENTICATED',
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[TEST DEBUG] Rendering App');
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -56,13 +56,16 @@ function App() {
|
||||
// Debugging: Log renders to identify infinite loops
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
console.log('[App] Render:', {
|
||||
flyersCount: flyers.length,
|
||||
selectedFlyerId: selectedFlyer?.flyer_id,
|
||||
flyerIdFromUrl,
|
||||
authStatus,
|
||||
profileId: userProfile?.user.user_id,
|
||||
});
|
||||
logger.debug(
|
||||
{
|
||||
flyersCount: flyers.length,
|
||||
selectedFlyerId: selectedFlyer?.flyer_id,
|
||||
flyerIdFromUrl,
|
||||
authStatus,
|
||||
profileId: userProfile?.user.user_id,
|
||||
},
|
||||
'[App] Render',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,7 +79,6 @@ function App() {
|
||||
const handleCloseVoiceAssistant = useCallback(() => closeModal('voiceAssistant'), [closeModal]);
|
||||
|
||||
const handleOpenWhatsNew = useCallback(() => openModal('whatsNew'), [openModal]);
|
||||
const handleCloseWhatsNew = useCallback(() => closeModal('whatsNew'), [closeModal]);
|
||||
|
||||
const handleOpenCorrectionTool = useCallback(() => openModal('correctionTool'), [openModal]);
|
||||
const handleCloseCorrectionTool = useCallback(() => closeModal('correctionTool'), [closeModal]);
|
||||
@@ -134,7 +136,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlyer && flyers.length > 0) {
|
||||
if (process.env.NODE_ENV === 'test') console.log('[App] Effect: Auto-selecting first flyer');
|
||||
if (process.env.NODE_ENV === 'test') logger.debug('[App] Effect: Auto-selecting first flyer');
|
||||
handleFlyerSelect(flyers[0]);
|
||||
}
|
||||
}, [flyers, selectedFlyer, handleFlyerSelect]);
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('../config', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
const _mockedApiClient = vi.mocked(apiClient);
|
||||
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
||||
const mockedUseModal = vi.mocked(useModal);
|
||||
|
||||
|
||||
@@ -34,17 +34,16 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
|
||||
// Fetch the image and store it as a File object for API submission
|
||||
useEffect(() => {
|
||||
if (isOpen && imageUrl) {
|
||||
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
|
||||
logger.debug({ imageUrl }, '[FlyerCorrectionTool] isOpen is true, fetching image URL');
|
||||
fetch(imageUrl)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
|
||||
setImageFile(file);
|
||||
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
|
||||
logger.debug('[FlyerCorrectionTool] Image fetched and stored as File object');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
|
||||
logger.error({ error: err }, 'Failed to fetch image for correction tool');
|
||||
logger.error({ err }, '[FlyerCorrectionTool] Failed to fetch image');
|
||||
notifyError('Could not load the image for correction.');
|
||||
});
|
||||
}
|
||||
@@ -112,26 +111,37 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
|
||||
const handleMouseUp = () => {
|
||||
setIsDrawing(false);
|
||||
setStartPoint(null);
|
||||
console.debug('[DEBUG] FlyerCorrectionTool: Mouse Up - selection complete.', { selectionRect });
|
||||
logger.debug({ selectionRect }, '[FlyerCorrectionTool] Mouse Up - selection complete');
|
||||
};
|
||||
|
||||
const handleRescan = async (type: ExtractionType) => {
|
||||
console.debug(`[DEBUG] handleRescan triggered for type: ${type}`);
|
||||
console.debug(
|
||||
`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`,
|
||||
logger.debug({ type }, '[FlyerCorrectionTool] handleRescan triggered');
|
||||
logger.debug(
|
||||
{
|
||||
hasSelectionRect: !!selectionRect,
|
||||
hasImageRef: !!imageRef.current,
|
||||
hasImageFile: !!imageFile,
|
||||
},
|
||||
'[FlyerCorrectionTool] handleRescan state',
|
||||
);
|
||||
|
||||
if (!selectionRect || !imageRef.current || !imageFile) {
|
||||
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
|
||||
if (!selectionRect) console.warn('[DEBUG] Reason: No selectionRect');
|
||||
if (!imageRef.current) console.warn('[DEBUG] Reason: No imageRef');
|
||||
if (!imageFile) console.warn('[DEBUG] Reason: No imageFile');
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
hasSelectionRect: !!selectionRect,
|
||||
hasImageRef: !!imageRef.current,
|
||||
hasImageFile: !!imageFile,
|
||||
},
|
||||
'[FlyerCorrectionTool] handleRescan: Guard failed. Missing prerequisites',
|
||||
);
|
||||
notifyError('Please select an area on the image first.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(`[DEBUG] handleRescan: Prerequisites met. Starting processing for "${type}".`);
|
||||
logger.debug(
|
||||
{ type },
|
||||
'[FlyerCorrectionTool] handleRescan: Prerequisites met. Starting processing',
|
||||
);
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Scale selection coordinates to the original image dimensions
|
||||
@@ -145,38 +155,34 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
|
||||
width: selectionRect.width * scaleX,
|
||||
height: selectionRect.height * scaleY,
|
||||
};
|
||||
console.debug('[DEBUG] handleRescan: Calculated scaled cropArea:', cropArea);
|
||||
logger.debug({ cropArea }, '[FlyerCorrectionTool] handleRescan: Calculated scaled cropArea');
|
||||
|
||||
console.debug('[DEBUG] handleRescan: Awaiting aiApiClient.rescanImageArea...');
|
||||
logger.debug('[FlyerCorrectionTool] handleRescan: Awaiting aiApiClient.rescanImageArea');
|
||||
const response = await aiApiClient.rescanImageArea(imageFile, cropArea, type);
|
||||
console.debug('[DEBUG] handleRescan: API call returned. Response ok:', response.ok);
|
||||
logger.debug({ ok: response.ok }, '[FlyerCorrectionTool] handleRescan: API call returned');
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to rescan area.');
|
||||
}
|
||||
|
||||
const { text } = await response.json();
|
||||
console.debug('[DEBUG] handleRescan: Successfully extracted text:', text);
|
||||
logger.debug({ text }, '[FlyerCorrectionTool] handleRescan: Successfully extracted text');
|
||||
notifySuccess(`Extracted: ${text}`);
|
||||
onDataExtracted(type, text);
|
||||
onClose(); // Close modal on success
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
|
||||
logger.error({ err }, '[FlyerCorrectionTool] handleRescan: Caught an error');
|
||||
notifyError(msg);
|
||||
logger.error({ error: err }, 'Error during rescan:');
|
||||
} finally {
|
||||
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
|
||||
logger.debug('[FlyerCorrectionTool] handleRescan: Finished. Setting isProcessing=false');
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', {
|
||||
isProcessing,
|
||||
hasSelection: !!selectionRect,
|
||||
});
|
||||
logger.debug({ isProcessing, hasSelection: !!selectionRect }, '[FlyerCorrectionTool] Rendering');
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// src/components/Leaderboard.test.tsx
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
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 { createMockLogger } from '../tests/utils/mockLogger';
|
||||
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||
|
||||
// The apiClient and logger are mocked globally.
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('FlyerUploader', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Disable react-query's online manager to prevent it from interfering with fake timers
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
onlineManager.setEventListener((_setOnline) => {
|
||||
return () => {};
|
||||
});
|
||||
console.log(`\n--- [TEST LOG] ---: Starting test: "${expect.getState().currentTestName}"`);
|
||||
@@ -130,11 +130,14 @@ describe('FlyerUploader', () => {
|
||||
|
||||
try {
|
||||
// The polling interval is 3s, so we wait for a bit longer.
|
||||
await waitFor(() => {
|
||||
const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
|
||||
console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
|
||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
|
||||
}, { timeout: 4000 });
|
||||
await waitFor(
|
||||
() => {
|
||||
const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
|
||||
console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
|
||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
|
||||
} catch (error) {
|
||||
console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.');
|
||||
@@ -202,16 +205,19 @@ describe('FlyerUploader', () => {
|
||||
'--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.',
|
||||
);
|
||||
// Wait for the second poll to occur and the UI to update.
|
||||
await waitFor(() => {
|
||||
console.log(
|
||||
`--- [TEST LOG] ---: 8b. waitFor interval: calls=${
|
||||
mockedAiApiClient.getJobStatus.mock.calls.length
|
||||
}`,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Processing complete! Redirecting to flyer 42...'),
|
||||
).toBeInTheDocument();
|
||||
}, { timeout: 4000 });
|
||||
await waitFor(
|
||||
() => {
|
||||
console.log(
|
||||
`--- [TEST LOG] ---: 8b. waitFor interval: calls=${
|
||||
mockedAiApiClient.getJobStatus.mock.calls.length
|
||||
}`,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Processing complete! Redirecting to flyer 42...'),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
|
||||
} catch (error) {
|
||||
console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.');
|
||||
@@ -234,7 +240,10 @@ describe('FlyerUploader', () => {
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' });
|
||||
// The getJobStatus function throws a specific error when the job fails,
|
||||
// which is then caught by react-query and placed in the `error` state.
|
||||
const jobFailedError = new aiApiClientModule.JobFailedError('AI model exploded', 'UNKNOWN_ERROR');
|
||||
const jobFailedError = new aiApiClientModule.JobFailedError(
|
||||
'AI model exploded',
|
||||
'UNKNOWN_ERROR',
|
||||
);
|
||||
mockedAiApiClient.getJobStatus.mockRejectedValue(jobFailedError);
|
||||
|
||||
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
||||
@@ -285,7 +294,10 @@ describe('FlyerUploader', () => {
|
||||
await screen.findByText('Working...');
|
||||
|
||||
// Wait for the failure UI
|
||||
await waitFor(() => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(),
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop polling for job status when the component unmounts', async () => {
|
||||
@@ -335,7 +347,7 @@ describe('FlyerUploader', () => {
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
|
||||
status: 409,
|
||||
body: { flyerId: 99, message: 'This flyer has already been processed.' },
|
||||
});
|
||||
});
|
||||
|
||||
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
|
||||
renderComponent();
|
||||
@@ -350,7 +362,9 @@ describe('FlyerUploader', () => {
|
||||
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
|
||||
// With the fix, the duplicate error message and the link are combined into a single paragraph.
|
||||
// We now look for this combined message.
|
||||
const errorMessage = await screen.findByText(/This flyer has already been processed. You can view it here:/i);
|
||||
const errorMessage = await screen.findByText(
|
||||
/This flyer has already been processed. You can view it here:/i,
|
||||
);
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
|
||||
} catch (error) {
|
||||
@@ -471,7 +485,7 @@ describe('FlyerUploader', () => {
|
||||
console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.');
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-no-flyerid' });
|
||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
||||
{ state: 'completed', returnValue: {} }, // No flyerId
|
||||
{ state: 'completed', returnValue: {} }, // No flyerId
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
@@ -27,10 +27,9 @@ export const VoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose
|
||||
const [modelTranscript, setModelTranscript] = useState('');
|
||||
const [history, setHistory] = useState<{ speaker: 'user' | 'model'; text: string }[]>([]);
|
||||
|
||||
// Use `any` for the session promise ref to avoid type conflicts with the underlying Google AI SDK,
|
||||
// which may have a more complex session object type. The `LiveSession` interface is used
|
||||
// conceptually in callbacks, but `any` provides flexibility for the initial assignment.
|
||||
const sessionPromiseRef = useRef<any | null>(null);
|
||||
// The session promise ref holds the promise returned by startVoiceSession.
|
||||
// We type it as Promise<LiveSession> to allow calling .then() with proper typing.
|
||||
const sessionPromiseRef = useRef<Promise<LiveSession> | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const scriptProcessorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
@@ -151,7 +150,7 @@ export const VoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose
|
||||
},
|
||||
};
|
||||
|
||||
sessionPromiseRef.current = startVoiceSession(callbacks);
|
||||
sessionPromiseRef.current = startVoiceSession(callbacks) as Promise<LiveSession>;
|
||||
} catch (e) {
|
||||
// We check if the caught object is an instance of Error to safely access its message property.
|
||||
// This avoids using 'any' and handles different types of thrown values.
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('useAppInitialization Hook', () => {
|
||||
});
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
value: vi.fn().mockImplementation((_query) => ({
|
||||
matches: false, // default to light mode
|
||||
})),
|
||||
writable: true,
|
||||
@@ -98,7 +98,8 @@ describe('useAppInitialization Hook', () => {
|
||||
|
||||
it('should call navigate to clean the URL after processing a token', async () => {
|
||||
renderHook(() => useAppInitialization(), {
|
||||
wrapper: (props) => wrapper({ ...props, initialEntries: ['/some/path?googleAuthToken=test-token'] }),
|
||||
wrapper: (props) =>
|
||||
wrapper({ ...props, initialEntries: ['/some/path?googleAuthToken=test-token'] }),
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith('test-token');
|
||||
@@ -106,14 +107,14 @@ describe('useAppInitialization Hook', () => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/some/path', { replace: true });
|
||||
});
|
||||
|
||||
it("should open \"What's New\" modal if version is new", () => {
|
||||
it('should open "What\'s New" modal if version is new', () => {
|
||||
vi.spyOn(window.localStorage, 'getItem').mockReturnValue('1.0.0');
|
||||
renderHook(() => useAppInitialization(), { wrapper });
|
||||
expect(mockOpenModal).toHaveBeenCalledWith('whatsNew');
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith('lastSeenVersion', '1.0.1');
|
||||
});
|
||||
|
||||
it("should not open \"What's New\" modal if version is the same", () => {
|
||||
it('should not open "What\'s New" modal if version is the same', () => {
|
||||
vi.spyOn(window.localStorage, 'getItem').mockReturnValue('1.0.1');
|
||||
renderHook(() => useAppInitialization(), { wrapper });
|
||||
expect(mockOpenModal).not.toHaveBeenCalled();
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
// src/hooks/useFlyerUploader.ts
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadAndProcessFlyer,
|
||||
getJobStatus,
|
||||
type JobStatus,
|
||||
JobFailedError,
|
||||
} from '../services/aiApiClient';
|
||||
import { uploadAndProcessFlyer, getJobStatus, type JobStatus } from '../services/aiApiClient';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { generateFileChecksum } from '../utils/checksum';
|
||||
import type { ProcessingStage } from '../types';
|
||||
|
||||
export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
|
||||
|
||||
@@ -105,7 +99,7 @@ export const useFlyerUploader = () => {
|
||||
|
||||
// Consolidate state derivation for the UI from the react-query hooks using useMemo.
|
||||
// This improves performance by memoizing the derived state and makes the logic easier to follow.
|
||||
const { processingState, errorMessage, duplicateFlyerId, flyerId, statusMessage } = useMemo(() => {
|
||||
const { processingState, errorMessage, duplicateFlyerId, flyerId } = useMemo(() => {
|
||||
// The order of these checks is critical. Errors must be checked first to override
|
||||
// any stale `jobStatus` from a previous successful poll.
|
||||
const state: ProcessingState = (() => {
|
||||
@@ -150,7 +144,7 @@ export const useFlyerUploader = () => {
|
||||
processingState: state,
|
||||
errorMessage: msg,
|
||||
duplicateFlyerId: dupId,
|
||||
flyerId: jobStatus?.state === 'completed' ? jobStatus.returnValue?.flyerId ?? null : null,
|
||||
flyerId: jobStatus?.state === 'completed' ? (jobStatus.returnValue?.flyerId ?? null) : null,
|
||||
statusMessage: uploadMutation.isPending ? 'Uploading file...' : jobStatus?.progress?.message,
|
||||
};
|
||||
}, [uploadMutation, jobStatus, pollError]);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useUpdateShoppingListItemMutation,
|
||||
useRemoveShoppingListItemMutation,
|
||||
} from './mutations';
|
||||
import { logger } from '../services/logger.client';
|
||||
import type { ShoppingListItem } from '../types';
|
||||
|
||||
/**
|
||||
@@ -84,7 +85,7 @@ const useShoppingListsHook = () => {
|
||||
await createListMutation.mutateAsync({ name });
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation hook (notification shown)
|
||||
console.error('useShoppingLists: Failed to create list', error);
|
||||
logger.error({ err: error }, '[useShoppingLists] Failed to create list');
|
||||
}
|
||||
},
|
||||
[userProfile, createListMutation],
|
||||
@@ -102,7 +103,7 @@ const useShoppingListsHook = () => {
|
||||
await deleteListMutation.mutateAsync({ listId });
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation hook (notification shown)
|
||||
console.error('useShoppingLists: Failed to delete list', error);
|
||||
logger.error({ err: error }, '[useShoppingLists] Failed to delete list');
|
||||
}
|
||||
},
|
||||
[userProfile, deleteListMutation],
|
||||
@@ -123,7 +124,7 @@ const useShoppingListsHook = () => {
|
||||
await addItemMutation.mutateAsync({ listId, item });
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation hook (notification shown)
|
||||
console.error('useShoppingLists: Failed to add item', error);
|
||||
logger.error({ err: error }, '[useShoppingLists] Failed to add item');
|
||||
}
|
||||
},
|
||||
[userProfile, addItemMutation],
|
||||
@@ -141,7 +142,7 @@ const useShoppingListsHook = () => {
|
||||
await updateItemMutation.mutateAsync({ itemId, updates });
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation hook (notification shown)
|
||||
console.error('useShoppingLists: Failed to update item', error);
|
||||
logger.error({ err: error }, '[useShoppingLists] Failed to update item');
|
||||
}
|
||||
},
|
||||
[userProfile, updateItemMutation],
|
||||
@@ -159,7 +160,7 @@ const useShoppingListsHook = () => {
|
||||
await removeItemMutation.mutateAsync({ itemId });
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation hook (notification shown)
|
||||
console.error('useShoppingLists: Failed to remove item', error);
|
||||
logger.error({ err: error }, '[useShoppingLists] Failed to remove item');
|
||||
}
|
||||
},
|
||||
[userProfile, removeItemMutation],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useUserData } from '../hooks/useUserData';
|
||||
import { useAddWatchedItemMutation, useRemoveWatchedItemMutation } from './mutations';
|
||||
import { logger } from '../services/logger.client';
|
||||
|
||||
/**
|
||||
* A custom hook to manage all state and logic related to a user's watched items.
|
||||
@@ -43,7 +44,7 @@ const useWatchedItemsHook = () => {
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation hook (notification shown)
|
||||
// Just log for debugging
|
||||
console.error('useWatchedItems: Failed to add item', error);
|
||||
logger.error({ err: error }, '[useWatchedItems] Failed to add item');
|
||||
}
|
||||
},
|
||||
[userProfile, addWatchedItemMutation],
|
||||
@@ -62,7 +63,7 @@ const useWatchedItemsHook = () => {
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation hook (notification shown)
|
||||
// Just log for debugging
|
||||
console.error('useWatchedItems: Failed to remove item', error);
|
||||
logger.error({ err: error }, '[useWatchedItems] Failed to remove item');
|
||||
}
|
||||
},
|
||||
[userProfile, removeWatchedItemMutation],
|
||||
|
||||
@@ -114,10 +114,13 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/generic-error');
|
||||
expect(response.status).toBe(500);
|
||||
// In test/dev, we now expect a stack trace for 5xx errors.
|
||||
expect(response.body.message).toBe('A generic server error occurred.');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
console.log('[DEBUG] errorHandler.test.ts: Received 500 error response with ID:', response.body.errorId);
|
||||
expect(response.body.error.message).toBe('A generic server error occurred.');
|
||||
expect(response.body.error.details.stack).toBeDefined();
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
console.log(
|
||||
'[DEBUG] errorHandler.test.ts: Received 500 error response with ID:',
|
||||
response.body.meta.requestId,
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
@@ -136,7 +139,10 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/http-error-404');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ message: 'Resource not found' });
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: 'Resource not found' },
|
||||
});
|
||||
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -152,7 +158,10 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/not-found-error');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ message: 'Specific resource missing' });
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: 'Specific resource missing' },
|
||||
});
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -168,7 +177,10 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/fk-error');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ message: 'The referenced item does not exist.' });
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: { code: 'BAD_REQUEST', message: 'The referenced item does not exist.' },
|
||||
});
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -184,7 +196,10 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/unique-error');
|
||||
|
||||
expect(response.status).toBe(409); // 409 Conflict
|
||||
expect(response.body).toEqual({ message: 'This item already exists.' });
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: { code: 'CONFLICT', message: 'This item already exists.' },
|
||||
});
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -200,9 +215,9 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/validation-error');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Input validation failed');
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors).toEqual([
|
||||
expect(response.body.error.message).toBe('Input validation failed');
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details).toEqual([
|
||||
{ path: ['body', 'email'], message: 'Invalid email format' },
|
||||
]);
|
||||
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
|
||||
@@ -222,9 +237,9 @@ describe('errorHandler Middleware', () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
// In test/dev, we now expect a stack trace for 5xx errors.
|
||||
expect(response.body.message).toBe('A database connection issue occurred.');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
expect(response.body.error.message).toBe('A database connection issue occurred.');
|
||||
expect(response.body.error.details.stack).toBeDefined();
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(DatabaseError),
|
||||
@@ -243,7 +258,10 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/unauthorized-error-no-status');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ message: 'Invalid Token' });
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: { code: 'UNAUTHORIZED', message: 'Invalid Token' },
|
||||
});
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(Error),
|
||||
@@ -258,7 +276,10 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/unauthorized-error-with-status');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ message: 'Invalid Token' });
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: { code: 'UNAUTHORIZED', message: 'Invalid Token' },
|
||||
});
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(Error),
|
||||
@@ -304,17 +325,17 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/generic-error');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toMatch(
|
||||
expect(response.body.error.message).toMatch(
|
||||
/An unexpected server error occurred. Please reference error ID: \w+/,
|
||||
);
|
||||
expect(response.body.stack).toBeUndefined();
|
||||
expect(response.body.error.details?.stack).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the actual error message for client errors (4xx) in production', async () => {
|
||||
const response = await supertest(app).get('/http-error-404');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Resource not found');
|
||||
expect(response.body.error.message).toBe('Resource not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
// src/middleware/errorHandler.ts
|
||||
// ============================================================================
|
||||
// CENTRALIZED ERROR HANDLING MIDDLEWARE
|
||||
// ============================================================================
|
||||
// This middleware standardizes all error responses per ADR-028.
|
||||
// It should be the LAST `app.use()` call to catch all errors.
|
||||
// ============================================================================
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { ZodError } from 'zod';
|
||||
@@ -9,12 +16,43 @@ import {
|
||||
ValidationError,
|
||||
} from '../services/db/errors.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { ErrorCode, ApiErrorResponse } from '../types/api';
|
||||
|
||||
/**
|
||||
* Helper to send standardized error responses.
|
||||
*/
|
||||
function sendErrorResponse(
|
||||
res: Response,
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
meta?: { requestId?: string; timestamp?: string },
|
||||
): Response<ApiErrorResponse> {
|
||||
const response: ApiErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
if (details !== undefined) {
|
||||
response.error.details = details;
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
response.meta = meta;
|
||||
}
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* A centralized error handling middleware for the Express application.
|
||||
* This middleware should be the LAST `app.use()` call to catch all errors from previous routes and middleware.
|
||||
*
|
||||
* It standardizes error responses and ensures consistent logging.
|
||||
* It standardizes error responses per ADR-028 and ensures consistent logging per ADR-004.
|
||||
*/
|
||||
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// If headers have already been sent, delegate to the default Express error handler.
|
||||
@@ -29,16 +67,19 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
|
||||
if (err instanceof ZodError) {
|
||||
const statusCode = 400;
|
||||
const message = 'The request data is invalid.';
|
||||
const errors = err.issues.map((e) => ({ path: e.path, message: e.message }));
|
||||
log.warn({ err, validationErrors: errors, statusCode }, `Client Error on ${req.method} ${req.path}: ${message}`);
|
||||
return res.status(statusCode).json({ message, errors });
|
||||
const details = err.issues.map((e) => ({ path: e.path, message: e.message }));
|
||||
log.warn(
|
||||
{ err, validationErrors: details, statusCode },
|
||||
`Client Error on ${req.method} ${req.path}: ${message}`,
|
||||
);
|
||||
return sendErrorResponse(res, statusCode, ErrorCode.VALIDATION_ERROR, message, details);
|
||||
}
|
||||
|
||||
// --- Handle Custom Operational Errors ---
|
||||
if (err instanceof NotFoundError) {
|
||||
const statusCode = 404;
|
||||
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(statusCode).json({ message: err.message });
|
||||
return sendErrorResponse(res, statusCode, ErrorCode.NOT_FOUND, err.message);
|
||||
}
|
||||
|
||||
if (err instanceof ValidationError) {
|
||||
@@ -47,30 +88,66 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
|
||||
{ err, validationErrors: err.validationErrors, statusCode },
|
||||
`Client Error on ${req.method} ${req.path}: ${err.message}`,
|
||||
);
|
||||
return res.status(statusCode).json({ message: err.message, errors: err.validationErrors });
|
||||
return sendErrorResponse(
|
||||
res,
|
||||
statusCode,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
err.message,
|
||||
err.validationErrors,
|
||||
);
|
||||
}
|
||||
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
const statusCode = 409;
|
||||
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(statusCode).json({ message: err.message }); // Use 409 Conflict for unique constraints
|
||||
return sendErrorResponse(res, statusCode, ErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
const statusCode = 400;
|
||||
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(statusCode).json({ message: err.message });
|
||||
return sendErrorResponse(res, statusCode, ErrorCode.BAD_REQUEST, err.message);
|
||||
}
|
||||
|
||||
// --- Handle Generic Client Errors (e.g., from express-jwt, or manual status setting) ---
|
||||
let status = (err as any).status || (err as any).statusCode;
|
||||
const errWithStatus = err as Error & { status?: number; statusCode?: number };
|
||||
let status = errWithStatus.status || errWithStatus.statusCode;
|
||||
// Default UnauthorizedError to 401 if no status is present, a common case for express-jwt.
|
||||
if (err.name === 'UnauthorizedError' && !status) {
|
||||
status = 401;
|
||||
}
|
||||
if (status && status >= 400 && status < 500) {
|
||||
log.warn({ err, statusCode: status }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(status).json({ message: err.message });
|
||||
log.warn(
|
||||
{ err, statusCode: status },
|
||||
`Client Error on ${req.method} ${req.path}: ${err.message}`,
|
||||
);
|
||||
|
||||
// Map status codes to error codes
|
||||
let errorCode: string;
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorCode = ErrorCode.BAD_REQUEST;
|
||||
break;
|
||||
case 401:
|
||||
errorCode = ErrorCode.UNAUTHORIZED;
|
||||
break;
|
||||
case 403:
|
||||
errorCode = ErrorCode.FORBIDDEN;
|
||||
break;
|
||||
case 404:
|
||||
errorCode = ErrorCode.NOT_FOUND;
|
||||
break;
|
||||
case 409:
|
||||
errorCode = ErrorCode.CONFLICT;
|
||||
break;
|
||||
case 429:
|
||||
errorCode = ErrorCode.RATE_LIMITED;
|
||||
break;
|
||||
default:
|
||||
errorCode = ErrorCode.BAD_REQUEST;
|
||||
}
|
||||
|
||||
return sendErrorResponse(res, status, errorCode, err.message);
|
||||
}
|
||||
|
||||
// --- Handle All Other (500-level) Errors ---
|
||||
@@ -91,11 +168,23 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
|
||||
|
||||
// In production, send a generic message to avoid leaking implementation details.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return res.status(500).json({
|
||||
message: `An unexpected server error occurred. Please reference error ID: ${errorId}`,
|
||||
});
|
||||
return sendErrorResponse(
|
||||
res,
|
||||
500,
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`An unexpected server error occurred. Please reference error ID: ${errorId}`,
|
||||
undefined,
|
||||
{ requestId: errorId },
|
||||
);
|
||||
}
|
||||
|
||||
// In non-production environments (dev, test, etc.), send more details for easier debugging.
|
||||
return res.status(500).json({ message: err.message, stack: err.stack, errorId });
|
||||
};
|
||||
return sendErrorResponse(
|
||||
res,
|
||||
500,
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
err.message,
|
||||
{ stack: err.stack },
|
||||
{ requestId: errorId },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/pages/MyDealsPage.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import MyDealsPage from './MyDealsPage';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import type { WatchedItemDeal } from '../types';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/pages/UserProfilePage.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import UserProfilePage from './UserProfilePage';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import { UserProfile, Achievement, UserAchievement } from '../types';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/pages/UserProfilePage.tsx
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import type { UserProfile } from '../types';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { notifySuccess, notifyError } from '../services/notificationService';
|
||||
import { AchievementsList } from '../components/AchievementsList';
|
||||
|
||||
@@ -15,47 +15,43 @@ export const VoiceLabPage: React.FC = () => {
|
||||
const [audioPlayer, setAudioPlayer] = useState<HTMLAudioElement | null>(null);
|
||||
|
||||
// Debug log for rendering
|
||||
console.log(
|
||||
'[VoiceLabPage RENDER] audioPlayer state is:',
|
||||
audioPlayer ? 'Present (Object)' : 'Null',
|
||||
);
|
||||
logger.debug({ hasAudioPlayer: !!audioPlayer }, '[VoiceLabPage] Render');
|
||||
|
||||
const handleGenerateSpeech = async () => {
|
||||
console.log('[VoiceLabPage] handleGenerateSpeech triggered');
|
||||
logger.debug('[VoiceLabPage] handleGenerateSpeech triggered');
|
||||
if (!textToSpeak.trim()) {
|
||||
notifyError('Please enter some text to generate speech.');
|
||||
return;
|
||||
}
|
||||
setIsGeneratingSpeech(true);
|
||||
try {
|
||||
console.log('[VoiceLabPage] Calling generateSpeechFromText...');
|
||||
logger.debug('[VoiceLabPage] Calling generateSpeechFromText');
|
||||
const response = await generateSpeechFromText(textToSpeak);
|
||||
const base64Audio = await response.json(); // Extract the base64 audio string from the response
|
||||
console.log('[VoiceLabPage] Response JSON received. Length:', base64Audio?.length);
|
||||
logger.debug({ audioLength: base64Audio?.length }, '[VoiceLabPage] Response JSON received');
|
||||
|
||||
if (base64Audio) {
|
||||
const audioSrc = `data:audio/mpeg;base64,${base64Audio}`;
|
||||
console.log('[VoiceLabPage] creating new Audio()');
|
||||
logger.debug('[VoiceLabPage] Creating new Audio()');
|
||||
const audio = new Audio(audioSrc);
|
||||
console.log('[VoiceLabPage] Audio created:', audio);
|
||||
logger.debug('[VoiceLabPage] Audio created');
|
||||
|
||||
console.log('[VoiceLabPage] calling setAudioPlayer...');
|
||||
logger.debug('[VoiceLabPage] Calling setAudioPlayer');
|
||||
setAudioPlayer(audio);
|
||||
|
||||
console.log('[VoiceLabPage] calling audio.play()...');
|
||||
logger.debug('[VoiceLabPage] Calling audio.play()');
|
||||
await audio.play();
|
||||
console.log('[VoiceLabPage] audio.play() resolved');
|
||||
logger.debug('[VoiceLabPage] audio.play() resolved');
|
||||
} else {
|
||||
console.warn('[VoiceLabPage] base64Audio was falsy');
|
||||
logger.warn('[VoiceLabPage] base64Audio was falsy');
|
||||
notifyError('The AI did not return any audio data.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VoiceLabPage] Error caught:', error);
|
||||
logger.error({ err: error }, '[VoiceLabPage] Failed to generate speech');
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||
logger.error({ err: error }, 'Failed to generate speech:');
|
||||
notifyError(`Speech generation failed: ${errorMessage}`);
|
||||
} finally {
|
||||
console.log('[VoiceLabPage] finally block - setting isGeneratingSpeech false');
|
||||
logger.debug('[VoiceLabPage] finally block - setting isGeneratingSpeech false');
|
||||
setIsGeneratingSpeech(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ActivityLogItem } from '../../types';
|
||||
import { UserProfile } from '../../types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useActivityLogQuery } from '../../hooks/queries/useActivityLogQuery';
|
||||
import { logger } from '../../services/logger.client';
|
||||
|
||||
export type ActivityLogClickHandler = (log: ActivityLogItem) => void;
|
||||
|
||||
@@ -98,8 +99,9 @@ export const ActivityLog: React.FC<ActivityLogProps> = ({ userProfile, onLogClic
|
||||
{log.user_avatar_url ? (
|
||||
(() => {
|
||||
const altText = log.user_full_name || 'User Avatar';
|
||||
console.log(
|
||||
`[ActivityLog] Rendering avatar for log ${log.activity_log_id}. Alt: "${altText}"`,
|
||||
logger.debug(
|
||||
{ activityLogId: log.activity_log_id, altText },
|
||||
'[ActivityLog] Rendering avatar',
|
||||
);
|
||||
return (
|
||||
<img className="h-8 w-8 rounded-full" src={log.user_avatar_url} alt={altText} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/pages/admin/CorrectionsPage.tsx
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../types';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
import { ArrowPathIcon } from '../../components/icons/ArrowPathIcon';
|
||||
import { CorrectionRow } from './components/CorrectionRow';
|
||||
@@ -18,15 +17,9 @@ export const CorrectionsPage: React.FC = () => {
|
||||
refetch: refetchCorrections,
|
||||
} = useSuggestedCorrectionsQuery();
|
||||
|
||||
const {
|
||||
data: masterItems = [],
|
||||
isLoading: isLoadingMasterItems,
|
||||
} = useMasterItemsQuery();
|
||||
const { data: masterItems = [], isLoading: isLoadingMasterItems } = useMasterItemsQuery();
|
||||
|
||||
const {
|
||||
data: categories = [],
|
||||
isLoading: isLoadingCategories,
|
||||
} = useCategoriesQuery();
|
||||
const { data: categories = [], isLoading: isLoadingCategories } = useCategoriesQuery();
|
||||
|
||||
const isLoading = isLoadingCorrections || isLoadingMasterItems || isLoadingCategories;
|
||||
const error = correctionsError?.message || null;
|
||||
|
||||
@@ -5,14 +5,15 @@ import { fetchAllBrands, uploadBrandLogo } from '../../../services/apiClient';
|
||||
import { Brand } from '../../../types';
|
||||
import { ErrorDisplay } from '../../../components/ErrorDisplay';
|
||||
import { useApiOnMount } from '../../../hooks/useApiOnMount';
|
||||
import { logger } from '../../../services/logger.client';
|
||||
|
||||
export const AdminBrandManager: React.FC = () => {
|
||||
// Wrap the fetcher function in useCallback to prevent it from being recreated on every render.
|
||||
// The hook expects a function that returns a Promise<Response>, and it will handle
|
||||
// the JSON parsing and error checking internally.
|
||||
const fetchBrandsWrapper = useCallback(() => {
|
||||
console.log(
|
||||
'AdminBrandManager: The memoized fetchBrandsWrapper is being passed to useApiOnMount.',
|
||||
logger.debug(
|
||||
'[AdminBrandManager] The memoized fetchBrandsWrapper is being passed to useApiOnMount',
|
||||
);
|
||||
// This wrapper simply calls the API client function. The hook will manage the promise.
|
||||
return fetchAllBrands();
|
||||
@@ -30,19 +31,22 @@ export const AdminBrandManager: React.FC = () => {
|
||||
// At render time, decide which data to display. If updatedBrands exists, it takes precedence.
|
||||
// Otherwise, fall back to the initial data from the hook. Default to an empty array.
|
||||
const brandsToRender = updatedBrands || initialBrands || [];
|
||||
console.log('AdminBrandManager RENDER:', {
|
||||
loading,
|
||||
error: error?.message,
|
||||
hasInitialBrands: !!initialBrands,
|
||||
hasUpdatedBrands: !!updatedBrands,
|
||||
brandsToRenderCount: brandsToRender.length,
|
||||
});
|
||||
logger.debug(
|
||||
{
|
||||
loading,
|
||||
error: error?.message,
|
||||
hasInitialBrands: !!initialBrands,
|
||||
hasUpdatedBrands: !!updatedBrands,
|
||||
brandsToRenderCount: brandsToRender.length,
|
||||
},
|
||||
'[AdminBrandManager] Render',
|
||||
);
|
||||
|
||||
// The file parameter is now optional to handle cases where the user cancels the file picker.
|
||||
const handleLogoUpload = async (brandId: number, file: File | undefined) => {
|
||||
if (!file) {
|
||||
// This check is now the single source of truth for a missing file.
|
||||
console.log('AdminBrandManager: handleLogoUpload called with no file. Showing error toast.');
|
||||
logger.debug('[AdminBrandManager] handleLogoUpload called with no file. Showing error toast');
|
||||
toast.error('Please select a file to upload.');
|
||||
return;
|
||||
}
|
||||
@@ -61,11 +65,14 @@ export const AdminBrandManager: React.FC = () => {
|
||||
|
||||
try {
|
||||
const response = await uploadBrandLogo(brandId, file);
|
||||
console.log('AdminBrandManager: Logo upload response received.', {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
logger.debug(
|
||||
{
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
},
|
||||
'[AdminBrandManager] Logo upload response received',
|
||||
);
|
||||
|
||||
// Check for a successful response before attempting to parse JSON.
|
||||
if (!response.ok) {
|
||||
@@ -78,8 +85,9 @@ export const AdminBrandManager: React.FC = () => {
|
||||
|
||||
// Optimistically update the UI by setting the updatedBrands state.
|
||||
// This update is based on the currently rendered list of brands.
|
||||
console.log(
|
||||
`AdminBrandManager: Optimistically updating brand ${brandId} with new logo: ${logoUrl}`,
|
||||
logger.debug(
|
||||
{ brandId, logoUrl },
|
||||
'[AdminBrandManager] Optimistically updating brand with new logo',
|
||||
);
|
||||
setUpdatedBrands(
|
||||
brandsToRender.map((brand) =>
|
||||
@@ -93,12 +101,12 @@ export const AdminBrandManager: React.FC = () => {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
console.log('AdminBrandManager: Rendering the loading state.');
|
||||
logger.debug('[AdminBrandManager] Rendering the loading state');
|
||||
return <div className="text-center p-4">Loading brands...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error(`AdminBrandManager: Rendering the error state. Error: ${error.message}`);
|
||||
logger.error({ err: error }, '[AdminBrandManager] Rendering the error state');
|
||||
return <ErrorDisplay message={`Failed to load brands: ${error.message}`} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CorrectionRow } from './CorrectionRow';
|
||||
import * as apiClient from '../../../services/apiClient';
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/pages/admin/components/ProfileManager.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock, test } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import { ProfileManager } from './ProfileManager';
|
||||
import * as apiClient from '../../../services/apiClient';
|
||||
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
||||
@@ -272,7 +272,9 @@ describe('ProfileManager', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
|
||||
expect(loggerSpy).toHaveBeenCalledWith('[handleProfileSave] Aborted: No user is logged in.');
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
'[handleProfileSave] Aborted: No user is logged in.',
|
||||
);
|
||||
});
|
||||
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -974,11 +976,11 @@ describe('ProfileManager', () => {
|
||||
});
|
||||
|
||||
it('should handle updating the user profile and address with empty strings', async () => {
|
||||
mockedApiClient.updateUserProfile.mockImplementation(async (data) =>
|
||||
new Response(JSON.stringify({ ...authenticatedProfile, ...data })),
|
||||
mockedApiClient.updateUserProfile.mockImplementation(
|
||||
async (data) => new Response(JSON.stringify({ ...authenticatedProfile, ...data })),
|
||||
);
|
||||
mockedApiClient.updateUserAddress.mockImplementation(async (data) =>
|
||||
new Response(JSON.stringify({ ...mockAddress, ...data })),
|
||||
mockedApiClient.updateUserAddress.mockImplementation(
|
||||
async (data) => new Response(JSON.stringify({ ...mockAddress, ...data })),
|
||||
);
|
||||
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||
@@ -1004,7 +1006,7 @@ describe('ProfileManager', () => {
|
||||
expect.objectContaining({ signal: expect.anything() }),
|
||||
);
|
||||
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ full_name: '' })
|
||||
expect.objectContaining({ full_name: '' }),
|
||||
);
|
||||
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/providers/ApiProvider.test.tsx
|
||||
import React, { useContext } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ApiProvider } from './ApiProvider';
|
||||
import { ApiContext } from '../contexts/ApiContext';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
@@ -26,7 +26,7 @@ describe('ApiProvider & ApiContext', () => {
|
||||
render(
|
||||
<ApiProvider>
|
||||
<div data-testid="child">Child Content</div>
|
||||
</ApiProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Content')).toBeInTheDocument();
|
||||
@@ -36,7 +36,7 @@ describe('ApiProvider & ApiContext', () => {
|
||||
render(
|
||||
<ApiProvider>
|
||||
<TestConsumer />
|
||||
</ApiProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('value-check')).toHaveTextContent('Matches apiClient');
|
||||
});
|
||||
@@ -46,4 +46,4 @@ describe('ApiProvider & ApiContext', () => {
|
||||
render(<TestConsumer />);
|
||||
expect(screen.getByTestId('value-check')).toHaveTextContent('Matches apiClient');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockResolvedValue(mockCorrections);
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockCorrections);
|
||||
expect(response.body.data).toEqual(mockCorrections);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -179,7 +179,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
);
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/approve should approve a correction', async () => {
|
||||
@@ -187,7 +187,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.approveCorrection).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Correction approved successfully.' });
|
||||
expect(response.body.data).toEqual({ message: 'Correction approved successfully.' });
|
||||
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(
|
||||
correctionId,
|
||||
expect.anything(),
|
||||
@@ -206,7 +206,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
|
||||
expect(response.body.data).toEqual({ message: 'Correction rejected successfully.' });
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/reject should return 500 on DB error', async () => {
|
||||
@@ -230,7 +230,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.put(`/api/admin/corrections/${correctionId}`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedCorrection);
|
||||
expect(response.body.data).toEqual(mockUpdatedCorrection);
|
||||
});
|
||||
|
||||
it('PUT /corrections/:id should return 400 for invalid data', async () => {
|
||||
@@ -248,7 +248,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.put('/api/admin/corrections/999')
|
||||
.send({ suggested_value: 'new value' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Correction with ID 999 not found');
|
||||
expect(response.body.error.message).toBe('Correction with ID 999 not found');
|
||||
});
|
||||
|
||||
it('PUT /corrections/:id should return 500 on a generic DB error', async () => {
|
||||
@@ -259,7 +259,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.put('/api/admin/corrections/101')
|
||||
.send({ suggested_value: 'new value' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Generic DB Error');
|
||||
expect(response.body.error.message).toBe('Generic DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockResolvedValue(mockFlyers);
|
||||
const response = await supertest(app).get('/api/admin/review/flyers');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyers);
|
||||
expect(response.body.data).toEqual(mockFlyers);
|
||||
expect(vi.mocked(mockedDb.adminRepo.getFlyersForReview)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
);
|
||||
@@ -282,7 +282,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/review/flyers');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +292,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -302,14 +302,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockResolvedValue(mockBrands);
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockBrands);
|
||||
expect(response.body.data).toEqual(mockBrands);
|
||||
});
|
||||
|
||||
it('GET /brands should return 500 on DB error', async () => {
|
||||
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
||||
@@ -319,7 +319,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Brand logo updated successfully.');
|
||||
expect(response.body.data.message).toBe('Brand logo updated successfully.');
|
||||
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
|
||||
brandId,
|
||||
expect.stringContaining('/flyer-images/'),
|
||||
@@ -339,7 +339,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
it('POST /brands/:id/logo should return 400 if no file is uploaded', async () => {
|
||||
const response = await supertest(app).post('/api/admin/brands/55/logo');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toMatch(
|
||||
expect(response.body.error.message).toMatch(
|
||||
/Logo image file is required|The request data is invalid|Logo image file is missing./,
|
||||
);
|
||||
});
|
||||
@@ -367,7 +367,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.attach('logoImage', Buffer.from('this is not an image'), 'document.txt');
|
||||
expect(response.status).toBe(400);
|
||||
// This message comes from the handleMulterError middleware for the imageFileFilter
|
||||
expect(response.body.message).toBe('Only image files are allowed!');
|
||||
expect(response.body.error.message).toBe('Only image files are allowed!');
|
||||
});
|
||||
|
||||
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
|
||||
@@ -414,7 +414,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedRecipe);
|
||||
expect(response.body.data).toEqual(mockUpdatedRecipe);
|
||||
});
|
||||
|
||||
it('PUT /recipes/:id/status should return 400 for an invalid status value', async () => {
|
||||
@@ -448,7 +448,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.put(`/api/admin/comments/${commentId}/status`)
|
||||
.send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedComment);
|
||||
expect(response.body.data).toEqual(mockUpdatedComment);
|
||||
});
|
||||
|
||||
it('PUT /comments/:id/status should return 400 for an invalid status value', async () => {
|
||||
@@ -485,7 +485,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
|
||||
const response = await supertest(app).get('/api/admin/unmatched-items');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUnmatchedItems);
|
||||
expect(response.body.data).toEqual(mockUnmatchedItems);
|
||||
});
|
||||
|
||||
it('GET /unmatched-items should return 500 on DB error', async () => {
|
||||
@@ -515,23 +515,21 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
);
|
||||
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Flyer with ID 999 not found.');
|
||||
expect(response.body.error.message).toBe('Flyer with ID 999 not found.');
|
||||
});
|
||||
|
||||
it('DELETE /flyers/:flyerId should return 500 on a generic DB error', async () => {
|
||||
const flyerId = 42;
|
||||
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
|
||||
new Error('Generic DB Error'),
|
||||
);
|
||||
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(new Error('Generic DB Error'));
|
||||
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Generic DB Error');
|
||||
expect(response.body.error.message).toBe('Generic DB Error');
|
||||
});
|
||||
|
||||
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
|
||||
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/Expected number, received nan/i);
|
||||
expect(response.body.error.details[0].message).toMatch(/Expected number, received nan/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
// Use the instance method mock
|
||||
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Daily deal check job has been triggered');
|
||||
expect(response.body.data.message).toContain('Daily deal check job has been triggered');
|
||||
expect(backgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
});
|
||||
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Job runner failed');
|
||||
expect(response.body.error.message).toContain('Job runner failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
|
||||
const response = await supertest(app).post('/api/admin/trigger/failing-job');
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Failing test job has been enqueued');
|
||||
expect(response.body.data.message).toContain('Failing test job has been enqueued');
|
||||
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', {
|
||||
reportDate: 'FAIL',
|
||||
});
|
||||
@@ -138,23 +138,29 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue is down'));
|
||||
const response = await supertest(app).post('/api/admin/trigger/failing-job');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Queue is down');
|
||||
expect(response.body.error.message).toBe('Queue is down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trigger/analytics-report', () => {
|
||||
it('should trigger the analytics report job and return 202 Accepted', async () => {
|
||||
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockResolvedValue('manual-report-job-123');
|
||||
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockResolvedValue(
|
||||
'manual-report-job-123',
|
||||
);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Analytics report generation job has been enqueued');
|
||||
expect(response.body.data.message).toContain(
|
||||
'Analytics report generation job has been enqueued',
|
||||
);
|
||||
expect(backgroundJobService.triggerAnalyticsReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return 500 if enqueuing the analytics job fails', async () => {
|
||||
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockRejectedValue(new Error('Queue error'));
|
||||
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockRejectedValue(
|
||||
new Error('Queue error'),
|
||||
);
|
||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -162,17 +168,21 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
|
||||
describe('POST /trigger/weekly-analytics', () => {
|
||||
it('should trigger the weekly analytics job and return 202 Accepted', async () => {
|
||||
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockResolvedValue('manual-weekly-report-job-123');
|
||||
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockResolvedValue(
|
||||
'manual-weekly-report-job-123',
|
||||
);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Successfully enqueued weekly analytics job');
|
||||
expect(response.body.data.message).toContain('Successfully enqueued weekly analytics job');
|
||||
expect(backgroundJobService.triggerWeeklyAnalyticsReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return 500 if enqueuing the weekly analytics job fails', async () => {
|
||||
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockRejectedValue(new Error('Queue error'));
|
||||
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockRejectedValue(
|
||||
new Error('Queue error'),
|
||||
);
|
||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
@@ -185,7 +195,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(cleanupQueue.add).mockResolvedValue(mockJob);
|
||||
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toBe(
|
||||
expect(response.body.data.message).toBe(
|
||||
`File cleanup job for flyer ID ${flyerId} has been enqueued.`,
|
||||
);
|
||||
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
|
||||
@@ -196,13 +206,13 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(cleanupQueue.add).mockRejectedValue(new Error('Queue is down'));
|
||||
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Queue is down');
|
||||
expect(response.body.error.message).toBe('Queue is down');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyerId', async () => {
|
||||
const response = await supertest(app).post('/api/admin/flyers/abc/cleanup');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/Expected number, received nan/i);
|
||||
expect(response.body.error.details[0].message).toMatch(/Expected number, received nan/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,7 +234,9 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(`Job ${jobId} has been successfully marked for retry.`);
|
||||
expect(response.body.data.message).toBe(
|
||||
`Job ${jobId} has been successfully marked for retry.`,
|
||||
);
|
||||
expect(mockJob.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -244,7 +256,9 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Job with ID '${jobId}' not found in queue '${queueName}'.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 if the job ID is not found in the queue', async () => {
|
||||
@@ -253,7 +267,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
`/api/admin/jobs/${queueName}/not-found-job/retry`,
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found in queue');
|
||||
expect(response.body.error.message).toContain('not found in queue');
|
||||
});
|
||||
|
||||
it('should return 400 if the job is not in a failed state', async () => {
|
||||
@@ -267,7 +281,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(
|
||||
expect(response.body.error.message).toBe(
|
||||
"Job is not in a 'failed' state. Current state: completed.",
|
||||
); // This is now handled by the errorHandler
|
||||
expect(mockJob.retry).not.toHaveBeenCalled();
|
||||
@@ -284,7 +298,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Cannot retry job');
|
||||
expect(response.body.error.message).toContain('Cannot retry job');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid queueName or jobId', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/admin.monitoring.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
|
||||
@@ -133,7 +133,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockLogs);
|
||||
expect(response.body.data).toEqual(mockLogs);
|
||||
expect(adminRepo.getActivityLog).toHaveBeenCalledWith(50, 0, expect.anything());
|
||||
});
|
||||
|
||||
@@ -148,15 +148,15 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
it('should return 400 for invalid limit and offset query parameters', async () => {
|
||||
const response = await supertest(app).get('/api/admin/activity-log?limit=abc&offset=-1');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details.length).toBe(2); // Both limit and offset are invalid
|
||||
});
|
||||
|
||||
it('should return 500 if fetching activity log fails', async () => {
|
||||
vi.mocked(adminRepo.getActivityLog).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
expect(response.body.data).toEqual([
|
||||
{ name: 'flyer-processing', isRunning: true },
|
||||
{ name: 'email-sending', isRunning: true },
|
||||
{ name: 'analytics-reporting', isRunning: false },
|
||||
@@ -190,7 +190,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
vi.mocked(monitoringService.getWorkerStatuses).mockRejectedValue(new Error('Worker Error'));
|
||||
const response = await supertest(app).get('/api/admin/workers/status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Worker Error');
|
||||
expect(response.body.error.message).toBe('Worker Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
expect(response.body.data).toEqual([
|
||||
{
|
||||
name: 'flyer-processing',
|
||||
counts: { waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
|
||||
@@ -251,13 +251,11 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if fetching queue counts fails', async () => {
|
||||
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(
|
||||
new Error('Redis is down'),
|
||||
);
|
||||
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(new Error('Redis is down'));
|
||||
|
||||
const response = await supertest(app).get('/api/admin/queues/status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Redis is down');
|
||||
expect(response.body.error.message).toBe('Redis is down');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Router, NextFunction, Request, Response } from 'express';
|
||||
import passport from './passport.routes';
|
||||
import { isAdmin } from './passport.routes'; // Correctly imported
|
||||
import multer from 'multer';
|
||||
import { z } from 'zod';
|
||||
|
||||
import * as db from '../services/db/index.db';
|
||||
@@ -10,11 +9,8 @@ import type { UserProfile } from '../types';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
import { cacheService } from '../services/cacheService.server';
|
||||
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
|
||||
import {
|
||||
createUploadMiddleware,
|
||||
handleMulterError,
|
||||
} from '../middleware/multer.middleware';
|
||||
import { NotFoundError, ValidationError } from '../services/db/errors.db';
|
||||
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
// --- Bull Board (Job Queue UI) Imports ---
|
||||
@@ -22,15 +18,14 @@ import { createBullBoard } from '@bull-board/api';
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import { ExpressAdapter } from '@bull-board/express';
|
||||
import { backgroundJobService } from '../services/backgroundJobService';
|
||||
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
import {
|
||||
requiredString,
|
||||
numericIdParam,
|
||||
uuidParamSchema,
|
||||
optionalNumeric,
|
||||
optionalString,
|
||||
} from '../utils/zodUtils';
|
||||
flyerQueue,
|
||||
emailQueue,
|
||||
analyticsQueue,
|
||||
cleanupQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
} from '../services/queueService.server';
|
||||
import { numericIdParam, uuidParamSchema, optionalNumeric } from '../utils/zodUtils';
|
||||
// Removed: import { logger } from '../services/logger.server';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
@@ -38,6 +33,7 @@ import { userService } from '../services/userService';
|
||||
import { cleanupUploadedFile } from '../utils/fileUtils';
|
||||
import { brandService } from '../services/brandService';
|
||||
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
|
||||
|
||||
const updateCorrectionSchema = numericIdParam('id').extend({
|
||||
body: z.object({
|
||||
@@ -126,7 +122,7 @@ router.use(passport.authenticate('jwt', { session: false }), isAdmin);
|
||||
router.get('/corrections', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
|
||||
res.json(corrections);
|
||||
sendSuccess(res, corrections);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching suggested corrections');
|
||||
next(error);
|
||||
@@ -137,8 +133,11 @@ router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next
|
||||
try {
|
||||
req.log.debug('Fetching flyers for review via adminRepo');
|
||||
const flyers = await db.adminRepo.getFlyersForReview(req.log);
|
||||
req.log.info({ count: Array.isArray(flyers) ? flyers.length : 'unknown' }, 'Successfully fetched flyers for review');
|
||||
res.json(flyers);
|
||||
req.log.info(
|
||||
{ count: Array.isArray(flyers) ? flyers.length : 'unknown' },
|
||||
'Successfully fetched flyers for review',
|
||||
);
|
||||
sendSuccess(res, flyers);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching flyers for review');
|
||||
next(error);
|
||||
@@ -148,7 +147,7 @@ router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next
|
||||
router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const brands = await db.flyerRepo.getAllBrands(req.log);
|
||||
res.json(brands);
|
||||
sendSuccess(res, brands);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching brands');
|
||||
next(error);
|
||||
@@ -158,7 +157,7 @@ router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextF
|
||||
router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await db.adminRepo.getApplicationStats(req.log);
|
||||
res.json(stats);
|
||||
sendSuccess(res, stats);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching application stats');
|
||||
next(error);
|
||||
@@ -168,7 +167,7 @@ router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFu
|
||||
router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
|
||||
res.json(dailyStats);
|
||||
sendSuccess(res, dailyStats);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching daily stats');
|
||||
next(error);
|
||||
@@ -183,7 +182,7 @@ router.post(
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||
try {
|
||||
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
sendSuccess(res, { message: 'Correction approved successfully.' });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error approving correction');
|
||||
next(error);
|
||||
@@ -199,7 +198,7 @@ router.post(
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||
try {
|
||||
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
sendSuccess(res, { message: 'Correction rejected successfully.' });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error rejecting correction');
|
||||
next(error);
|
||||
@@ -219,7 +218,7 @@ router.put(
|
||||
body.suggested_value,
|
||||
req.log,
|
||||
);
|
||||
res.status(200).json(updatedCorrection);
|
||||
sendSuccess(res, updatedCorrection);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error updating suggested correction');
|
||||
next(error);
|
||||
@@ -235,7 +234,7 @@ router.put(
|
||||
const { params, body } = req as unknown as z.infer<typeof updateRecipeStatusSchema>;
|
||||
try {
|
||||
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedRecipe);
|
||||
sendSuccess(res, updatedRecipe);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error updating recipe status');
|
||||
next(error); // Pass all errors to the central error handler
|
||||
@@ -260,8 +259,11 @@ router.post(
|
||||
|
||||
const logoUrl = await brandService.updateBrandLogo(params.id, req.file, req.log);
|
||||
|
||||
req.log.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
|
||||
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
||||
req.log.info(
|
||||
{ brandId: params.id, logoUrl },
|
||||
`Brand logo updated for brand ID: ${params.id}`,
|
||||
);
|
||||
sendSuccess(res, { message: 'Brand logo updated successfully.', logoUrl });
|
||||
} catch (error) {
|
||||
// If an error occurs after the file has been uploaded (e.g., DB error),
|
||||
// we must clean up the orphaned file from the disk.
|
||||
@@ -272,15 +274,19 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/unmatched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching unmatched items');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/unmatched-items',
|
||||
validateRequest(emptySchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
|
||||
sendSuccess(res, items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching unmatched items');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
|
||||
@@ -295,7 +301,7 @@ router.delete(
|
||||
try {
|
||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error: unknown) {
|
||||
req.log.error({ error }, 'Error deleting recipe');
|
||||
next(error);
|
||||
@@ -314,7 +320,7 @@ router.delete(
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||
try {
|
||||
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error: unknown) {
|
||||
req.log.error({ error }, 'Error deleting flyer');
|
||||
next(error);
|
||||
@@ -334,7 +340,7 @@ router.put(
|
||||
body.status,
|
||||
req.log,
|
||||
); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedComment);
|
||||
sendSuccess(res, updatedComment);
|
||||
} catch (error: unknown) {
|
||||
req.log.error({ error }, 'Error updating comment status');
|
||||
next(error);
|
||||
@@ -345,7 +351,7 @@ router.put(
|
||||
router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const users = await db.adminRepo.getAllUsers(req.log);
|
||||
res.json(users);
|
||||
sendSuccess(res, users);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching users');
|
||||
next(error);
|
||||
@@ -362,7 +368,7 @@ router.get(
|
||||
|
||||
try {
|
||||
const logs = await db.adminRepo.getActivityLog(limit!, offset!, req.log);
|
||||
res.json(logs);
|
||||
sendSuccess(res, logs);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching activity log');
|
||||
next(error);
|
||||
@@ -378,7 +384,7 @@ router.get(
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
|
||||
try {
|
||||
const user = await db.userRepo.findUserProfileById(params.id, req.log);
|
||||
res.json(user);
|
||||
sendSuccess(res, user);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching user profile');
|
||||
next(error);
|
||||
@@ -394,7 +400,7 @@ router.put(
|
||||
const { params, body } = req as unknown as z.infer<typeof updateUserRoleSchema>;
|
||||
try {
|
||||
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
|
||||
res.json(updatedUser);
|
||||
sendSuccess(res, updatedUser);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error updating user ${params.id}:`);
|
||||
next(error);
|
||||
@@ -411,7 +417,7 @@ router.delete(
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
|
||||
try {
|
||||
await userService.deleteUserAsAdmin(userProfile.user.user_id, params.id, req.log);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error deleting user');
|
||||
next(error);
|
||||
@@ -437,10 +443,14 @@ router.post(
|
||||
// We call the function but don't wait for it to finish (no `await`).
|
||||
// This is a "fire-and-forget" operation from the client's perspective.
|
||||
backgroundJobService.runDailyDealCheck();
|
||||
res.status(202).json({
|
||||
message:
|
||||
'Daily deal check job has been triggered successfully. It will run in the background.',
|
||||
});
|
||||
sendSuccess(
|
||||
res,
|
||||
{
|
||||
message:
|
||||
'Daily deal check job has been triggered successfully. It will run in the background.',
|
||||
},
|
||||
202,
|
||||
);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, '[Admin] Failed to trigger daily deal check job.');
|
||||
next(error);
|
||||
@@ -464,9 +474,13 @@ router.post(
|
||||
|
||||
try {
|
||||
const jobId = await backgroundJobService.triggerAnalyticsReport();
|
||||
res.status(202).json({
|
||||
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
|
||||
});
|
||||
sendSuccess(
|
||||
res,
|
||||
{
|
||||
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
|
||||
},
|
||||
202,
|
||||
);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, '[Admin] Failed to enqueue analytics report job.');
|
||||
next(error);
|
||||
@@ -493,9 +507,11 @@ router.post(
|
||||
// Enqueue the cleanup job. The worker will handle the file deletion.
|
||||
try {
|
||||
await cleanupQueue.add('cleanup-flyer-files', { flyerId: params.flyerId });
|
||||
res
|
||||
.status(202)
|
||||
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
|
||||
sendSuccess(
|
||||
res,
|
||||
{ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` },
|
||||
202,
|
||||
);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error enqueuing cleanup job');
|
||||
next(error);
|
||||
@@ -512,22 +528,24 @@ router.post(
|
||||
adminTriggerLimiter,
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
req.log.info(
|
||||
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
|
||||
);
|
||||
const userProfile = req.user as UserProfile;
|
||||
req.log.info(
|
||||
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Add a job with a special 'forceFail' flag that the worker will recognize.
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
|
||||
res
|
||||
.status(202)
|
||||
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error enqueuing failing job');
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Add a job with a special 'forceFail' flag that the worker will recognize.
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
|
||||
sendSuccess(
|
||||
res,
|
||||
{ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` },
|
||||
202,
|
||||
);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error enqueuing failing job');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -546,7 +564,7 @@ router.post(
|
||||
|
||||
try {
|
||||
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
||||
res.status(200).json({
|
||||
sendSuccess(res, {
|
||||
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -560,29 +578,37 @@ router.post(
|
||||
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
|
||||
* This is useful for a system health dashboard to see if any workers have crashed.
|
||||
*/
|
||||
router.get('/workers/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const workerStatuses = await monitoringService.getWorkerStatuses();
|
||||
res.json(workerStatuses);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching worker statuses');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/workers/status',
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const workerStatuses = await monitoringService.getWorkerStatuses();
|
||||
sendSuccess(res, workerStatuses);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching worker statuses');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/admin/queues/status - Get job counts for all BullMQ queues.
|
||||
* This is useful for monitoring the health and backlog of background jobs.
|
||||
*/
|
||||
router.get('/queues/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const queueStatuses = await monitoringService.getQueueStatuses();
|
||||
res.json(queueStatuses);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching queue statuses');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/queues/status',
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const queueStatuses = await monitoringService.getQueueStatuses();
|
||||
sendSuccess(res, queueStatuses);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching queue statuses');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
|
||||
@@ -598,12 +624,8 @@ router.post(
|
||||
} = req as unknown as z.infer<typeof jobRetrySchema>;
|
||||
|
||||
try {
|
||||
await monitoringService.retryFailedJob(
|
||||
queueName,
|
||||
jobId,
|
||||
userProfile.user.user_id,
|
||||
);
|
||||
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
||||
await monitoringService.retryFailedJob(queueName, jobId, userProfile.user.user_id);
|
||||
sendSuccess(res, { message: `Job ${jobId} has been successfully marked for retry.` });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error retrying job');
|
||||
next(error);
|
||||
@@ -626,9 +648,7 @@ router.post(
|
||||
|
||||
try {
|
||||
const jobId = await backgroundJobService.triggerWeeklyAnalyticsReport();
|
||||
res
|
||||
.status(202)
|
||||
.json({ message: 'Successfully enqueued weekly analytics job.', jobId });
|
||||
sendSuccess(res, { message: 'Successfully enqueued weekly analytics job.', jobId }, 202);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error enqueuing weekly analytics job');
|
||||
next(error);
|
||||
@@ -647,9 +667,7 @@ router.post(
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
req.log.info(
|
||||
`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`,
|
||||
);
|
||||
req.log.info(`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`);
|
||||
|
||||
try {
|
||||
const [flyersDeleted, brandsDeleted, statsDeleted] = await Promise.all([
|
||||
@@ -659,7 +677,7 @@ router.post(
|
||||
]);
|
||||
|
||||
const totalDeleted = flyersDeleted + brandsDeleted + statsDeleted;
|
||||
res.status(200).json({
|
||||
sendSuccess(res, {
|
||||
message: `Successfully cleared the application cache. ${totalDeleted} keys were removed.`,
|
||||
details: {
|
||||
flyers: flyersDeleted,
|
||||
@@ -677,5 +695,4 @@ router.post(
|
||||
/* Catches errors from multer (e.g., file size, file filter) */
|
||||
router.use(handleMulterError);
|
||||
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -90,14 +90,14 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
vi.mocked(adminRepo.getApplicationStats).mockResolvedValue(mockStats);
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockStats);
|
||||
expect(response.body.data).toEqual(mockStats);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,14 +110,14 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
vi.mocked(adminRepo.getDailyStatsForLast30Days).mockResolvedValue(mockDailyStats);
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockDailyStats);
|
||||
expect(response.body.data).toEqual(mockDailyStats);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(adminRepo.getDailyStatsForLast30Days).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,14 +88,14 @@ describe('Admin System Routes (/api/admin/system)', () => {
|
||||
vi.mocked(geocodingService.clearGeocodeCache).mockResolvedValue(10);
|
||||
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toContain('10 keys were removed');
|
||||
expect(response.body.data.message).toContain('10 keys were removed');
|
||||
});
|
||||
|
||||
it('should return 500 if clearing the cache fails', async () => {
|
||||
vi.mocked(geocodingService.clearGeocodeCache).mockRejectedValue(new Error('Redis is down'));
|
||||
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Redis is down');
|
||||
expect(response.body.error.message).toContain('Redis is down');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
vi.mocked(adminRepo.getAllUsers).mockResolvedValue(mockUsers);
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUsers);
|
||||
expect(response.body.data).toEqual(mockUsers);
|
||||
expect(adminRepo.getAllUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUser);
|
||||
const response = await supertest(app).get(`/api/admin/users/${userId}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(response.body.data).toEqual(mockUser);
|
||||
expect(userRepo.findUserProfileById).toHaveBeenCalledWith(userId, expect.any(Object));
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
);
|
||||
const response = await supertest(app).get(`/api/admin/users/${missingId}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('User not found.');
|
||||
expect(response.body.error.message).toBe('User not found.');
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -160,7 +160,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
.put(`/api/admin/users/${userId}`)
|
||||
.send({ role: 'admin' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedUser);
|
||||
expect(response.body.data).toEqual(updatedUser);
|
||||
expect(adminRepo.updateUserRole).toHaveBeenCalledWith(userId, 'admin', expect.any(Object));
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
.put(`/api/admin/users/${missingId}`)
|
||||
.send({ role: 'user' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe(`User with ID ${missingId} not found.`);
|
||||
expect(response.body.error.message).toBe(`User with ID ${missingId} not found.`);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -183,7 +183,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
.put(`/api/admin/users/${userId}`)
|
||||
.send({ role: 'admin' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid role', async () => {
|
||||
@@ -201,7 +201,11 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
vi.mocked(userService.deleteUserAsAdmin).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, targetId, expect.any(Object));
|
||||
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(
|
||||
adminId,
|
||||
targetId,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent an admin from deleting their own account', async () => {
|
||||
@@ -209,9 +213,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(validationError);
|
||||
const response = await supertest(app).delete(`/api/admin/users/${adminId}`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toMatch(/Admins cannot delete their own account/);
|
||||
expect(response.body.error.message).toMatch(/Admins cannot delete their own account/);
|
||||
expect(userRepo.deleteUserById).not.toHaveBeenCalled();
|
||||
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, adminId, expect.any(Object));
|
||||
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(
|
||||
adminId,
|
||||
adminId,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
|
||||
@@ -151,7 +151,9 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const validChecksum = 'a'.repeat(64);
|
||||
|
||||
it('should enqueue a job and return 202 on success', async () => {
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-123' } as unknown as Job);
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
|
||||
id: 'job-123',
|
||||
} as unknown as Job);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
@@ -159,8 +161,8 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toBe('Flyer accepted for processing.');
|
||||
expect(response.body.jobId).toBe('job-123');
|
||||
expect(response.body.data.message).toBe('Flyer accepted for processing.');
|
||||
expect(response.body.data.jobId).toBe('job-123');
|
||||
expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -170,7 +172,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.field('checksum', validChecksum);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A flyer file (PDF or image) is required.');
|
||||
expect(response.body.error.message).toBe('A flyer file (PDF or image) is required.');
|
||||
});
|
||||
|
||||
it('should return 400 if checksum is missing', async () => {
|
||||
@@ -180,11 +182,14 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
// Use regex to be resilient to validation message changes
|
||||
expect(response.body.errors[0].message).toMatch(/checksum is required|Required/i);
|
||||
expect(response.body.error.details[0].message).toMatch(/checksum is required|Required/i);
|
||||
});
|
||||
|
||||
it('should return 409 if flyer checksum already exists', async () => {
|
||||
const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99);
|
||||
const duplicateError = new aiService.DuplicateFlyerError(
|
||||
'This flyer has already been processed.',
|
||||
99,
|
||||
);
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValue(duplicateError);
|
||||
|
||||
const response = await supertest(app)
|
||||
@@ -193,11 +198,13 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.message).toBe('This flyer has already been processed.');
|
||||
expect(response.body.error.message).toBe('This flyer has already been processed.');
|
||||
});
|
||||
|
||||
it('should return 500 if enqueuing the job fails', async () => {
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValueOnce(
|
||||
new Error('Redis connection failed'),
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
@@ -205,7 +212,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Redis connection failed');
|
||||
expect(response.body.error.message).toBe('Redis connection failed');
|
||||
});
|
||||
|
||||
it('should pass user ID to the job when authenticated', async () => {
|
||||
@@ -219,8 +226,10 @@ describe('AI Routes (/api/ai)', () => {
|
||||
basePath: '/api/ai',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-456' } as unknown as Job);
|
||||
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
|
||||
id: 'job-456',
|
||||
} as unknown as Job);
|
||||
|
||||
// Act
|
||||
await supertest(authenticatedApp)
|
||||
@@ -255,8 +264,10 @@ describe('AI Routes (/api/ai)', () => {
|
||||
basePath: '/api/ai',
|
||||
authenticatedUser: mockUserWithAddress,
|
||||
});
|
||||
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-789' } as unknown as Job);
|
||||
|
||||
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
|
||||
id: 'job-789',
|
||||
} as unknown as Job);
|
||||
|
||||
// Act
|
||||
await supertest(authenticatedApp)
|
||||
@@ -296,7 +307,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const response = await supertest(app).get('/api/ai/jobs/non-existent-job/status');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Job not found.');
|
||||
expect(response.body.error.message).toBe('Job not found.');
|
||||
});
|
||||
|
||||
it('should return job status if job is found', async () => {
|
||||
@@ -311,7 +322,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.state).toBe('completed');
|
||||
expect(response.body.data.state).toBe('completed');
|
||||
});
|
||||
|
||||
// Removed flaky test 'should return 400 for an invalid job ID format'
|
||||
@@ -343,7 +354,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyer);
|
||||
expect(response.body.data).toEqual(mockFlyer);
|
||||
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledWith(
|
||||
expect.any(Object), // req.file
|
||||
expect.any(Object), // req.body
|
||||
@@ -358,7 +369,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.field('some_legacy_field', 'value');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('No flyer file uploaded.');
|
||||
expect(response.body.error.message).toBe('No flyer file uploaded.');
|
||||
});
|
||||
|
||||
it('should return 409 and cleanup file if a duplicate flyer is detected', async () => {
|
||||
@@ -366,23 +377,29 @@ describe('AI Routes (/api/ai)', () => {
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.message).toBe('Duplicate legacy flyer.');
|
||||
expect(response.body.flyerId).toBe(101);
|
||||
expect(response.body.error.message).toBe('Duplicate legacy flyer.');
|
||||
expect(response.body.error.details.flyerId).toBe(101);
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return 500 and cleanup file on a generic service error', async () => {
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new Error('Internal service failure'));
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
|
||||
new Error('Internal service failure'),
|
||||
);
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Internal service failure');
|
||||
expect(response.body.error.message).toBe('Internal service failure');
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
@@ -412,7 +429,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.message).toBe('Flyer processed and saved successfully.');
|
||||
expect(response.body.data.message).toBe('Flyer processed and saved successfully.');
|
||||
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -425,7 +442,10 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should return 409 Conflict and delete the uploaded file if flyer checksum already exists', async () => {
|
||||
// Arrange
|
||||
const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99);
|
||||
const duplicateError = new aiService.DuplicateFlyerError(
|
||||
'This flyer has already been processed.',
|
||||
99,
|
||||
);
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
@@ -437,12 +457,14 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.message).toBe('This flyer has already been processed.');
|
||||
expect(response.body.error.message).toBe('This flyer has already been processed.');
|
||||
expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws
|
||||
// Assert that the file was deleted
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
// The filename is predictable in the test environment because of the multer config in ai.routes.ts
|
||||
expect(unlinkSpy).toHaveBeenCalledWith(expect.stringContaining('flyerImage-test-flyer-image.jpg'));
|
||||
expect(unlinkSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('flyerImage-test-flyer-image.jpg'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept payload when extractedData.items is missing and save with empty items', async () => {
|
||||
@@ -453,7 +475,9 @@ describe('AI Routes (/api/ai)', () => {
|
||||
extractedData: { store_name: 'Partial Store' }, // no items key
|
||||
};
|
||||
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 2 }));
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(
|
||||
createMockFlyer({ flyer_id: 2 }),
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
@@ -471,7 +495,9 @@ describe('AI Routes (/api/ai)', () => {
|
||||
extractedData: { items: [] }, // store_name missing
|
||||
};
|
||||
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 3 }));
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(
|
||||
createMockFlyer({ flyer_id: 3 }),
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
@@ -519,7 +545,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle payload where extractedData is null', async () => {
|
||||
it('should handle payload where extractedData is null', async () => {
|
||||
const payloadWithNullExtractedData = {
|
||||
checksum: 'null-extracted-data-checksum',
|
||||
originalFileName: 'flyer-null.jpg',
|
||||
@@ -590,10 +616,12 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
it('should handle malformed JSON in data field and return 400', async () => {
|
||||
const malformedDataString = '{"checksum":'; // Invalid JSON
|
||||
|
||||
|
||||
// Since the service parses the data, we mock it to throw a ValidationError when parsing fails
|
||||
// or when it detects the malformed input.
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new ValidationError([], 'Checksum is required.'));
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
|
||||
new ValidationError([], 'Checksum is required.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
@@ -603,8 +631,8 @@ describe('AI Routes (/api/ai)', () => {
|
||||
// The outer catch block should be hit, leading to empty parsed data.
|
||||
// The handler then fails the checksum validation.
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Checksum is required.');
|
||||
// Note: The logging expectation was removed because if the service throws a ValidationError,
|
||||
expect(response.body.error.message).toBe('Checksum is required.');
|
||||
// Note: The logging expectation was removed because if the service throws a ValidationError,
|
||||
// the route handler passes it to the global error handler, which might log differently or not as a "critical error during parsing" in the route itself.
|
||||
});
|
||||
|
||||
@@ -615,9 +643,11 @@ describe('AI Routes (/api/ai)', () => {
|
||||
};
|
||||
// Spy on fs.promises.unlink to verify file cleanup
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
|
||||
// Mock the service to throw a ValidationError because the checksum is missing
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new ValidationError([], 'Checksum is required.'));
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
|
||||
new ValidationError([], 'Checksum is required.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/flyers/process')
|
||||
@@ -625,7 +655,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('flyerImage', imagePath);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Checksum is required.');
|
||||
expect(response.body.error.message).toBe('Checksum is required.');
|
||||
// Ensure the uploaded file is cleaned up
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -643,7 +673,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
it('should return 200 with a stubbed response on success', async () => {
|
||||
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.is_flyer).toBe(true);
|
||||
expect(response.body.data.is_flyer).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic error', async () => {
|
||||
@@ -674,7 +704,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('image', imagePath)
|
||||
.field('extractionType', 'store_name'); // Missing cropArea
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(
|
||||
expect(response.body.error.details[0].message).toMatch(
|
||||
/cropArea must be a valid JSON string|Required/i,
|
||||
);
|
||||
});
|
||||
@@ -700,7 +730,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.post('/api/ai/extract-address')
|
||||
.attach('image', imagePath);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.address).toBe('not identified');
|
||||
expect(response.body.data.address).toBe('not identified');
|
||||
});
|
||||
|
||||
it('should return 500 on a generic error', async () => {
|
||||
@@ -728,7 +758,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.post('/api/ai/extract-logo')
|
||||
.attach('images', imagePath);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.store_logo_base_64).toBeNull();
|
||||
expect(response.body.data.store_logo_base_64).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 500 on a generic error', async () => {
|
||||
@@ -750,7 +780,11 @@ describe('AI Routes (/api/ai)', () => {
|
||||
const mockUser = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
||||
});
|
||||
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUser });
|
||||
const authenticatedApp = createTestApp({
|
||||
router: aiRouter,
|
||||
basePath: '/api/ai',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Inject an authenticated user for this test block
|
||||
@@ -771,7 +805,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('image', imagePath);
|
||||
// Use the authenticatedApp instance for requests in this block
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockResult);
|
||||
expect(response.body.data).toEqual(mockResult);
|
||||
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -788,27 +822,20 @@ describe('AI Routes (/api/ai)', () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
// The error message might be wrapped or formatted differently
|
||||
expect(response.body.message).toMatch(/AI API is down/i);
|
||||
expect(response.body.error.message).toMatch(/AI API is down/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user is authenticated', () => {
|
||||
const mockUserProfile = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
||||
});
|
||||
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUserProfile });
|
||||
// Note: authenticatedApp is available from the describe block above if needed
|
||||
|
||||
beforeEach(() => {
|
||||
// The authenticatedApp instance is already set up with mockUserProfile
|
||||
});
|
||||
|
||||
it('POST /quick-insights should return the stubbed response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/quick-insights')
|
||||
.send({ items: [{ name: 'test' }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.text).toContain('server-generated quick insight');
|
||||
expect(response.body.data.text).toContain('server-generated quick insight');
|
||||
});
|
||||
|
||||
it('POST /quick-insights should accept items with "item" property instead of "name"', async () => {
|
||||
@@ -835,20 +862,20 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.post('/api/ai/deep-dive')
|
||||
.send({ items: [{ name: 'test' }] });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.text).toContain('server-generated deep dive');
|
||||
expect(response.body.data.text).toContain('server-generated deep dive');
|
||||
});
|
||||
|
||||
it('POST /generate-image should return 501 Not Implemented', async () => {
|
||||
const response = await supertest(app).post('/api/ai/generate-image').send({ prompt: 'test' });
|
||||
|
||||
expect(response.status).toBe(501);
|
||||
expect(response.body.message).toBe('Image generation is not yet implemented.');
|
||||
expect(response.body.error.message).toBe('Image generation is not yet implemented.');
|
||||
});
|
||||
|
||||
it('POST /generate-speech should return 501 Not Implemented', async () => {
|
||||
const response = await supertest(app).post('/api/ai/generate-speech').send({ text: 'test' });
|
||||
expect(response.status).toBe(501);
|
||||
expect(response.body.message).toBe('Speech generation is not yet implemented.');
|
||||
expect(response.body.error.message).toBe('Speech generation is not yet implemented.');
|
||||
});
|
||||
|
||||
it('POST /search-web should return the stubbed response', async () => {
|
||||
@@ -857,7 +884,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.send({ query: 'test query' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.text).toContain('The web says this is good');
|
||||
expect(response.body.data.text).toContain('The web says this is good');
|
||||
});
|
||||
|
||||
it('POST /compare-prices should return the stubbed response', async () => {
|
||||
@@ -866,7 +893,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.send({ items: [{ name: 'Milk' }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.text).toContain('server-generated price comparison');
|
||||
expect(response.body.data.text).toContain('server-generated price comparison');
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return result on success', async () => {
|
||||
@@ -882,7 +909,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockResult);
|
||||
expect(response.body.data).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return 500 if the AI service fails', async () => {
|
||||
@@ -899,7 +926,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Maps API key invalid');
|
||||
expect(response.body.error.message).toBe('Maps API key invalid');
|
||||
});
|
||||
|
||||
it('POST /deep-dive should return 500 on a generic error', async () => {
|
||||
@@ -910,7 +937,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.post('/api/ai/deep-dive')
|
||||
.send({ items: [{ name: 'test' }] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Deep dive logging failed');
|
||||
expect(response.body.error.message).toBe('Deep dive logging failed');
|
||||
});
|
||||
|
||||
it('POST /search-web should return 500 on a generic error', async () => {
|
||||
@@ -921,7 +948,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.post('/api/ai/search-web')
|
||||
.send({ query: 'test query' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Search web logging failed');
|
||||
expect(response.body.error.message).toBe('Search web logging failed');
|
||||
});
|
||||
|
||||
it('POST /compare-prices should return 500 on a generic error', async () => {
|
||||
@@ -932,7 +959,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.post('/api/ai/compare-prices')
|
||||
.send({ items: [{ name: 'Milk' }] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Compare prices logging failed');
|
||||
expect(response.body.error.message).toBe('Compare prices logging failed');
|
||||
});
|
||||
|
||||
it('POST /quick-insights should return 400 if items are missing', async () => {
|
||||
|
||||
@@ -9,10 +9,7 @@ import { optionalAuth } from './passport.routes';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { aiService, DuplicateFlyerError } from '../services/aiService.server';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import {
|
||||
createUploadMiddleware,
|
||||
handleMulterError,
|
||||
} from '../middleware/multer.middleware';
|
||||
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
|
||||
import { logger } from '../services/logger.server'; // Needed for module-level logging (e.g., Zod schema transforms)
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { UserProfile } from '../types'; // This was a duplicate, fixed.
|
||||
@@ -26,6 +23,7 @@ import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -35,7 +33,8 @@ const uploadAndProcessSchema = z.object({
|
||||
body: z.object({
|
||||
// Stricter validation for SHA-256 checksum. It must be a 64-character hexadecimal string.
|
||||
checksum: requiredString('File checksum is required.').pipe(
|
||||
z.string()
|
||||
z
|
||||
.string()
|
||||
.length(64, 'Checksum must be 64 characters long.')
|
||||
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
|
||||
),
|
||||
@@ -96,8 +95,14 @@ const flyerItemForAnalysisSchema = z
|
||||
// Sanitize item and name by trimming whitespace.
|
||||
// The transform ensures that null/undefined values are preserved
|
||||
// while trimming any actual string values.
|
||||
item: z.string().nullish().transform(val => (val ? val.trim() : val)),
|
||||
name: z.string().nullish().transform(val => (val ? val.trim() : val)),
|
||||
item: z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((val) => (val ? val.trim() : val)),
|
||||
name: z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((val) => (val ? val.trim() : val)),
|
||||
})
|
||||
// Using .passthrough() allows extra properties on the item object.
|
||||
// If the intent is to strictly enforce only 'item' and 'name' (and other known properties),
|
||||
@@ -190,7 +195,12 @@ router.post(
|
||||
const { body } = uploadAndProcessSchema.parse({ body: req.body });
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
|
||||
return sendError(
|
||||
res,
|
||||
ErrorCode.BAD_REQUEST,
|
||||
'A flyer file (PDF or image) is required.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
req.log.debug(
|
||||
@@ -204,7 +214,7 @@ router.post(
|
||||
if (process.env.NODE_ENV === 'test' && !req.headers['authorization']) {
|
||||
userProfile = undefined;
|
||||
}
|
||||
|
||||
|
||||
const job = await aiService.enqueueFlyerProcessing(
|
||||
req.file,
|
||||
body.checksum,
|
||||
@@ -215,15 +225,19 @@ router.post(
|
||||
);
|
||||
|
||||
// Respond immediately to the client with 202 Accepted
|
||||
res.status(202).json({
|
||||
message: 'Flyer accepted for processing.',
|
||||
jobId: job.id,
|
||||
});
|
||||
sendSuccess(
|
||||
res,
|
||||
{
|
||||
message: 'Flyer accepted for processing.',
|
||||
jobId: job.id,
|
||||
},
|
||||
202,
|
||||
);
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(req.file);
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
req.log.warn(`Duplicate flyer upload attempt blocked for checksum: ${req.body?.checksum}`);
|
||||
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
|
||||
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -246,16 +260,21 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No flyer file uploaded.' });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, 'No flyer file uploaded.', 400);
|
||||
}
|
||||
const userProfile = req.user as UserProfile;
|
||||
const newFlyer = await aiService.processLegacyFlyerUpload(req.file, req.body, userProfile, req.log);
|
||||
res.status(200).json(newFlyer);
|
||||
const newFlyer = await aiService.processLegacyFlyerUpload(
|
||||
req.file,
|
||||
req.body,
|
||||
userProfile,
|
||||
req.log,
|
||||
);
|
||||
sendSuccess(res, newFlyer);
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(req.file);
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
req.log.warn(`Duplicate legacy flyer upload attempt blocked.`);
|
||||
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
|
||||
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -277,7 +296,7 @@ router.get(
|
||||
try {
|
||||
const jobStatus = await monitoringService.getFlyerJobStatus(jobId); // This was a duplicate, fixed.
|
||||
req.log.debug(`[API /ai/jobs] Status check for job ${jobId}: ${jobStatus.state}`);
|
||||
res.json(jobStatus);
|
||||
sendSuccess(res, jobStatus);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -300,7 +319,7 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Flyer image file is required.' });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, 'Flyer image file is required.', 400);
|
||||
}
|
||||
|
||||
const userProfile = req.user as UserProfile | undefined;
|
||||
@@ -312,12 +331,16 @@ router.post(
|
||||
req.log,
|
||||
);
|
||||
|
||||
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
|
||||
sendSuccess(
|
||||
res,
|
||||
{ message: 'Flyer processed and saved successfully.', flyer: newFlyer },
|
||||
201,
|
||||
);
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(req.file);
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
req.log.warn(`Duplicate flyer upload attempt blocked.`);
|
||||
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
|
||||
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -336,10 +359,10 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Image file is required.' });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
|
||||
}
|
||||
req.log.info(`Server-side flyer check for file: ${req.file.originalname}`);
|
||||
res.status(200).json({ is_flyer: true }); // Stubbed response
|
||||
sendSuccess(res, { is_flyer: true }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
} finally {
|
||||
@@ -356,10 +379,10 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Image file is required.' });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
|
||||
}
|
||||
req.log.info(`Server-side address extraction for file: ${req.file.originalname}`);
|
||||
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
|
||||
sendSuccess(res, { address: 'not identified' }); // Updated stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
} finally {
|
||||
@@ -376,10 +399,10 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
|
||||
return res.status(400).json({ message: 'Image files are required.' });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, 'Image files are required.', 400);
|
||||
}
|
||||
req.log.info(`Server-side logo extraction for ${req.files.length} image(s).`);
|
||||
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
|
||||
sendSuccess(res, { store_logo_base_64: null }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
} finally {
|
||||
@@ -396,9 +419,7 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
req.log.info(`Server-side quick insights requested.`);
|
||||
res
|
||||
.status(200)
|
||||
.json({ text: 'This is a server-generated quick insight: buy the cheap stuff!' }); // Stubbed response
|
||||
sendSuccess(res, { text: 'This is a server-generated quick insight: buy the cheap stuff!' }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -413,9 +434,9 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
req.log.info(`Server-side deep dive requested.`);
|
||||
res
|
||||
.status(200)
|
||||
.json({ text: 'This is a server-generated deep dive analysis. It is very detailed.' }); // Stubbed response
|
||||
sendSuccess(res, {
|
||||
text: 'This is a server-generated deep dive analysis. It is very detailed.',
|
||||
}); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -430,7 +451,7 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
req.log.info(`Server-side web search requested.`);
|
||||
res.status(200).json({ text: 'The web says this is good.', sources: [] }); // Stubbed response
|
||||
sendSuccess(res, { text: 'The web says this is good.', sources: [] }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -446,7 +467,7 @@ router.post(
|
||||
try {
|
||||
const { items } = req.body;
|
||||
req.log.info(`Server-side price comparison requested for ${items.length} items.`);
|
||||
res.status(200).json({
|
||||
sendSuccess(res, {
|
||||
text: 'This is a server-generated price comparison. Milk is cheaper at SuperMart.',
|
||||
sources: [],
|
||||
}); // Stubbed response
|
||||
@@ -466,7 +487,7 @@ router.post(
|
||||
const { items, store, userLocation } = req.body;
|
||||
req.log.debug({ itemCount: items.length, storeName: store.name }, 'Trip planning requested.');
|
||||
const result = await aiService.planTripWithMaps(items, store, userLocation);
|
||||
res.status(200).json(result);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
req.log.error({ error: errMsg(error) }, 'Error in /api/ai/plan-trip endpoint:');
|
||||
next(error);
|
||||
@@ -485,7 +506,7 @@ router.post(
|
||||
// This endpoint is a placeholder for a future feature.
|
||||
// Returning 501 Not Implemented is the correct HTTP response for this case.
|
||||
req.log.info('Request received for unimplemented endpoint: /api/ai/generate-image');
|
||||
res.status(501).json({ message: 'Image generation is not yet implemented.' });
|
||||
sendError(res, ErrorCode.NOT_IMPLEMENTED, 'Image generation is not yet implemented.', 501);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -498,7 +519,7 @@ router.post(
|
||||
// This endpoint is a placeholder for a future feature.
|
||||
// Returning 501 Not Implemented is the correct HTTP response for this case.
|
||||
req.log.info('Request received for unimplemented endpoint: /api/ai/generate-speech');
|
||||
res.status(501).json({ message: 'Speech generation is not yet implemented.' });
|
||||
sendError(res, ErrorCode.NOT_IMPLEMENTED, 'Speech generation is not yet implemented.', 501);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -515,7 +536,7 @@ router.post(
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Image file is required.' });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
|
||||
}
|
||||
// validateRequest transforms the cropArea JSON string into an object in req.body.
|
||||
// So we use it directly instead of JSON.parse().
|
||||
@@ -536,7 +557,7 @@ router.post(
|
||||
req.log,
|
||||
);
|
||||
|
||||
res.status(200).json(result);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
} finally {
|
||||
|
||||
@@ -137,9 +137,9 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.message).toBe('User registered successfully!');
|
||||
expect(response.body.userprofile.user.email).toBe(newUserEmail);
|
||||
expect(response.body.token).toBeTypeOf('string'); // This was a duplicate, fixed.
|
||||
expect(response.body.data.message).toBe('User registered successfully!');
|
||||
expect(response.body.data.userprofile.user.email).toBe(newUserEmail);
|
||||
expect(response.body.data.token).toBeTypeOf('string'); // This was a duplicate, fixed.
|
||||
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||
newUserEmail,
|
||||
strongPassword,
|
||||
@@ -171,7 +171,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.message).toBe('User registered successfully!');
|
||||
expect(response.body.data.message).toBe('User registered successfully!');
|
||||
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||
email,
|
||||
strongPassword,
|
||||
@@ -242,7 +242,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
interface ZodError {
|
||||
message: string;
|
||||
}
|
||||
const errorMessages = response.body.errors?.map((e: ZodError) => e.message).join(' ');
|
||||
const errorMessages = response.body.error.details?.map((e: ZodError) => e.message).join(' ');
|
||||
expect(errorMessages).toMatch(/Password is too weak/i);
|
||||
});
|
||||
|
||||
@@ -260,7 +260,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: newUserEmail, password: strongPassword });
|
||||
|
||||
expect(response.status).toBe(409); // 409 Conflict
|
||||
expect(response.body.message).toBe('User with that email already exists.');
|
||||
expect(response.body.error.message).toBe('User with that email already exists.');
|
||||
});
|
||||
|
||||
it('should return 500 if a generic database error occurs during registration', async () => {
|
||||
@@ -272,7 +272,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'fail@test.com', password: strongPassword });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB connection lost'); // The errorHandler will forward the message
|
||||
expect(response.body.error.message).toBe('DB connection lost'); // The errorHandler will forward the message
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid email format', async () => {
|
||||
@@ -281,7 +281,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'not-an-email', password: strongPassword });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('A valid email is required.');
|
||||
expect(response.body.error.details[0].message).toBe('A valid email is required.');
|
||||
});
|
||||
|
||||
it('should return 400 for a password that is too short', async () => {
|
||||
@@ -290,7 +290,9 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: newUserEmail, password: 'short' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('Password must be at least 8 characters long.');
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'Password must be at least 8 characters long.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,7 +311,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
// The API now returns a nested UserProfile object
|
||||
expect(response.body.userprofile).toEqual(
|
||||
expect(response.body.data.userprofile).toEqual(
|
||||
expect.objectContaining({
|
||||
user: expect.objectContaining({
|
||||
user_id: 'user-123',
|
||||
@@ -317,7 +319,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.body.token).toBeTypeOf('string');
|
||||
expect(response.body.data.token).toBeTypeOf('string');
|
||||
expect(response.headers['set-cookie']).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -327,7 +329,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'test@test.com', password: 'wrong_password' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.message).toBe('Incorrect email or password.');
|
||||
expect(response.body.error.message).toBe('Incorrect email or password.');
|
||||
});
|
||||
|
||||
it('should reject login for a locked account', async () => {
|
||||
@@ -336,7 +338,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'locked@test.com', password: 'password123' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.message).toBe(
|
||||
expect(response.body.error.message).toBe(
|
||||
'Account is temporarily locked. Please try again in 15 minutes.',
|
||||
);
|
||||
});
|
||||
@@ -371,7 +373,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'dberror@test.com', password: 'any_password' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Database connection failed');
|
||||
expect(response.body.error.message).toBe('Database connection failed');
|
||||
});
|
||||
|
||||
it('should log a warning when passport authentication fails without a user', async () => {
|
||||
@@ -414,7 +416,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'not-an-email', password: 'password123' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('A valid email is required.');
|
||||
expect(response.body.error.details[0].message).toBe('A valid email is required.');
|
||||
});
|
||||
|
||||
it('should return 400 if password is missing', async () => {
|
||||
@@ -423,7 +425,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'test@test.com' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('Password is required.');
|
||||
expect(response.body.error.details[0].message).toBe('Password is required.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,8 +441,8 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toContain('a password reset link has been sent'); // This was a duplicate, fixed.
|
||||
expect(response.body.token).toBeTypeOf('string');
|
||||
expect(response.body.data.message).toContain('a password reset link has been sent'); // This was a duplicate, fixed.
|
||||
expect(response.body.data.token).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('should return a generic success message even if the user does not exist', async () => {
|
||||
@@ -451,7 +453,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'nouser@test.com' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toContain('a password reset link has been sent');
|
||||
expect(response.body.data.message).toContain('a password reset link has been sent');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -469,7 +471,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'invalid-email' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('A valid email is required.');
|
||||
expect(response.body.error.details[0].message).toBe('A valid email is required.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,7 +484,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ token: 'valid-token', newPassword: 'a-Very-Strong-Password-789!' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Password has been reset successfully.');
|
||||
expect(response.body.data.message).toBe('Password has been reset successfully.');
|
||||
});
|
||||
|
||||
it('should reject with an invalid or expired token', async () => {
|
||||
@@ -493,7 +495,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ token: 'invalid-token', newPassword: 'a-Very-Strong-Password-123!' }); // Use strong password to pass validation
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid or expired password reset token.');
|
||||
expect(response.body.error.message).toBe('Invalid or expired password reset token.');
|
||||
});
|
||||
|
||||
it('should return 400 for a weak new password', async () => {
|
||||
@@ -511,7 +513,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ newPassword: 'a-Very-Strong-Password-789!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/Token is required|Required/i);
|
||||
expect(response.body.error.details[0].message).toMatch(/Token is required|Required/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -524,13 +526,13 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.set('Cookie', 'refreshToken=valid-refresh-token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.token).toBeTypeOf('string');
|
||||
expect(response.body.data.token).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('should return 401 if no refresh token cookie is provided', async () => {
|
||||
const response = await supertest(app).post('/api/auth/refresh-token');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.message).toBe('Refresh token not found.');
|
||||
expect(response.body.error.message).toBe('Refresh token not found.');
|
||||
});
|
||||
|
||||
it('should return 403 if refresh token is invalid', async () => {
|
||||
@@ -552,7 +554,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.post('/api/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=any-token');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toMatch(/DB Error/);
|
||||
expect(response.body.error.message).toMatch(/DB Error/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -568,7 +570,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully.');
|
||||
expect(response.body.data.message).toBe('Logged out successfully.');
|
||||
|
||||
// Check that the 'set-cookie' header is trying to expire the cookie
|
||||
const setCookieHeader = response.headers['set-cookie'];
|
||||
@@ -616,7 +618,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting on /forgot-password', () => {
|
||||
describe('Rate Limiting on /forgot-password', () => {
|
||||
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||
// Arrange
|
||||
const email = 'rate-limit-test@example.com';
|
||||
@@ -658,7 +660,7 @@ describe('Rate Limiting on /forgot-password', () => {
|
||||
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting on /reset-password', () => {
|
||||
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
refreshTokenLimiter,
|
||||
logoutLimiter,
|
||||
} from '../config/rateLimiters';
|
||||
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
|
||||
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { authService } from '../services/authService';
|
||||
@@ -103,13 +104,19 @@ router.post(
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
return res
|
||||
.status(201)
|
||||
.json({ message: 'User registered successfully!', userprofile: newUserProfile, token: accessToken });
|
||||
return sendSuccess(
|
||||
res,
|
||||
{
|
||||
message: 'User registered successfully!',
|
||||
userprofile: newUserProfile,
|
||||
token: accessToken,
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
// If the email is a duplicate, return a 409 Conflict status.
|
||||
return res.status(409).json({ message: error.message });
|
||||
return sendError(res, ErrorCode.CONFLICT, error.message, 409);
|
||||
}
|
||||
req.log.error({ error }, `User registration route failed for email: ${email}.`);
|
||||
// Pass the error to the centralized handler
|
||||
@@ -143,13 +150,16 @@ router.post(
|
||||
return next(err);
|
||||
}
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: info.message || 'Login failed' });
|
||||
return sendError(res, ErrorCode.UNAUTHORIZED, info.message || 'Login failed', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { rememberMe } = req.body;
|
||||
const userProfile = user as UserProfile;
|
||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
|
||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
|
||||
userProfile,
|
||||
req.log,
|
||||
);
|
||||
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||
|
||||
const cookieOptions = {
|
||||
@@ -160,7 +170,7 @@ router.post(
|
||||
|
||||
res.cookie('refreshToken', refreshToken, cookieOptions);
|
||||
// Return the full user profile object on login to avoid a second fetch on the client.
|
||||
return res.json({ userprofile: userProfile, token: accessToken });
|
||||
return sendSuccess(res, { userprofile: userProfile, token: accessToken });
|
||||
} catch (tokenErr) {
|
||||
const email = (user as UserProfile)?.user?.email || req.body.email;
|
||||
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
|
||||
@@ -191,7 +201,7 @@ router.post(
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
};
|
||||
if (process.env.NODE_ENV === 'test' && token) responsePayload.token = token;
|
||||
res.status(200).json(responsePayload);
|
||||
sendSuccess(res, responsePayload);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
|
||||
next(error);
|
||||
@@ -214,10 +224,15 @@ router.post(
|
||||
const resetSuccessful = await authService.updatePassword(token, newPassword, req.log);
|
||||
|
||||
if (!resetSuccessful) {
|
||||
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
|
||||
return sendError(
|
||||
res,
|
||||
ErrorCode.BAD_REQUEST,
|
||||
'Invalid or expired password reset token.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Password has been reset successfully.' });
|
||||
sendSuccess(res, { message: 'Password has been reset successfully.' });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `An error occurred during password reset.`);
|
||||
next(error);
|
||||
@@ -226,23 +241,27 @@ router.post(
|
||||
);
|
||||
|
||||
// New Route to refresh the access token
|
||||
router.post('/refresh-token', refreshTokenLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.refreshAccessToken(refreshToken, req.log);
|
||||
if (!result) {
|
||||
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
|
||||
router.post(
|
||||
'/refresh-token',
|
||||
refreshTokenLimiter,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return sendError(res, ErrorCode.UNAUTHORIZED, 'Refresh token not found.', 401);
|
||||
}
|
||||
res.json({ token: result.accessToken });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'An error occurred during /refresh-token.');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await authService.refreshAccessToken(refreshToken, req.log);
|
||||
if (!result) {
|
||||
return sendError(res, ErrorCode.FORBIDDEN, 'Invalid or expired refresh token.', 403);
|
||||
}
|
||||
sendSuccess(res, { token: result.accessToken });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'An error occurred during /refresh-token.');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout - Logs the user out by invalidating their refresh token.
|
||||
@@ -264,7 +283,7 @@ router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
|
||||
maxAge: 0, // Use maxAge for modern compatibility; Express sets 'Expires' as a fallback.
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
res.status(200).json({ message: 'Logged out successfully.' });
|
||||
sendSuccess(res, { message: 'Logged out successfully.' });
|
||||
});
|
||||
|
||||
// --- OAuth Routes ---
|
||||
|
||||
@@ -69,7 +69,11 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const app = createTestApp({ router: budgetRouter, basePath: '/api/budgets', authenticatedUser: mockUserProfile });
|
||||
const app = createTestApp({
|
||||
router: budgetRouter,
|
||||
basePath: '/api/budgets',
|
||||
authenticatedUser: mockUserProfile,
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should return a list of budgets for the user', async () => {
|
||||
@@ -80,7 +84,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
const response = await supertest(app).get('/api/budgets');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockBudgets);
|
||||
expect(response.body.data).toEqual(mockBudgets);
|
||||
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(
|
||||
mockUserProfile.user.user_id,
|
||||
expectLogger,
|
||||
@@ -91,7 +95,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.getBudgetsForUser).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/budgets');
|
||||
expect(response.status).toBe(500); // The custom handler will now be used
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +118,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockCreatedBudget);
|
||||
expect(response.body.data).toEqual(mockCreatedBudget);
|
||||
});
|
||||
|
||||
it('should return 400 if the user does not exist', async () => {
|
||||
@@ -129,7 +133,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
);
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('User not found');
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
});
|
||||
|
||||
it('should return 500 if a generic database error occurs', async () => {
|
||||
@@ -142,7 +146,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid budget data', async () => {
|
||||
@@ -156,7 +160,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
const response = await supertest(app).post('/api/budgets').send(invalidData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toHaveLength(4);
|
||||
expect(response.body.error.details).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should return 400 if required fields are missing', async () => {
|
||||
@@ -165,7 +169,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
.post('/api/budgets')
|
||||
.send({ amount_cents: 10000, period: 'monthly', start_date: '2024-01-01' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('Budget name is required.');
|
||||
expect(response.body.error.details[0].message).toBe('Budget name is required.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,7 +187,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedBudget);
|
||||
expect(response.body.data).toEqual(mockUpdatedBudget);
|
||||
});
|
||||
|
||||
it('should return 404 if the budget is not found', async () => {
|
||||
@@ -192,7 +196,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
);
|
||||
const response = await supertest(app).put('/api/budgets/999').send({ amount_cents: 1 });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Budget not found');
|
||||
expect(response.body.error.message).toBe('Budget not found');
|
||||
});
|
||||
|
||||
it('should return 500 if a generic database error occurs', async () => {
|
||||
@@ -200,13 +204,13 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
|
||||
expect(response.status).toBe(500); // The custom handler will now be used
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 if no update fields are provided', async () => {
|
||||
const response = await supertest(app).put('/api/budgets/1').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe(
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
@@ -214,7 +218,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
it('should return 400 for an invalid budget ID', async () => {
|
||||
const response = await supertest(app).put('/api/budgets/abc').send({ amount_cents: 5000 });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/Invalid ID|number/i);
|
||||
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,20 +243,20 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
);
|
||||
const response = await supertest(app).delete('/api/budgets/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Budget not found');
|
||||
expect(response.body.error.message).toBe('Budget not found');
|
||||
});
|
||||
|
||||
it('should return 500 if a generic database error occurs', async () => {
|
||||
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).delete('/api/budgets/1');
|
||||
expect(response.status).toBe(500); // The custom handler will now be used
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid budget ID', async () => {
|
||||
const response = await supertest(app).delete('/api/budgets/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/Invalid ID|number/i);
|
||||
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,7 +273,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockSpendingData);
|
||||
expect(response.body.data).toEqual(mockSpendingData);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -281,7 +285,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid date formats', async () => {
|
||||
@@ -289,14 +293,14 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
'/api/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toHaveLength(2);
|
||||
expect(response.body.error.details).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return 400 if required query parameters are missing', async () => {
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis');
|
||||
expect(response.status).toBe(400);
|
||||
// Expect errors for both startDate and endDate
|
||||
expect(response.body.errors).toHaveLength(2);
|
||||
expect(response.body.error.details).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,15 @@ import type { UserProfile } from '../types';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { requiredString, numericIdParam } from '../utils/zodUtils';
|
||||
import { budgetUpdateLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
|
||||
const budgetIdParamSchema = numericIdParam('id', "Invalid ID for parameter 'id'. Must be a number.");
|
||||
const budgetIdParamSchema = numericIdParam(
|
||||
'id',
|
||||
"Invalid ID for parameter 'id'. Must be a number.",
|
||||
);
|
||||
|
||||
const createBudgetSchema = z.object({
|
||||
body: z.object({
|
||||
@@ -48,7 +52,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const budgets = await budgetRepo.getBudgetsForUser(userProfile.user.user_id, req.log);
|
||||
res.json(budgets);
|
||||
sendSuccess(res, budgets);
|
||||
} catch (error) {
|
||||
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching budgets');
|
||||
next(error);
|
||||
@@ -67,7 +71,7 @@ router.post(
|
||||
const { body } = req as unknown as CreateBudgetRequest;
|
||||
try {
|
||||
const newBudget = await budgetRepo.createBudget(userProfile.user.user_id, body, req.log);
|
||||
res.status(201).json(newBudget);
|
||||
sendSuccess(res, newBudget, 201);
|
||||
} catch (error: unknown) {
|
||||
req.log.error({ error, userId: userProfile.user.user_id, body }, 'Error creating budget');
|
||||
next(error);
|
||||
@@ -92,7 +96,7 @@ router.put(
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
res.json(updatedBudget);
|
||||
sendSuccess(res, updatedBudget);
|
||||
} catch (error: unknown) {
|
||||
req.log.error(
|
||||
{ error, userId: userProfile.user.user_id, budgetId: params.id },
|
||||
@@ -115,7 +119,7 @@ router.delete(
|
||||
const { params } = req as unknown as DeleteBudgetRequest;
|
||||
try {
|
||||
await budgetRepo.deleteBudget(params.id, userProfile.user.user_id, req.log);
|
||||
res.status(204).send(); // No Content
|
||||
sendNoContent(res);
|
||||
} catch (error: unknown) {
|
||||
req.log.error(
|
||||
{ error, userId: userProfile.user.user_id, budgetId: params.id },
|
||||
@@ -147,7 +151,7 @@ router.get(
|
||||
endDate,
|
||||
req.log,
|
||||
);
|
||||
res.json(spendingData);
|
||||
sendSuccess(res, spendingData);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, userId: userProfile.user.user_id, startDate, endDate },
|
||||
|
||||
@@ -27,16 +27,14 @@ vi.mock('../services/logger.server', async () => ({
|
||||
// Mock the passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(
|
||||
(_strategy, _options) => (req: Request, res: Response, next: NextFunction) => {
|
||||
// If req.user is not set by the test setup, simulate unauthenticated access.
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
// If req.user is set, proceed as an authenticated user.
|
||||
next();
|
||||
},
|
||||
),
|
||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
// If req.user is not set by the test setup, simulate unauthenticated access.
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
// If req.user is set, proceed as an authenticated user.
|
||||
next();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -77,7 +75,7 @@ describe('Deals Routes (/api/users/deals)', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockDeals);
|
||||
expect(response.body.data).toEqual(mockDeals);
|
||||
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
|
||||
mockUser.user.user_id,
|
||||
expectLogger,
|
||||
@@ -96,7 +94,7 @@ describe('Deals Routes (/api/users/deals)', () => {
|
||||
'/api/users/deals/best-watched-prices',
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching best watched item deals.',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dealsRepo } from '../services/db/deals.db';
|
||||
import type { UserProfile } from '../types';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { userReadLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -40,7 +41,7 @@ router.get(
|
||||
req.log,
|
||||
);
|
||||
req.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
|
||||
res.status(200).json(deals);
|
||||
sendSuccess(res, deals);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching best watched item deals.');
|
||||
next(error); // Pass errors to the global error handler
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
const response = await supertest(app).get('/api/flyers');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyers);
|
||||
expect(response.body.data).toEqual(mockFlyers);
|
||||
// Also assert that the default limit and offset were used.
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 0);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching flyers in /api/flyers:',
|
||||
@@ -87,8 +87,8 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
const response = await supertest(app).get('/api/flyers?limit=abc&offset=-5');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors.length).toBe(2);
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
const response = await supertest(app).get('/api/flyers/123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyer);
|
||||
expect(response.body.data).toEqual(mockFlyer);
|
||||
expect(db.flyerRepo.getFlyerById).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
@@ -114,14 +114,14 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
const response = await supertest(app).get('/api/flyers/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found');
|
||||
expect(response.body.error.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyer ID', async () => {
|
||||
const response = await supertest(app).get('/api/flyers/abc');
|
||||
expect(response.status).toBe(400);
|
||||
// Zod coercion results in NaN for "abc", which triggers a type error before our custom message
|
||||
expect(response.body.errors[0].message).toMatch(
|
||||
expect(response.body.error.details[0].message).toMatch(
|
||||
/Invalid flyer ID provided|expected number, received NaN/,
|
||||
);
|
||||
});
|
||||
@@ -131,7 +131,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers/123');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError, flyerId: 123 },
|
||||
'Error fetching flyer by ID:',
|
||||
@@ -147,13 +147,13 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
const response = await supertest(app).get('/api/flyers/123/items');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyerItems);
|
||||
expect(response.body.data).toEqual(mockFlyerItems);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyer ID', async () => {
|
||||
const response = await supertest(app).get('/api/flyers/abc/items');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(
|
||||
expect(response.body.error.details[0].message).toMatch(
|
||||
/Invalid flyer ID provided|expected number, received NaN/,
|
||||
);
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers/123/items');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError, flyerId: 123 },
|
||||
'Error fetching flyer items in /api/flyers/:id/items:',
|
||||
@@ -181,7 +181,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.send({ flyerIds: [1, 2] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyerItems);
|
||||
expect(response.body.data).toEqual(mockFlyerItems);
|
||||
});
|
||||
|
||||
it('should return 400 if flyerIds is not an array', async () => {
|
||||
@@ -189,7 +189,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.post('/api/flyers/items/batch-fetch')
|
||||
.send({ flyerIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/expected array/);
|
||||
expect(response.body.error.details[0].message).toMatch(/expected array/);
|
||||
});
|
||||
|
||||
it('should return 400 if flyerIds is an empty array, as per schema validation', async () => {
|
||||
@@ -198,7 +198,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.send({ flyerIds: [] });
|
||||
expect(response.status).toBe(400);
|
||||
// Check for the specific Zod error message.
|
||||
expect(response.body.errors[0].message).toBe('flyerIds must be a non-empty array.');
|
||||
expect(response.body.error.details[0].message).toBe('flyerIds must be a non-empty array.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -207,7 +207,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.post('/api/flyers/items/batch-fetch')
|
||||
.send({ flyerIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,7 +220,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.send({ flyerIds: [1, 2, 3] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ count: 42 });
|
||||
expect(response.body.data).toEqual({ count: 42 });
|
||||
});
|
||||
|
||||
it('should return 400 if flyerIds is not an array', async () => {
|
||||
@@ -237,7 +237,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.send({ flyerIds: [] });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ count: 0 });
|
||||
expect(response.body.data).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -246,7 +246,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.send({ flyerIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,7 +317,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
const response = await supertest(app)
|
||||
.get('/api/flyers')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true');
|
||||
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers).toHaveProperty('ratelimit-limit');
|
||||
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
||||
@@ -350,7 +350,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
it('should apply trackingLimiter to POST /items/:itemId/track', async () => {
|
||||
// Mock fire-and-forget promise
|
||||
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockResolvedValue(undefined);
|
||||
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/1/track')
|
||||
.set('X-Test-Rate-Limit-Enable', 'true')
|
||||
|
||||
@@ -4,11 +4,8 @@ import * as db from '../services/db/index.db';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { optionalNumeric } from '../utils/zodUtils';
|
||||
import {
|
||||
publicReadLimiter,
|
||||
batchLimiter,
|
||||
trackingLimiter,
|
||||
} from '../config/rateLimiters';
|
||||
import { publicReadLimiter, batchLimiter, trackingLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -53,34 +50,44 @@ const trackItemSchema = z.object({
|
||||
/**
|
||||
* GET /api/flyers - Get a paginated list of all flyers.
|
||||
*/
|
||||
router.get('/', publicReadLimiter, validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
|
||||
try {
|
||||
// The `validateRequest` middleware ensures `req.query` is valid.
|
||||
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
||||
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
|
||||
router.get(
|
||||
'/',
|
||||
publicReadLimiter,
|
||||
validateRequest(getFlyersSchema),
|
||||
async (req, res, next): Promise<void> => {
|
||||
try {
|
||||
// The `validateRequest` middleware ensures `req.query` is valid.
|
||||
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
||||
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
|
||||
|
||||
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
|
||||
res.json(flyers);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
|
||||
sendSuccess(res, flyers);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/flyers/:id - Get a single flyer by its ID.
|
||||
*/
|
||||
router.get('/:id', publicReadLimiter, validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
|
||||
try {
|
||||
// Explicitly parse to get the coerced number type for `id`.
|
||||
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||
const flyer = await db.flyerRepo.getFlyerById(id);
|
||||
res.json(flyer);
|
||||
} catch (error) {
|
||||
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/:id',
|
||||
publicReadLimiter,
|
||||
validateRequest(flyerIdParamSchema),
|
||||
async (req, res, next): Promise<void> => {
|
||||
try {
|
||||
// Explicitly parse to get the coerced number type for `id`.
|
||||
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||
const flyer = await db.flyerRepo.getFlyerById(id);
|
||||
sendSuccess(res, flyer);
|
||||
} catch (error) {
|
||||
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/flyers/:id/items - Get all items for a specific flyer.
|
||||
@@ -90,14 +97,16 @@ router.get(
|
||||
publicReadLimiter,
|
||||
validateRequest(flyerIdParamSchema),
|
||||
async (req, res, next): Promise<void> => {
|
||||
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
|
||||
try {
|
||||
// Explicitly parse to get the coerced number type for `id`.
|
||||
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||
const items = await db.flyerRepo.getFlyerItems(id, req.log);
|
||||
res.json(items);
|
||||
sendSuccess(res, items);
|
||||
} catch (error) {
|
||||
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer items in /api/flyers/:id/items:');
|
||||
req.log.error(
|
||||
{ error, flyerId: req.params.id },
|
||||
'Error fetching flyer items in /api/flyers/:id/items:',
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -117,7 +126,7 @@ router.post(
|
||||
// No re-parsing needed here as `validateRequest` has already ensured the body shape,
|
||||
// and `express.json()` has parsed it. There's no type coercion to apply.
|
||||
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
res.json(items);
|
||||
sendSuccess(res, items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching batch flyer items');
|
||||
next(error);
|
||||
@@ -139,7 +148,7 @@ router.post(
|
||||
// The schema ensures flyerIds is an array of numbers.
|
||||
// The `?? []` was redundant as `validateRequest` would have already caught a missing `flyerIds`.
|
||||
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
res.json({ count });
|
||||
sendSuccess(res, { count });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error counting batch flyer items');
|
||||
next(error);
|
||||
@@ -150,22 +159,27 @@ router.post(
|
||||
/**
|
||||
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
|
||||
*/
|
||||
router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSchema), (req, res, next): void => {
|
||||
try {
|
||||
// Explicitly parse to get coerced types.
|
||||
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
|
||||
router.post(
|
||||
'/items/:itemId/track',
|
||||
trackingLimiter,
|
||||
validateRequest(trackItemSchema),
|
||||
(req, res, next): void => {
|
||||
try {
|
||||
// Explicitly parse to get coerced types.
|
||||
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
|
||||
|
||||
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
|
||||
// We add a .catch to log any potential errors without crashing the server process.
|
||||
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
|
||||
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
|
||||
});
|
||||
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
|
||||
// We add a .catch to log any potential errors without crashing the server process.
|
||||
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
|
||||
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
|
||||
});
|
||||
|
||||
res.status(202).send();
|
||||
} catch (error) {
|
||||
// This will catch Zod parsing errors if they occur.
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
sendSuccess(res, { message: 'Tracking accepted' }, 202);
|
||||
} catch (error) {
|
||||
// This will catch Zod parsing errors if they occur.
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createMockUserAchievement,
|
||||
createMockLeaderboardUser,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import '../tests/utils/mockLogger';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAchievements);
|
||||
expect(response.body.data).toEqual(mockAchievements);
|
||||
expect(db.gamificationRepo.getAllAchievements).toHaveBeenCalledWith(expectLogger);
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Connection Failed');
|
||||
expect(response.body.error.message).toBe('DB Connection Failed');
|
||||
});
|
||||
|
||||
it('should return 400 if awarding an achievement to a non-existent user', async () => {
|
||||
@@ -125,7 +125,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
.post('/api/achievements/award')
|
||||
.send({ userId: 'non-existent', achievementName: 'Test Award' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('User not found');
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
const response = await supertest(authenticatedApp).get('/api/achievements/me');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUserAchievements);
|
||||
expect(response.body.data).toEqual(mockUserAchievements);
|
||||
expect(db.gamificationRepo.getUserAchievements).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
expectLogger,
|
||||
@@ -167,7 +167,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
vi.mocked(db.gamificationRepo.getUserAchievements).mockRejectedValue(dbError);
|
||||
const response = await supertest(authenticatedApp).get('/api/achievements/me');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toContain('Successfully awarded');
|
||||
expect(response.body.data.message).toContain('Successfully awarded');
|
||||
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledTimes(1);
|
||||
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledWith(
|
||||
awardPayload.userId,
|
||||
@@ -226,7 +226,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
|
||||
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid userId or achievementName', async () => {
|
||||
@@ -240,7 +240,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
.post('/api/achievements/award')
|
||||
.send({ userId: '', achievementName: '' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toHaveLength(2);
|
||||
expect(response.body.error.details).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return 400 if userId or achievementName are missing', async () => {
|
||||
@@ -254,13 +254,13 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
.post('/api/achievements/award')
|
||||
.send({ achievementName: 'Test Award' });
|
||||
expect(response1.status).toBe(400);
|
||||
expect(response1.body.errors[0].message).toBe('userId is required.');
|
||||
expect(response1.body.error.details[0].message).toBe('userId is required.');
|
||||
|
||||
const response2 = await supertest(adminApp)
|
||||
.post('/api/achievements/award')
|
||||
.send({ userId: 'user-789' });
|
||||
expect(response2.status).toBe(400);
|
||||
expect(response2.body.errors[0].message).toBe('achievementName is required.');
|
||||
expect(response2.body.error.details[0].message).toBe('achievementName is required.');
|
||||
});
|
||||
|
||||
it('should return 400 if awarding an achievement to a non-existent user', async () => {
|
||||
@@ -277,7 +277,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
.post('/api/achievements/award')
|
||||
.send({ userId: 'non-existent', achievementName: 'Test Award' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('User not found');
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,7 +298,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockLeaderboard);
|
||||
expect(response.body.data).toEqual(mockLeaderboard);
|
||||
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(5, expect.anything());
|
||||
});
|
||||
|
||||
@@ -316,7 +316,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockLeaderboard);
|
||||
expect(response.body.data).toEqual(mockLeaderboard);
|
||||
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(10, expect.anything());
|
||||
});
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid limit parameter', async () => {
|
||||
@@ -332,8 +332,8 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
'/api/achievements/leaderboard?limit=100',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toMatch(/less than or equal to 50|Too big/i);
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details[0].message).toMatch(/less than or equal to 50|Too big/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,11 +13,8 @@ import { validateRequest } from '../middleware/validation.middleware';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { requiredString, optionalNumeric } from '../utils/zodUtils';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import {
|
||||
publicReadLimiter,
|
||||
userReadLimiter,
|
||||
adminTriggerLimiter,
|
||||
} from '../config/rateLimiters';
|
||||
import { publicReadLimiter, userReadLimiter, adminTriggerLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = express.Router();
|
||||
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
||||
@@ -48,7 +45,7 @@ const awardAchievementSchema = z.object({
|
||||
router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const achievements = await gamificationService.getAllAchievements(req.log);
|
||||
res.json(achievements);
|
||||
sendSuccess(res, achievements);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching all achievements in /api/achievements:');
|
||||
next(error);
|
||||
@@ -69,7 +66,7 @@ router.get(
|
||||
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
||||
const { limit } = leaderboardQuerySchema.parse(req.query);
|
||||
const leaderboard = await gamificationService.getLeaderboard(limit!, req.log);
|
||||
res.json(leaderboard);
|
||||
sendSuccess(res, leaderboard);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching leaderboard:');
|
||||
next(error);
|
||||
@@ -94,7 +91,7 @@ router.get(
|
||||
userProfile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
res.json(userAchievements);
|
||||
sendSuccess(res, userAchievements);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, userId: userProfile.user.user_id },
|
||||
@@ -124,11 +121,9 @@ adminGamificationRouter.post(
|
||||
const { body } = req as unknown as AwardAchievementRequest;
|
||||
try {
|
||||
await gamificationService.awardAchievement(body.userId, body.achievementName, req.log);
|
||||
res
|
||||
.status(200)
|
||||
.json({
|
||||
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
|
||||
});
|
||||
sendSuccess(res, {
|
||||
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('pong');
|
||||
expect(response.body.data.message).toBe('pong');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,10 +78,8 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert: Check for the correct status and response body.
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
message: 'Redis connection is healthy.',
|
||||
});
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.message).toBe('Redis connection is healthy.');
|
||||
});
|
||||
|
||||
it('should return 500 if Redis ping fails', async () => {
|
||||
@@ -94,7 +92,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Connection timed out');
|
||||
expect(response.body.error.message).toBe('Connection timed out');
|
||||
});
|
||||
|
||||
it('should return 500 if Redis ping returns an unexpected response', async () => {
|
||||
@@ -106,7 +104,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
|
||||
expect(response.body.error.message).toContain('Unexpected Redis ping response: OK');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,9 +120,9 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.currentTime).toBe('2024-03-15T10:30:00.000Z');
|
||||
expect(response.body.year).toBe(2024);
|
||||
expect(response.body.week).toBe(11);
|
||||
expect(response.body.data.currentTime).toBe('2024-03-15T10:30:00.000Z');
|
||||
expect(response.body.data.year).toBe(2024);
|
||||
expect(response.body.data.week).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toBe('All required database tables exist.');
|
||||
expect(response.body.data.message).toBe('All required database tables exist.');
|
||||
});
|
||||
|
||||
it('should return 500 if tables are missing', async () => {
|
||||
@@ -149,7 +147,9 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Missing tables: missing_table_1, missing_table_2');
|
||||
expect(response.body.error.message).toContain(
|
||||
'Missing tables: missing_table_1, missing_table_2',
|
||||
);
|
||||
// The error is passed to next(), so the global error handler would log it, not the route handler itself.
|
||||
});
|
||||
|
||||
@@ -161,10 +161,12 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
console.log('[DEBUG] health.routes.test.ts: Verifying logger.error for DB schema check failure');
|
||||
expect(response.body.error.message).toBe('DB connection failed'); // This is the message from the original error
|
||||
expect(response.body.error.details.stack).toBeDefined();
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
console.log(
|
||||
'[DEBUG] health.routes.test.ts: Verifying logger.error for DB schema check failure',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
@@ -181,8 +183,8 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
expect(response.body.error.message).toBe('DB connection failed'); // This is the message from the original error
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'DB connection failed' }),
|
||||
@@ -203,7 +205,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toContain('is accessible and writable');
|
||||
expect(response.body.data.message).toContain('is accessible and writable');
|
||||
});
|
||||
|
||||
it('should return 500 if storage is not accessible or writable', async () => {
|
||||
@@ -216,7 +218,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Storage check failed.');
|
||||
expect(response.body.error.message).toContain('Storage check failed.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
@@ -235,7 +237,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Storage check failed.');
|
||||
expect(response.body.error.message).toContain('Storage check failed.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
@@ -260,7 +262,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting.');
|
||||
expect(response.body.data.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting.');
|
||||
});
|
||||
|
||||
it('should return 500 for an unhealthy pool status', async () => {
|
||||
@@ -277,8 +279,8 @@ describe('Health Routes (/api/health)', () => {
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.message).toContain('Pool may be under stress.');
|
||||
expect(response.body.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting.');
|
||||
expect(response.body.error.message).toContain('Pool may be under stress.');
|
||||
expect(response.body.error.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting.');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Database pool health check shows high waiting count: 15',
|
||||
);
|
||||
@@ -295,8 +297,8 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-pool');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
expect(response.body.error.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
@@ -315,9 +317,9 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-pool');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
expect(response.body.error.message).toBe('Pool is not initialized'); // This is the message from the original error
|
||||
expect(response.body.error.details.stack).toBeDefined();
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'Pool is not initialized' }),
|
||||
@@ -334,10 +336,12 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Connection timed out');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
console.log('[DEBUG] health.routes.test.ts: Checking if logger.error was called with the correct pattern');
|
||||
expect(response.body.error.message).toBe('Connection timed out');
|
||||
expect(response.body.error.details.stack).toBeDefined();
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
console.log(
|
||||
'[DEBUG] health.routes.test.ts: Checking if logger.error was called with the correct pattern',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
@@ -352,9 +356,9 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/redis');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
expect(response.body.error.message).toContain('Unexpected Redis ping response: OK');
|
||||
expect(response.body.error.details.stack).toBeDefined();
|
||||
expect(response.body.meta.requestId).toEqual(expect.any(String));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { connection as redisConnection } from '../services/queueService.server';
|
||||
import fs from 'node:fs/promises';
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -129,7 +130,7 @@ const emptySchema = z.object({});
|
||||
* GET /api/health/ping - A simple endpoint to check if the server is responsive.
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
res.status(200).send('pong');
|
||||
return sendSuccess(res, { message: 'pong' });
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
@@ -146,7 +147,7 @@ router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response)
|
||||
* It only checks that the Node.js process can handle HTTP requests.
|
||||
*/
|
||||
router.get('/live', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
return sendSuccess(res, {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -198,9 +199,10 @@ router.get('/ready', validateRequest(emptySchema), async (req: Request, res: Res
|
||||
// Return appropriate HTTP status code
|
||||
// 200 = healthy or degraded (can still handle traffic)
|
||||
// 503 = unhealthy (should not receive traffic)
|
||||
const httpStatus = overallStatus === 'unhealthy' ? 503 : 200;
|
||||
|
||||
return res.status(httpStatus).json(response);
|
||||
if (overallStatus === 'unhealthy') {
|
||||
return sendError(res, ErrorCode.SERVICE_UNAVAILABLE, 'Service unhealthy', 503, response);
|
||||
}
|
||||
return sendSuccess(res, response);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -216,14 +218,13 @@ router.get('/startup', validateRequest(emptySchema), async (req: Request, res: R
|
||||
const database = await checkDatabase();
|
||||
|
||||
if (database.status === 'unhealthy') {
|
||||
return res.status(503).json({
|
||||
return sendError(res, ErrorCode.SERVICE_UNAVAILABLE, 'Waiting for database connection', 503, {
|
||||
status: 'starting',
|
||||
message: 'Waiting for database connection',
|
||||
database,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
return sendSuccess(res, {
|
||||
status: 'started',
|
||||
timestamp: new Date().toISOString(),
|
||||
database,
|
||||
@@ -245,7 +246,7 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
|
||||
new Error(`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`),
|
||||
);
|
||||
}
|
||||
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
|
||||
return sendSuccess(res, { message: 'All required database tables exist.' });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return next(error);
|
||||
@@ -266,8 +267,7 @@ router.get('/storage', validateRequest(emptySchema), async (req, res, next: Next
|
||||
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
return sendSuccess(res, {
|
||||
message: `Storage directory '${storagePath}' is accessible and writable.`,
|
||||
});
|
||||
} catch {
|
||||
@@ -293,12 +293,16 @@ router.get(
|
||||
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
|
||||
|
||||
if (isHealthy) {
|
||||
return res.status(200).json({ success: true, message });
|
||||
return sendSuccess(res, { message, ...status });
|
||||
} else {
|
||||
req.log.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: `Pool may be under stress. ${message}` });
|
||||
return sendError(
|
||||
res,
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`Pool may be under stress. ${message}`,
|
||||
500,
|
||||
status,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
@@ -319,7 +323,7 @@ router.get(
|
||||
router.get('/time', validateRequest(emptySchema), (req: Request, res: Response) => {
|
||||
const now = new Date();
|
||||
const { year, week } = getSimpleWeekAndYear(now);
|
||||
res.json({
|
||||
return sendSuccess(res, {
|
||||
currentTime: now.toISOString(),
|
||||
year,
|
||||
week,
|
||||
@@ -336,7 +340,7 @@ router.get(
|
||||
try {
|
||||
const reply = await redisConnection.ping();
|
||||
if (reply === 'PONG') {
|
||||
return res.status(200).json({ success: true, message: 'Redis connection is healthy.' });
|
||||
return sendSuccess(res, { message: 'Redis connection is healthy.' });
|
||||
}
|
||||
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -153,11 +153,12 @@ describe('Passport Configuration', () => {
|
||||
logger,
|
||||
);
|
||||
// The strategy now just strips auth fields.
|
||||
// SECURITY: password_hash and refresh_token are intentionally discarded.
|
||||
const {
|
||||
password_hash,
|
||||
failed_login_attempts,
|
||||
last_failed_login,
|
||||
refresh_token,
|
||||
password_hash: _password_hash,
|
||||
failed_login_attempts: _failed_login_attempts,
|
||||
last_failed_login: _last_failed_login,
|
||||
refresh_token: _refresh_token,
|
||||
...expectedUserProfile
|
||||
} = mockAuthableProfile;
|
||||
expect(done).toHaveBeenCalledWith(null, expectedUserProfile);
|
||||
|
||||
@@ -141,13 +141,20 @@ passport.use(
|
||||
// sensitive fields before passing the profile to the session.
|
||||
// The `...userProfile` rest parameter will contain the clean UserProfile object,
|
||||
// which no longer has a top-level email property.
|
||||
// SECURITY: password_hash and refresh_token are intentionally discarded - never send to client.
|
||||
const {
|
||||
password_hash,
|
||||
password_hash: _password_hash,
|
||||
failed_login_attempts,
|
||||
last_failed_login,
|
||||
refresh_token,
|
||||
refresh_token: _refresh_token,
|
||||
...cleanUserProfile
|
||||
} = userprofile;
|
||||
|
||||
// Log login metadata for audit purposes (non-sensitive fields only)
|
||||
req.log.debug(
|
||||
{ failed_login_attempts, last_failed_login },
|
||||
'User login metadata stripped from session',
|
||||
);
|
||||
return done(null, cleanUserProfile);
|
||||
} catch (err: unknown) {
|
||||
req.log.error({ error: err }, 'Error during local authentication strategy:');
|
||||
@@ -269,7 +276,9 @@ const jwtOptions = {
|
||||
|
||||
// --- DEBUG LOGGING FOR JWT SECRET ---
|
||||
if (!JWT_SECRET) {
|
||||
logger.fatal('[Passport] CRITICAL: JWT_SECRET is missing or empty in environment variables! JwtStrategy will fail.');
|
||||
logger.fatal(
|
||||
'[Passport] CRITICAL: JWT_SECRET is missing or empty in environment variables! JwtStrategy will fail.',
|
||||
);
|
||||
} else {
|
||||
logger.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`);
|
||||
}
|
||||
|
||||
@@ -40,18 +40,22 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
|
||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
|
||||
|
||||
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
|
||||
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).toEqual(mockItems);
|
||||
expect(response.body.data).toEqual(mockItems);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
|
||||
const response = await supertest(app)
|
||||
.get('/api/personalization/master-items')
|
||||
.set('x-test-rate-limit-enable', 'true');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching master items in /api/personalization/master-items:',
|
||||
@@ -67,7 +71,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRestrictions);
|
||||
expect(response.body.data).toEqual(mockRestrictions);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -75,7 +79,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:',
|
||||
@@ -91,7 +95,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
const response = await supertest(app).get('/api/personalization/appliances');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAppliances);
|
||||
expect(response.body.data).toEqual(mockAppliances);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -99,7 +103,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
vi.mocked(db.personalizationRepo.getAppliances).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/personalization/appliances');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching appliances in /api/personalization/appliances:',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { publicReadLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -28,7 +29,7 @@ router.get(
|
||||
res.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
|
||||
res.json(masterItems);
|
||||
sendSuccess(res, masterItems);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
|
||||
next(error);
|
||||
@@ -46,7 +47,7 @@ router.get(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const restrictions = await db.personalizationRepo.getDietaryRestrictions(req.log);
|
||||
res.json(restrictions);
|
||||
sendSuccess(res, restrictions);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error },
|
||||
@@ -67,7 +68,7 @@ router.get(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const appliances = await db.personalizationRepo.getAppliances(req.log);
|
||||
res.json(appliances);
|
||||
sendSuccess(res, appliances);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching appliances in /api/personalization/appliances:');
|
||||
next(error);
|
||||
|
||||
@@ -22,16 +22,14 @@ vi.mock('../services/logger.server', async () => ({
|
||||
// Mock the passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(
|
||||
(_strategy, _options) => (req: Request, res: Response, next: NextFunction) => {
|
||||
// If req.user is not set by the test setup, simulate unauthenticated access.
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
// If req.user is set, proceed as an authenticated user.
|
||||
next();
|
||||
},
|
||||
),
|
||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
// If req.user is not set by the test setup, simulate unauthenticated access.
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
// If req.user is set, proceed as an authenticated user.
|
||||
next();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,7 +39,11 @@ import { priceRepo } from '../services/db/price.db';
|
||||
|
||||
describe('Price Routes (/api/price-history)', () => {
|
||||
const mockUser = createMockUserProfile({ user: { user_id: 'price-user-123' } });
|
||||
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history', authenticatedUser: mockUser });
|
||||
const app = createTestApp({
|
||||
router: priceRouter,
|
||||
basePath: '/api/price-history',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -59,7 +61,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
.send({ masterItemIds: [1, 2] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockHistory);
|
||||
expect(response.body.data).toEqual(mockHistory);
|
||||
expect(priceRepo.getPriceHistory).toHaveBeenCalledWith([1, 2], expect.any(Object), 1000, 0);
|
||||
});
|
||||
|
||||
@@ -69,12 +71,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
.post('/api/price-history')
|
||||
.send({ masterItemIds: [1, 2, 3], limit: 50, offset: 10 });
|
||||
|
||||
expect(priceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1, 2, 3],
|
||||
expect.any(Object),
|
||||
50,
|
||||
10,
|
||||
);
|
||||
expect(priceRepo.getPriceHistory).toHaveBeenCalledWith([1, 2, 3], expect.any(Object), 50, 10);
|
||||
});
|
||||
|
||||
it('should log the request info', async () => {
|
||||
@@ -98,14 +95,14 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
.send({ masterItemIds: [1, 2, 3] });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Database connection failed');
|
||||
expect(response.body.error.message).toBe('Database connection failed');
|
||||
});
|
||||
|
||||
it('should return 400 if masterItemIds is an empty array', async () => {
|
||||
const response = await supertest(app).post('/api/price-history').send({ masterItemIds: [] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe(
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'masterItemIds must be a non-empty array of positive integers.',
|
||||
);
|
||||
});
|
||||
@@ -117,7 +114,9 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
// The actual message is "Invalid input: expected array, received string"
|
||||
expect(response.body.errors[0].message).toBe('Invalid input: expected array, received string');
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'Invalid input: expected array, received string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 if masterItemIds contains non-positive integers', async () => {
|
||||
@@ -126,7 +125,7 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
.send({ masterItemIds: [1, -2, 3] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('Number must be greater than 0');
|
||||
expect(response.body.error.details[0].message).toBe('Number must be greater than 0');
|
||||
});
|
||||
|
||||
it('should return 400 if masterItemIds is missing', async () => {
|
||||
@@ -134,7 +133,9 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
// The actual message is "Invalid input: expected array, received undefined"
|
||||
expect(response.body.errors[0].message).toBe('Invalid input: expected array, received undefined');
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'Invalid input: expected array, received undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid limit and offset', async () => {
|
||||
@@ -143,10 +144,12 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
.send({ masterItemIds: [1], limit: -1, offset: 'abc' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toHaveLength(2);
|
||||
expect(response.body.error.details).toHaveLength(2);
|
||||
// The actual message is "Too small: expected number to be >0"
|
||||
expect(response.body.errors[0].message).toBe('Too small: expected number to be >0');
|
||||
expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
|
||||
expect(response.body.error.details[0].message).toBe('Too small: expected number to be >0');
|
||||
expect(response.body.error.details[1].message).toBe(
|
||||
'Invalid input: expected number, received NaN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,16 +6,15 @@ import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { priceRepo } from '../services/db/price.db';
|
||||
import { optionalNumeric } from '../utils/zodUtils';
|
||||
import { priceHistoryLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const priceHistorySchema = z.object({
|
||||
body: z.object({
|
||||
masterItemIds: z
|
||||
.array(z.number().int().positive('Number must be greater than 0'))
|
||||
.nonempty({
|
||||
message: 'masterItemIds must be a non-empty array of positive integers.',
|
||||
}),
|
||||
masterItemIds: z.array(z.number().int().positive('Number must be greater than 0')).nonempty({
|
||||
message: 'masterItemIds must be a non-empty array of positive integers.',
|
||||
}),
|
||||
limit: optionalNumeric({ default: 1000, integer: true, positive: true }),
|
||||
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
|
||||
}),
|
||||
@@ -44,7 +43,7 @@ router.post(
|
||||
);
|
||||
try {
|
||||
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
|
||||
res.status(200).json(priceHistory);
|
||||
sendSuccess(res, priceHistory);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import type { UserReaction } from '../types';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
@@ -20,15 +22,13 @@ vi.mock('../services/logger.server', async () => ({
|
||||
// Mock Passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(
|
||||
() => (req: any, res: any, next: any) => {
|
||||
// If we are testing the unauthenticated state (no user injected), simulate 401.
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
},
|
||||
),
|
||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
// If we are testing the unauthenticated state (no user injected), simulate 401.
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -51,20 +51,24 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||
|
||||
it('should return a list of reactions', async () => {
|
||||
const mockReactions = [{ id: 1, reaction_type: 'like', entity_id: '123' }];
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
|
||||
const mockReactions = [
|
||||
{ reaction_id: 1, reaction_type: 'like', entity_id: '123' },
|
||||
] as unknown as UserReaction[];
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions);
|
||||
|
||||
const response = await supertest(app).get('/api/reactions');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockReactions);
|
||||
expect(response.body.data).toEqual(mockReactions);
|
||||
expect(reactionRepo.getReactions).toHaveBeenCalledWith({}, expectLogger);
|
||||
});
|
||||
|
||||
it('should filter by query parameters', async () => {
|
||||
const mockReactions = [{ id: 1, reaction_type: 'like' }];
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
|
||||
|
||||
const mockReactions = [
|
||||
{ reaction_id: 1, reaction_type: 'like' },
|
||||
] as unknown as UserReaction[];
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions);
|
||||
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const query = { userId: validUuid, entityType: 'recipe', entityId: '1' };
|
||||
|
||||
@@ -73,7 +77,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(reactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
expect.objectContaining(query),
|
||||
expectLogger
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,10 +88,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const response = await supertest(app).get('/api/reactions');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error },
|
||||
'Error fetching user reactions'
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Error fetching user reactions');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,26 +96,25 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||
|
||||
it('should return reaction summary for an entity', async () => {
|
||||
const mockSummary = { like: 10, love: 5 };
|
||||
vi.mocked(reactionRepo.getReactionSummary).mockResolvedValue(mockSummary as any);
|
||||
const mockSummary = [
|
||||
{ reaction_type: 'like', count: 10 },
|
||||
{ reaction_type: 'love', count: 5 },
|
||||
];
|
||||
vi.mocked(reactionRepo.getReactionSummary).mockResolvedValue(mockSummary);
|
||||
|
||||
const response = await supertest(app)
|
||||
.get('/api/reactions/summary')
|
||||
.query({ entityType: 'recipe', entityId: '123' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockSummary);
|
||||
expect(reactionRepo.getReactionSummary).toHaveBeenCalledWith(
|
||||
'recipe',
|
||||
'123',
|
||||
expectLogger
|
||||
);
|
||||
expect(response.body.data).toEqual(mockSummary);
|
||||
expect(reactionRepo.getReactionSummary).toHaveBeenCalledWith('recipe', '123', expectLogger);
|
||||
});
|
||||
|
||||
it('should return 400 if required parameters are missing', async () => {
|
||||
const response = await supertest(app).get('/api/reactions/summary');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('required');
|
||||
expect(response.body.error.details[0].message).toContain('required');
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
@@ -126,10 +126,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
.query({ entityType: 'recipe', entityId: '123' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error },
|
||||
'Error fetching reaction summary'
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Error fetching reaction summary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,18 +145,20 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
};
|
||||
|
||||
it('should return 201 when a reaction is added', async () => {
|
||||
const mockResult = { ...validBody, id: 1, user_id: 'user-123' };
|
||||
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult as any);
|
||||
const mockResult = {
|
||||
...validBody,
|
||||
reaction_id: 1,
|
||||
user_id: 'user-123',
|
||||
} as unknown as UserReaction;
|
||||
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/reactions/toggle')
|
||||
.send(validBody);
|
||||
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual({ message: 'Reaction added.', reaction: mockResult });
|
||||
expect(response.body.data).toEqual({ message: 'Reaction added.', reaction: mockResult });
|
||||
expect(reactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
{ user_id: 'user-123', ...validBody },
|
||||
expectLogger
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -167,12 +166,10 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
// Returning null/false from toggleReaction implies the reaction was removed
|
||||
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/reactions/toggle')
|
||||
.send(validBody);
|
||||
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Reaction removed.' });
|
||||
expect(response.body.data).toEqual({ message: 'Reaction removed.' });
|
||||
});
|
||||
|
||||
it('should return 400 if body is invalid', async () => {
|
||||
@@ -181,14 +178,12 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
.send({ entity_type: 'recipe' }); // Missing other required fields
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const unauthApp = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
|
||||
const response = await supertest(unauthApp)
|
||||
.post('/api/reactions/toggle')
|
||||
.send(validBody);
|
||||
const response = await supertest(unauthApp).post('/api/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
@@ -197,14 +192,12 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
const error = new Error('DB Error');
|
||||
vi.mocked(reactionRepo.toggleReaction).mockRejectedValue(error);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/reactions/toggle')
|
||||
.send(validBody);
|
||||
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error, body: validBody },
|
||||
'Error toggling user reaction'
|
||||
'Error toggling user reaction',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -240,4 +233,4 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
expect(parseInt(response.headers['ratelimit-limit'])).toBe(150);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import passport from './passport.routes';
|
||||
import { requiredString } from '../utils/zodUtils';
|
||||
import { UserProfile } from '../types';
|
||||
import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -49,7 +50,7 @@ router.get(
|
||||
try {
|
||||
const { query } = getReactionsSchema.parse({ query: req.query });
|
||||
const reactions = await reactionRepo.getReactions(query, req.log);
|
||||
res.json(reactions);
|
||||
sendSuccess(res, reactions);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching user reactions');
|
||||
next(error);
|
||||
@@ -69,8 +70,12 @@ router.get(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { query } = getReactionSummarySchema.parse({ query: req.query });
|
||||
const summary = await reactionRepo.getReactionSummary(query.entityType, query.entityId, req.log);
|
||||
res.json(summary);
|
||||
const summary = await reactionRepo.getReactionSummary(
|
||||
query.entityType,
|
||||
query.entityId,
|
||||
req.log,
|
||||
);
|
||||
sendSuccess(res, summary);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching reaction summary');
|
||||
next(error);
|
||||
@@ -99,9 +104,9 @@ router.post(
|
||||
};
|
||||
const result = await reactionRepo.toggleReaction(reactionData, req.log);
|
||||
if (result) {
|
||||
res.status(201).json({ message: 'Reaction added.', reaction: result });
|
||||
sendSuccess(res, { message: 'Reaction added.', reaction: result }, 201);
|
||||
} else {
|
||||
res.status(200).json({ message: 'Reaction removed.' });
|
||||
sendSuccess(res, { message: 'Reaction removed.' });
|
||||
}
|
||||
} catch (error) {
|
||||
req.log.error({ error, body }, 'Error toggling user reaction');
|
||||
@@ -110,4 +115,4 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// src/routes/recipe.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { createMockRecipe, createMockRecipeComment, createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
createMockRecipe,
|
||||
createMockRecipeComment,
|
||||
createMockUserProfile,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
@@ -26,14 +31,12 @@ vi.mock('../services/aiService.server', () => ({
|
||||
// Mock Passport
|
||||
vi.mock('./passport.routes', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(
|
||||
() => (req: any, res: any, next: any) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
},
|
||||
),
|
||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -70,7 +73,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRecipes);
|
||||
expect(response.body.data).toEqual(mockRecipes);
|
||||
expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(75, expectLogger);
|
||||
});
|
||||
|
||||
@@ -85,7 +88,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching recipes in /api/recipes/by-sale-percentage:',
|
||||
@@ -97,7 +100,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
'/api/recipes/by-sale-percentage?minPercentage=101',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('Too big');
|
||||
expect(response.body.error.details[0].message).toContain('Too big');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +123,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching recipes in /api/recipes/by-sale-ingredients:',
|
||||
@@ -132,7 +135,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
'/api/recipes/by-sale-ingredients?minIngredients=abc',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +149,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRecipes);
|
||||
expect(response.body.data).toEqual(mockRecipes);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -156,7 +159,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
'/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching recipes in /api/recipes/by-ingredient-and-tag:',
|
||||
@@ -168,7 +171,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
'/api/recipes/by-ingredient-and-tag?ingredient=chicken',
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('Query parameter "tag" is required.');
|
||||
expect(response.body.error.details[0].message).toBe('Query parameter "tag" is required.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,14 +183,14 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const response = await supertest(app).get('/api/recipes/1/comments');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockComments);
|
||||
expect(response.body.data).toEqual(mockComments);
|
||||
expect(db.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1, expectLogger);
|
||||
});
|
||||
|
||||
it('should return an empty array if recipe has no comments', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue([]);
|
||||
const response = await supertest(app).get('/api/recipes/2/comments');
|
||||
expect(response.body).toEqual([]);
|
||||
expect(response.body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -195,7 +198,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/1/comments');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`Error fetching comments for recipe ID 1:`,
|
||||
@@ -205,7 +208,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
it('should return 400 for an invalid recipeId', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/abc/comments');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +220,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const response = await supertest(app).get('/api/recipes/456');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRecipe);
|
||||
expect(response.body.data).toEqual(mockRecipe);
|
||||
expect(db.recipeRepo.getRecipeById).toHaveBeenCalledWith(456, expectLogger);
|
||||
});
|
||||
|
||||
@@ -226,7 +229,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(notFoundError);
|
||||
const response = await supertest(app).get('/api/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found');
|
||||
expect(response.body.error.message).toContain('not found');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: notFoundError },
|
||||
`Error fetching recipe ID 999:`,
|
||||
@@ -238,7 +241,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/456');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`Error fetching recipe ID 456:`,
|
||||
@@ -248,7 +251,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
it('should return 400 for an invalid recipeId', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,12 +268,10 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const mockSuggestion = 'Chicken and Rice Casserole...';
|
||||
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
|
||||
|
||||
const response = await supertest(authApp)
|
||||
.post('/api/recipes/suggest')
|
||||
.send({ ingredients });
|
||||
const response = await supertest(authApp).post('/api/recipes/suggest').send({ ingredients });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ suggestion: mockSuggestion });
|
||||
expect(response.body.data).toEqual({ suggestion: mockSuggestion });
|
||||
expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith(ingredients, expectLogger);
|
||||
});
|
||||
|
||||
@@ -282,7 +283,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
.send({ ingredients: ['water'] });
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.message).toContain('unavailable');
|
||||
expect(response.body.error.message).toContain('unavailable');
|
||||
});
|
||||
|
||||
it('should return 400 if ingredients list is empty', async () => {
|
||||
@@ -291,7 +292,9 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
.send({ ingredients: [] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('At least one ingredient is required');
|
||||
expect(response.body.error.details[0].message).toContain(
|
||||
'At least one ingredient is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
@@ -314,7 +317,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
expect(response.status).toBe(500);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error },
|
||||
'Error generating recipe suggestion'
|
||||
'Error generating recipe suggestion',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import passport from './passport.routes';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
|
||||
import { publicReadLimiter, suggestionLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -49,7 +50,7 @@ router.get(
|
||||
// Explicitly parse req.query to apply coercion (string -> number) and default values
|
||||
const { query } = bySalePercentageSchema.parse({ query: req.query });
|
||||
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage!, req.log);
|
||||
res.json(recipes);
|
||||
sendSuccess(res, recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
|
||||
next(error);
|
||||
@@ -72,7 +73,7 @@ router.get(
|
||||
query.minIngredients!,
|
||||
req.log,
|
||||
);
|
||||
res.json(recipes);
|
||||
sendSuccess(res, recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
|
||||
next(error);
|
||||
@@ -95,7 +96,7 @@ router.get(
|
||||
query.tag,
|
||||
req.log,
|
||||
);
|
||||
res.json(recipes);
|
||||
sendSuccess(res, recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
|
||||
next(error);
|
||||
@@ -106,32 +107,42 @@ router.get(
|
||||
/**
|
||||
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
|
||||
*/
|
||||
router.get('/:recipeId/comments', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
||||
try {
|
||||
// Explicitly parse req.params to coerce recipeId to a number
|
||||
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
||||
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
|
||||
res.json(comments);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/:recipeId/comments',
|
||||
publicReadLimiter,
|
||||
validateRequest(recipeIdParamsSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
// Explicitly parse req.params to coerce recipeId to a number
|
||||
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
||||
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
|
||||
sendSuccess(res, comments);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
|
||||
*/
|
||||
router.get('/:recipeId', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
||||
try {
|
||||
// Explicitly parse req.params to coerce recipeId to a number
|
||||
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
||||
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
|
||||
res.json(recipe);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/:recipeId',
|
||||
publicReadLimiter,
|
||||
validateRequest(recipeIdParamsSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
// Explicitly parse req.params to coerce recipeId to a number
|
||||
const { params } = recipeIdParamsSchema.parse({ params: req.params });
|
||||
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
|
||||
sendSuccess(res, recipe);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/recipes/suggest - Generates a simple recipe suggestion from a list of ingredients.
|
||||
@@ -148,12 +159,15 @@ router.post(
|
||||
const suggestion = await aiService.generateRecipeSuggestion(body.ingredients, req.log);
|
||||
|
||||
if (!suggestion) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: 'AI service is currently unavailable or failed to generate a suggestion.' });
|
||||
return sendError(
|
||||
res,
|
||||
ErrorCode.SERVICE_UNAVAILABLE,
|
||||
'AI service is currently unavailable or failed to generate a suggestion.',
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ suggestion });
|
||||
sendSuccess(res, { suggestion });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error generating recipe suggestion');
|
||||
next(error);
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('Stats Routes (/api/stats)', () => {
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(response.body.error.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
'Error fetching most frequent sale items in /api/stats/most-frequent-sales:',
|
||||
@@ -62,8 +62,8 @@ describe('Stats Routes (/api/stats)', () => {
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales?days=0&limit=abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors.length).toBe(2);
|
||||
expect(response.body.error.details).toBeDefined();
|
||||
expect(response.body.error.details.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as db from '../services/db/index.db';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { optionalNumeric } from '../utils/zodUtils';
|
||||
import { publicReadLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -34,7 +35,7 @@ router.get(
|
||||
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
||||
const { days, limit } = statsQuerySchema.parse(req.query);
|
||||
const items = await db.adminRepo.getMostFrequentSaleItems(days!, limit!, req.log);
|
||||
res.json(items);
|
||||
sendSuccess(res, items);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error },
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
expect(response.body.data).toEqual({
|
||||
success: true,
|
||||
message: 'Application is online and running under PM2.',
|
||||
});
|
||||
@@ -69,7 +69,7 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return success: false when pm2 process does not exist', async () => {
|
||||
@@ -84,7 +84,7 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
expect(response.body.data).toEqual({
|
||||
success: false,
|
||||
message: 'Application process is not running under PM2.',
|
||||
});
|
||||
@@ -92,12 +92,14 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
it('should return 500 if pm2 command produces stderr output', async () => {
|
||||
// Arrange: Simulate a successful exit code but with content in stderr.
|
||||
const serviceError = new Error('PM2 command produced an error: A non-fatal warning occurred.');
|
||||
const serviceError = new Error(
|
||||
'PM2 command produced an error: A non-fatal warning occurred.',
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe(serviceError.message);
|
||||
expect(response.body.error.message).toBe(serviceError.message);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic exec error', async () => {
|
||||
@@ -109,7 +111,7 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('System error');
|
||||
expect(response.body.error.message).toBe('System error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +128,7 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockCoordinates);
|
||||
expect(response.body.data).toEqual(mockCoordinates);
|
||||
});
|
||||
|
||||
it('should return 404 if the address cannot be geocoded', async () => {
|
||||
@@ -135,7 +137,7 @@ describe('System Routes (/api/system)', () => {
|
||||
.post('/api/system/geocode')
|
||||
.send({ address: 'Invalid Address' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Could not geocode the provided address.');
|
||||
expect(response.body.error.message).toBe('Could not geocode the provided address.');
|
||||
});
|
||||
|
||||
it('should return 500 if the geocoding service throws an error', async () => {
|
||||
@@ -153,7 +155,9 @@ describe('System Routes (/api/system)', () => {
|
||||
.send({ not_address: 'Victoria, BC' });
|
||||
expect(response.status).toBe(400);
|
||||
// Zod validation error message can vary slightly depending on configuration or version
|
||||
expect(response.body.errors[0].message).toMatch(/An address string is required|Required/i);
|
||||
expect(response.body.error.details[0].message).toMatch(
|
||||
/An address string is required|Required/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { requiredString } from '../utils/zodUtils';
|
||||
import { systemService } from '../services/systemService';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { geocodeLimiter } from '../config/rateLimiters';
|
||||
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -36,7 +37,7 @@ router.get(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = await systemService.getPm2Status();
|
||||
res.json(status);
|
||||
sendSuccess(res, status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -63,10 +64,10 @@ router.post(
|
||||
|
||||
if (!coordinates) {
|
||||
// This check remains, but now it only fails if BOTH services fail.
|
||||
return res.status(404).json({ message: 'Could not geocode the provided address.' });
|
||||
return sendError(res, ErrorCode.NOT_FOUND, 'Could not geocode the provided address.', 404);
|
||||
}
|
||||
|
||||
res.json(coordinates);
|
||||
sendSuccess(res, coordinates);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error geocoding address');
|
||||
next(error);
|
||||
|
||||
@@ -13,13 +13,16 @@ import {
|
||||
createMockNotification,
|
||||
createMockDietaryRestriction,
|
||||
createMockAppliance,
|
||||
createMockUserWithPasswordHash,
|
||||
createMockAddress,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { Appliance, Notification, DietaryRestriction } from '../types';
|
||||
import { ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db';
|
||||
import {
|
||||
ForeignKeyConstraintError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '../services/db/errors.db';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import '../tests/utils/mockLogger';
|
||||
import { cleanupFiles } from '../tests/utils/cleanupFiles';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { userService } from '../services/userService';
|
||||
@@ -148,7 +151,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUserProfile);
|
||||
expect(response.body.data).toEqual(mockUserProfile);
|
||||
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(
|
||||
mockUserProfile.user.user_id,
|
||||
expectLogger,
|
||||
@@ -161,7 +164,7 @@ describe('User Routes (/api/users)', () => {
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('Profile not found');
|
||||
expect(response.body.error.message).toContain('Profile not found');
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -184,7 +187,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.getWatchedItems).mockResolvedValue(mockItems);
|
||||
const response = await supertest(app).get('/api/users/watched-items');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockItems);
|
||||
expect(response.body.data).toEqual(mockItems);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -210,7 +213,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.addWatchedItem).mockResolvedValue(mockAddedItem);
|
||||
const response = await supertest(app).post('/api/users/watched-items').send(newItem);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockAddedItem);
|
||||
expect(response.body.data).toEqual(mockAddedItem);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -230,8 +233,8 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/watched-items')
|
||||
.send({ category: 'Produce' });
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'errors' array for the specific validation message.
|
||||
expect(response.body.errors[0].message).toBe("Field 'itemName' is required.");
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toBe("Field 'itemName' is required.");
|
||||
});
|
||||
|
||||
it('should return 400 if category is missing', async () => {
|
||||
@@ -239,8 +242,8 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/watched-items')
|
||||
.send({ itemName: 'Apples' });
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'errors' array for the specific validation message.
|
||||
expect(response.body.errors[0].message).toBe("Field 'category' is required.");
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toBe("Field 'category' is required.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,7 +289,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockLists);
|
||||
expect(response.body.data).toEqual(mockLists);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -312,14 +315,14 @@ describe('User Routes (/api/users)', () => {
|
||||
.send({ name: 'Party Supplies' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockNewList);
|
||||
expect(response.body.data).toEqual(mockNewList);
|
||||
});
|
||||
|
||||
it('should return 400 if name is missing', async () => {
|
||||
const response = await supertest(app).post('/api/users/shopping-lists').send({});
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'errors' array for the specific validation message.
|
||||
expect(response.body.errors[0].message).toBe("Field 'name' is required.");
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toBe("Field 'name' is required.");
|
||||
});
|
||||
|
||||
it('should return 400 on foreign key constraint error', async () => {
|
||||
@@ -330,7 +333,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/shopping-lists')
|
||||
.send({ name: 'Failing List' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('User not found');
|
||||
expect(response.body.error.message).toBe('User not found');
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error during creation', async () => {
|
||||
@@ -340,15 +343,15 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/shopping-lists')
|
||||
.send({ name: 'Failing List' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Connection Failed');
|
||||
expect(response.body.error.message).toBe('DB Connection Failed');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid listId on DELETE', async () => {
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'errors' array for the specific validation message.
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
|
||||
describe('DELETE /shopping-lists/:listId', () => {
|
||||
@@ -378,7 +381,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 400 for an invalid listId', async () => {
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -387,7 +390,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 400 if neither masterItemId nor customItemName are provided', async () => {
|
||||
const response = await supertest(app).post('/api/users/shopping-lists/1/items').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe(
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'Either masterItemId or customItemName must be provided.',
|
||||
);
|
||||
});
|
||||
@@ -436,7 +439,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.send(itemData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockAddedItem);
|
||||
expect(response.body.data).toEqual(mockAddedItem);
|
||||
expect(db.shoppingRepo.addShoppingListItem).toHaveBeenCalledWith(
|
||||
listId,
|
||||
mockUserProfile.user.user_id,
|
||||
@@ -479,7 +482,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.send(updates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedItem);
|
||||
expect(response.body.data).toEqual(mockUpdatedItem);
|
||||
expect(db.shoppingRepo.updateShoppingListItem).toHaveBeenCalledWith(
|
||||
itemId,
|
||||
mockUserProfile.user.user_id,
|
||||
@@ -511,7 +514,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 400 if no update fields are provided for an item', async () => {
|
||||
const response = await supertest(app).put(`/api/users/shopping-lists/items/101`).send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain(
|
||||
expect(response.body.error.details[0].message).toContain(
|
||||
'At least one field (quantity, is_purchased) must be provided.',
|
||||
);
|
||||
});
|
||||
@@ -554,7 +557,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedProfile);
|
||||
expect(response.body.data).toEqual(updatedProfile);
|
||||
});
|
||||
|
||||
it('should allow updating the profile with an empty string for avatar_url', async () => {
|
||||
@@ -569,7 +572,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedProfile);
|
||||
expect(response.body.data).toEqual(updatedProfile);
|
||||
// Verify that the Zod schema preprocessed the empty string to undefined
|
||||
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(
|
||||
mockUserProfile.user.user_id,
|
||||
@@ -594,7 +597,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 400 if the body is empty', async () => {
|
||||
const response = await supertest(app).put('/api/users/profile').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe(
|
||||
expect(response.body.error.details[0].message).toBe(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
@@ -607,7 +610,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Password updated successfully.');
|
||||
expect(response.body.data.message).toBe('Password updated successfully.');
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -629,7 +632,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.send({ newPassword: 'password123' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('Password is too weak.');
|
||||
expect(response.body.error.details[0].message).toContain('Password is too weak.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -640,33 +643,43 @@ describe('User Routes (/api/users)', () => {
|
||||
.delete('/api/users/account')
|
||||
.send({ password: 'correct-password' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Account deleted successfully.');
|
||||
expect(userService.deleteUserAccount).toHaveBeenCalledWith('user-123', 'correct-password', expectLogger);
|
||||
expect(response.body.data.message).toBe('Account deleted successfully.');
|
||||
expect(userService.deleteUserAccount).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
'correct-password',
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for an incorrect password', async () => {
|
||||
vi.mocked(userService.deleteUserAccount).mockRejectedValue(new ValidationError([], 'Incorrect password.'));
|
||||
vi.mocked(userService.deleteUserAccount).mockRejectedValue(
|
||||
new ValidationError([], 'Incorrect password.'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.send({ password: 'wrong-password' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Incorrect password.');
|
||||
expect(response.body.error.message).toBe('Incorrect password.');
|
||||
});
|
||||
|
||||
it('should return 404 if the user to delete is not found', async () => {
|
||||
vi.mocked(userService.deleteUserAccount).mockRejectedValue(new NotFoundError('User not found.'));
|
||||
vi.mocked(userService.deleteUserAccount).mockRejectedValue(
|
||||
new NotFoundError('User not found.'),
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.send({ password: 'any-password' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('User not found.');
|
||||
expect(response.body.error.message).toBe('User not found.');
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
vi.mocked(userService.deleteUserAccount).mockRejectedValue(new Error('DB Connection Failed'));
|
||||
vi.mocked(userService.deleteUserAccount).mockRejectedValue(
|
||||
new Error('DB Connection Failed'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.send({ password: 'correct-password' });
|
||||
@@ -691,7 +704,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile/preferences')
|
||||
.send(preferencesUpdate);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedProfile);
|
||||
expect(response.body.data).toEqual(updatedProfile);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
@@ -728,7 +741,7 @@ describe('User Routes (/api/users)', () => {
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRestrictions);
|
||||
expect(response.body.data).toEqual(mockRestrictions);
|
||||
});
|
||||
|
||||
it('GET should return 500 on a generic database error', async () => {
|
||||
@@ -745,8 +758,8 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 400 for an invalid masterItemId', async () => {
|
||||
const response = await supertest(app).delete('/api/users/watched-items/abc');
|
||||
expect(response.status).toBe(400);
|
||||
// Check the 'errors' array for the specific validation message.
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
// Check the 'error.details' array for the specific validation message.
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
|
||||
it('PUT should successfully set the restrictions', async () => {
|
||||
@@ -792,7 +805,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
|
||||
const response = await supertest(app).get('/api/users/me/appliances');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAppliances);
|
||||
expect(response.body.data).toEqual(mockAppliances);
|
||||
});
|
||||
|
||||
it('GET should return 500 on a generic database error', async () => {
|
||||
@@ -823,7 +836,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/me/appliances')
|
||||
.send({ applianceIds: [999] }); // Invalid ID
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid appliance ID');
|
||||
expect(response.body.error.message).toBe('Invalid appliance ID');
|
||||
});
|
||||
|
||||
it('PUT should return 500 on a generic database error', async () => {
|
||||
@@ -855,7 +868,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const response = await supertest(app).get('/api/users/notifications?limit=10');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockNotifications);
|
||||
expect(response.body.data).toEqual(mockNotifications);
|
||||
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
10,
|
||||
@@ -875,7 +888,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const response = await supertest(app).get('/api/users/notifications?includeRead=true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockNotifications);
|
||||
expect(response.body.data).toEqual(mockNotifications);
|
||||
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
20, // default limit
|
||||
@@ -935,7 +948,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/notifications/abc/mark-read')
|
||||
.send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -950,7 +963,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(userService.getUserAddress).mockResolvedValue(mockAddress);
|
||||
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAddress);
|
||||
expect(response.body.data).toEqual(mockAddress);
|
||||
});
|
||||
|
||||
it('GET /addresses/:addressId should return 500 on a generic database error', async () => {
|
||||
@@ -972,10 +985,12 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
|
||||
vi.mocked(userService.getUserAddress).mockRejectedValue(new ValidationError([], 'Forbidden'));
|
||||
vi.mocked(userService.getUserAddress).mockRejectedValue(
|
||||
new ValidationError([], 'Forbidden'),
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/addresses/2'); // Requesting address 2
|
||||
expect(response.status).toBe(400); // ValidationError maps to 400 by default in the test error handler
|
||||
expect(response.body.message).toBe('Forbidden');
|
||||
expect(response.body.error.message).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('GET /addresses/:addressId should return 404 if address not found', async () => {
|
||||
@@ -989,16 +1004,14 @@ describe('User Routes (/api/users)', () => {
|
||||
);
|
||||
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Address not found.');
|
||||
expect(response.body.error.message).toBe('Address not found.');
|
||||
});
|
||||
|
||||
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
|
||||
const addressData = { address_line_1: '123 New St' };
|
||||
vi.mocked(userService.upsertUserAddress).mockResolvedValue(5);
|
||||
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/address')
|
||||
.send(addressData);
|
||||
const response = await supertest(app).put('/api/users/profile/address').send(addressData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(userService.upsertUserAddress).toHaveBeenCalledWith(
|
||||
@@ -1020,7 +1033,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 400 if the address body is empty', async () => {
|
||||
const response = await supertest(app).put('/api/users/profile/address').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain(
|
||||
expect(response.body.error.details[0].message).toContain(
|
||||
'At least one address field must be provided',
|
||||
);
|
||||
});
|
||||
@@ -1042,7 +1055,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.avatar_url).toContain('https://example.com/uploads/avatars/');
|
||||
expect(response.body.data.avatar_url).toContain('https://example.com/uploads/avatars/');
|
||||
expect(userService.updateUserAvatar).toHaveBeenCalledWith(
|
||||
mockUserProfile.user.user_id,
|
||||
expect.any(Object),
|
||||
@@ -1068,7 +1081,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.attach('avatar', Buffer.from('this is not an image'), dummyTextPath);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Only image files are allowed!');
|
||||
expect(response.body.error.message).toBe('Only image files are allowed!');
|
||||
});
|
||||
|
||||
it('should return 400 if the uploaded file is too large', async () => {
|
||||
@@ -1081,6 +1094,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.attach('avatar', largeFile, dummyImagePath);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
// Multer middleware returns non-envelope format directly
|
||||
expect(response.body.message).toContain('File too large');
|
||||
});
|
||||
|
||||
@@ -1088,7 +1102,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const response = await supertest(app).post('/api/users/profile/avatar'); // No .attach() call
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('No avatar file uploaded.');
|
||||
expect(response.body.error.message).toBe('No avatar file uploaded.');
|
||||
});
|
||||
|
||||
it('should clean up the uploaded file if updating the profile fails', async () => {
|
||||
@@ -1115,7 +1129,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('should return 400 for a non-numeric address ID', async () => {
|
||||
const response = await supertest(app).get('/api/users/addresses/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1141,16 +1155,18 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 404 if recipe not found', async () => {
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(new NotFoundError('Recipe not found'));
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(
|
||||
new NotFoundError('Recipe not found'),
|
||||
);
|
||||
const response = await supertest(app).delete('/api/users/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Recipe not found');
|
||||
expect(response.body.error.message).toBe('Recipe not found');
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||
const response = await supertest(app).delete('/api/users/recipes/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
|
||||
it("PUT /recipes/:recipeId should update a user's own recipe", async () => {
|
||||
@@ -1161,7 +1177,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const response = await supertest(app).put('/api/users/recipes/1').send(updates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedRecipe);
|
||||
expect(response.body.data).toEqual(mockUpdatedRecipe);
|
||||
expect(db.recipeRepo.updateRecipe).toHaveBeenCalledWith(
|
||||
1,
|
||||
mockUserProfile.user.user_id,
|
||||
@@ -1191,7 +1207,7 @@ describe('User Routes (/api/users)', () => {
|
||||
it('PUT /recipes/:recipeId should return 400 if no update fields are provided', async () => {
|
||||
const response = await supertest(app).put('/api/users/recipes/1').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('No fields provided to update.');
|
||||
expect(response.body.error.details[0].message).toBe('No fields provided to update.');
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||
@@ -1199,7 +1215,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/recipes/abc')
|
||||
.send({ name: 'New Name' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
expect(response.body.error.details[0].message).toContain('received NaN');
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
|
||||
@@ -1208,7 +1224,7 @@ describe('User Routes (/api/users)', () => {
|
||||
);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Shopping list not found');
|
||||
expect(response.body.error.message).toBe('Shopping list not found');
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return a single shopping list', async () => {
|
||||
@@ -1219,7 +1235,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(mockList);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockList);
|
||||
expect(response.body.data).toEqual(mockList);
|
||||
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(
|
||||
1,
|
||||
mockUserProfile.user.user_id,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// src/routes/user.routes.ts
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import passport from './passport.routes';
|
||||
import multer from 'multer'; // Keep for MulterError type check
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { z } from 'zod';
|
||||
// Removed: import { logger } from '../services/logger.server';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { UserProfile } from '../types';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import {
|
||||
createUploadMiddleware,
|
||||
handleMulterError,
|
||||
} from '../middleware/multer.middleware';
|
||||
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
import { userService } from '../services/userService';
|
||||
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
||||
@@ -36,6 +32,7 @@ import {
|
||||
userSensitiveUpdateLimiter,
|
||||
userUploadLimiter,
|
||||
} from '../config/rateLimiters';
|
||||
import { sendSuccess, sendNoContent, sendError, ErrorCode } from '../utils/apiResponse';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -128,10 +125,14 @@ router.post(
|
||||
// The try-catch block was already correct here.
|
||||
try {
|
||||
// The `requireFileUpload` middleware is not used here, so we must check for `req.file`.
|
||||
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
|
||||
if (!req.file) return sendError(res, ErrorCode.BAD_REQUEST, 'No avatar file uploaded.', 400);
|
||||
const userProfile = req.user as UserProfile;
|
||||
const updatedProfile = await userService.updateUserAvatar(userProfile.user.user_id, req.file, req.log);
|
||||
res.json(updatedProfile);
|
||||
const updatedProfile = await userService.updateUserAvatar(
|
||||
userProfile.user.user_id,
|
||||
req.file,
|
||||
req.log,
|
||||
);
|
||||
sendSuccess(res, updatedProfile);
|
||||
} catch (error) {
|
||||
// If an error occurs after the file has been uploaded (e.g., DB error),
|
||||
// we must clean up the orphaned file from the disk.
|
||||
@@ -146,17 +147,14 @@ router.post(
|
||||
* GET /api/users/notifications - Get notifications for the authenticated user.
|
||||
* Supports pagination with `limit` and `offset` query parameters.
|
||||
*/
|
||||
type GetNotificationsRequest = z.infer<typeof notificationQuerySchema>;
|
||||
router.get(
|
||||
'/notifications',
|
||||
validateRequest(notificationQuerySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Cast to UserProfile to access user properties safely.
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
try {
|
||||
const { query } = req as unknown as GetNotificationsRequest;
|
||||
const parsedQuery = notificationQuerySchema.parse({ query: req.query }).query;
|
||||
const parsedQuery = notificationQuerySchema.shape.query.parse(req.query);
|
||||
const notifications = await db.notificationRepo.getNotificationsForUser(
|
||||
userProfile.user.user_id,
|
||||
parsedQuery.limit!,
|
||||
@@ -164,7 +162,7 @@ router.get(
|
||||
parsedQuery.includeRead!,
|
||||
req.log,
|
||||
);
|
||||
res.json(notifications);
|
||||
sendSuccess(res, notifications);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching notifications');
|
||||
next(error);
|
||||
@@ -182,7 +180,7 @@ router.post(
|
||||
try {
|
||||
const userProfile = req.user as UserProfile;
|
||||
await db.notificationRepo.markAllNotificationsAsRead(userProfile.user.user_id, req.log);
|
||||
res.status(204).send(); // No Content
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error marking all notifications as read');
|
||||
next(error);
|
||||
@@ -208,7 +206,7 @@ router.post(
|
||||
userProfile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
res.status(204).send(); // Success, no content to return
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error marking notification as read');
|
||||
next(error);
|
||||
@@ -230,7 +228,7 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
|
||||
userProfile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
res.json(fullUserProfile);
|
||||
sendSuccess(res, fullUserProfile);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
|
||||
next(error);
|
||||
@@ -256,7 +254,7 @@ router.put(
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
res.json(updatedProfile);
|
||||
sendSuccess(res, updatedProfile);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
|
||||
next(error);
|
||||
@@ -280,7 +278,7 @@ router.put(
|
||||
|
||||
try {
|
||||
await userService.updateUserPassword(userProfile.user.user_id, body.newPassword, req.log);
|
||||
res.status(200).json({ message: 'Password updated successfully.' });
|
||||
sendSuccess(res, { message: 'Password updated successfully.' });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
|
||||
next(error);
|
||||
@@ -304,7 +302,7 @@ router.delete(
|
||||
|
||||
try {
|
||||
await userService.deleteUserAccount(userProfile.user.user_id, body.password, req.log);
|
||||
res.status(200).json({ message: 'Account deleted successfully.' });
|
||||
sendSuccess(res, { message: 'Account deleted successfully.' });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
|
||||
next(error);
|
||||
@@ -320,7 +318,7 @@ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const items = await db.personalizationRepo.getWatchedItems(userProfile.user.user_id, req.log);
|
||||
res.json(items);
|
||||
sendSuccess(res, items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
|
||||
next(error);
|
||||
@@ -347,10 +345,10 @@ router.post(
|
||||
body.category,
|
||||
req.log,
|
||||
);
|
||||
res.status(201).json(newItem);
|
||||
sendSuccess(res, newItem, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
||||
}
|
||||
req.log.error({ error, body: req.body }, 'Failed to add watched item');
|
||||
next(error);
|
||||
@@ -378,7 +376,7 @@ router.delete(
|
||||
params.masterItemId,
|
||||
req.log,
|
||||
);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
|
||||
next(error);
|
||||
@@ -397,7 +395,7 @@ router.get(
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const lists = await db.shoppingRepo.getShoppingLists(userProfile.user.user_id, req.log);
|
||||
res.json(lists);
|
||||
sendSuccess(res, lists);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
|
||||
next(error);
|
||||
@@ -423,7 +421,7 @@ router.get(
|
||||
userProfile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
res.json(list);
|
||||
sendSuccess(res, list);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, listId: params.listId },
|
||||
@@ -453,10 +451,10 @@ router.post(
|
||||
body.name,
|
||||
req.log,
|
||||
);
|
||||
res.status(201).json(newList);
|
||||
sendSuccess(res, newList, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
||||
}
|
||||
req.log.error({ error, body: req.body }, 'Failed to create shopping list');
|
||||
next(error);
|
||||
@@ -478,7 +476,7 @@ router.delete(
|
||||
const { params } = req as unknown as GetShoppingListRequest;
|
||||
try {
|
||||
await db.shoppingRepo.deleteShoppingList(params.listId, userProfile.user.user_id, req.log);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
req.log.error(
|
||||
@@ -524,12 +522,15 @@ router.post(
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
res.status(201).json(newItem);
|
||||
sendSuccess(res, newItem, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
||||
}
|
||||
req.log.error({ error, params: req.params, body: req.body }, 'Failed to add shopping list item');
|
||||
req.log.error(
|
||||
{ error, params: req.params, body: req.body },
|
||||
'Failed to add shopping list item',
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -565,7 +566,7 @@ router.put(
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
res.json(updatedItem);
|
||||
sendSuccess(res, updatedItem);
|
||||
} catch (error: unknown) {
|
||||
req.log.error(
|
||||
{ error, params: req.params, body: req.body },
|
||||
@@ -591,8 +592,12 @@ router.delete(
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteShoppingListItemRequest;
|
||||
try {
|
||||
await db.shoppingRepo.removeShoppingListItem(params.itemId, userProfile.user.user_id, req.log);
|
||||
res.status(204).send();
|
||||
await db.shoppingRepo.removeShoppingListItem(
|
||||
params.itemId,
|
||||
userProfile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
sendNoContent(res);
|
||||
} catch (error: unknown) {
|
||||
req.log.error(
|
||||
{ error, params: req.params },
|
||||
@@ -625,7 +630,7 @@ router.put(
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
res.json(updatedProfile);
|
||||
sendSuccess(res, updatedProfile);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
|
||||
next(error);
|
||||
@@ -644,7 +649,7 @@ router.get(
|
||||
userProfile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
res.json(restrictions);
|
||||
sendSuccess(res, restrictions);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
|
||||
next(error);
|
||||
@@ -671,10 +676,10 @@ router.put(
|
||||
body.restrictionIds,
|
||||
req.log,
|
||||
);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
||||
}
|
||||
req.log.error({ error, body: req.body }, 'Failed to set user dietary restrictions');
|
||||
next(error);
|
||||
@@ -690,7 +695,7 @@ router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next
|
||||
userProfile.user.user_id,
|
||||
req.log,
|
||||
);
|
||||
res.json(appliances);
|
||||
sendSuccess(res, appliances);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
|
||||
next(error);
|
||||
@@ -716,10 +721,10 @@ router.put(
|
||||
body.applianceIds,
|
||||
req.log,
|
||||
);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
||||
}
|
||||
req.log.error({ error, body: req.body }, 'Failed to set user appliances');
|
||||
next(error);
|
||||
@@ -743,7 +748,7 @@ router.get(
|
||||
try {
|
||||
const addressId = params.addressId;
|
||||
const address = await userService.getUserAddress(userProfile, addressId, req.log);
|
||||
res.json(address);
|
||||
sendSuccess(res, address);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching user address');
|
||||
next(error);
|
||||
@@ -783,7 +788,7 @@ router.put(
|
||||
// encapsulated in a single service method that manages the transaction.
|
||||
// This ensures both the address upsert and the user profile update are atomic.
|
||||
const addressId = await userService.upsertUserAddress(userProfile, addressData, req.log); // This was a duplicate, fixed.
|
||||
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
|
||||
sendSuccess(res, { message: 'Address updated successfully', address_id: addressId });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error updating user address');
|
||||
next(error);
|
||||
@@ -803,12 +808,12 @@ router.post(
|
||||
const { body } = req as unknown as z.infer<typeof createRecipeSchema>;
|
||||
try {
|
||||
const recipe = await db.recipeRepo.createRecipe(userProfile.user.user_id, body, req.log);
|
||||
res.status(201).json(recipe);
|
||||
sendSuccess(res, recipe, 201);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error creating recipe');
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -827,7 +832,7 @@ router.delete(
|
||||
const { params } = req as unknown as DeleteRecipeRequest;
|
||||
try {
|
||||
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, false, req.log);
|
||||
res.status(204).send();
|
||||
sendNoContent(res);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, params: req.params },
|
||||
@@ -872,7 +877,7 @@ router.put(
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
res.json(updatedRecipe);
|
||||
sendSuccess(res, updatedRecipe);
|
||||
} catch (error) {
|
||||
req.log.error(
|
||||
{ error, params: req.params, body: req.body },
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('flyerDbInsertSchema', () => {
|
||||
});
|
||||
|
||||
it('should fail if store_id is missing', () => {
|
||||
const { store_id, ...invalid } = validDbFlyer;
|
||||
const { store_id: _store_id, ...invalid } = validDbFlyer;
|
||||
const result = flyerDbInsertSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
@@ -165,4 +165,4 @@ describe('flyerDbInsertSchema', () => {
|
||||
const result = flyerDbInsertSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,43 +24,43 @@ vi.mock('./logger.client', () => ({
|
||||
}));
|
||||
|
||||
// 2. Mock ./apiClient to simply pass calls through to the global fetch.
|
||||
vi.mock('./apiClient', async (importOriginal) => {
|
||||
vi.mock('./apiClient', async () => {
|
||||
// This is the core logic we want to preserve: it calls the global fetch
|
||||
// which is then intercepted by MSW.
|
||||
const apiFetch = (
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
apiOptions: import('./apiClient').ApiOptions = {},
|
||||
) => {
|
||||
const fullUrl = url.startsWith('/') ? `http://localhost/api${url}` : url;
|
||||
options.headers = new Headers(options.headers); // Ensure headers is a Headers object
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
apiOptions: import('./apiClient').ApiOptions = {},
|
||||
) => {
|
||||
const fullUrl = url.startsWith('/') ? `http://localhost/api${url}` : url;
|
||||
options.headers = new Headers(options.headers); // Ensure headers is a Headers object
|
||||
|
||||
if (apiOptions.tokenOverride) {
|
||||
options.headers.set('Authorization', `Bearer ${apiOptions.tokenOverride}`);
|
||||
}
|
||||
if (apiOptions.tokenOverride) {
|
||||
options.headers.set('Authorization', `Bearer ${apiOptions.tokenOverride}`);
|
||||
}
|
||||
|
||||
// ================================= WORKAROUND FOR JSDOM FILE NAME BUG =================================
|
||||
// JSDOM's fetch implementation (undici) loses filenames in FormData.
|
||||
// SOLUTION: Before fetch is called, we find the file, extract its real name,
|
||||
// and add it to a custom header. The MSW handler will read this header.
|
||||
if (options.body instanceof FormData) {
|
||||
console.log(`[apiFetch MOCK] FormData detected. Searching for file to preserve its name.`);
|
||||
for (const value of (options.body as FormData).values()) {
|
||||
if (value instanceof File) {
|
||||
console.log(
|
||||
`[apiFetch MOCK] Found file: '${value.name}'. Setting 'X-Test-Filename' header.`,
|
||||
);
|
||||
options.headers.set('X-Test-Filename', value.name);
|
||||
// We only expect one file per request in these tests, so we can break.
|
||||
break;
|
||||
}
|
||||
// ================================= WORKAROUND FOR JSDOM FILE NAME BUG =================================
|
||||
// JSDOM's fetch implementation (undici) loses filenames in FormData.
|
||||
// SOLUTION: Before fetch is called, we find the file, extract its real name,
|
||||
// and add it to a custom header. The MSW handler will read this header.
|
||||
if (options.body instanceof FormData) {
|
||||
console.log(`[apiFetch MOCK] FormData detected. Searching for file to preserve its name.`);
|
||||
for (const value of (options.body as FormData).values()) {
|
||||
if (value instanceof File) {
|
||||
console.log(
|
||||
`[apiFetch MOCK] Found file: '${value.name}'. Setting 'X-Test-Filename' header.`,
|
||||
);
|
||||
options.headers.set('X-Test-Filename', value.name);
|
||||
// We only expect one file per request in these tests, so we can break.
|
||||
break;
|
||||
}
|
||||
}
|
||||
// ======================================= END WORKAROUND ===============================================
|
||||
}
|
||||
// ======================================= END WORKAROUND ===============================================
|
||||
|
||||
const request = new Request(fullUrl, options);
|
||||
console.log(`[apiFetch MOCK] Executing fetch for URL: ${request.url}.`);
|
||||
return fetch(request);
|
||||
const request = new Request(fullUrl, options);
|
||||
console.log(`[apiFetch MOCK] Executing fetch for URL: ${request.url}.`);
|
||||
return fetch(request);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -75,11 +75,19 @@ vi.mock('./apiClient', async (importOriginal) => {
|
||||
authedPost: <T>(endpoint: string, body: T, options: import('./apiClient').ApiOptions = {}) => {
|
||||
return apiFetch(
|
||||
endpoint,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
options,
|
||||
);
|
||||
},
|
||||
authedPostForm: (endpoint: string, formData: FormData, options: import('./apiClient').ApiOptions = {}) => {
|
||||
authedPostForm: (
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
options: import('./apiClient').ApiOptions = {},
|
||||
) => {
|
||||
return apiFetch(endpoint, { method: 'POST', body: formData }, options);
|
||||
},
|
||||
// Add a mock for ApiOptions to satisfy the compiler
|
||||
@@ -322,7 +330,10 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
it('should throw a generic error with status text if the non-ok API response is not valid JSON', async () => {
|
||||
server.use(
|
||||
http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
|
||||
return HttpResponse.text('Gateway Timeout', { status: 504, statusText: 'Gateway Timeout' });
|
||||
return HttpResponse.text('Gateway Timeout', {
|
||||
status: 504,
|
||||
statusText: 'Gateway Timeout',
|
||||
});
|
||||
}),
|
||||
);
|
||||
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Gateway Timeout');
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
* It communicates with the application's own backend endpoints, which then securely
|
||||
* call the Google AI services. This ensures no API keys are exposed on the client.
|
||||
*/
|
||||
import type {
|
||||
FlyerItem,
|
||||
Store,
|
||||
MasterGroceryItem,
|
||||
ProcessingStage,
|
||||
GroundedResponse,
|
||||
} from '../types';
|
||||
import type { FlyerItem, Store, MasterGroceryItem, ProcessingStage } from '../types';
|
||||
import { logger } from './logger.client';
|
||||
import { apiFetch, authedGet, authedPost, authedPostForm } from './apiClient';
|
||||
import { authedGet, authedPost, authedPostForm } from './apiClient';
|
||||
|
||||
/**
|
||||
* Uploads a flyer file to the backend to be processed asynchronously.
|
||||
@@ -32,7 +26,9 @@ export const uploadAndProcessFlyer = async (
|
||||
formData.append('checksum', checksum);
|
||||
|
||||
logger.info(`[aiApiClient] Starting background processing for file: ${file.name}`);
|
||||
console.error(`[aiApiClient] uploadAndProcessFlyer: Uploading file '${file.name}' with checksum '${checksum}'`);
|
||||
console.error(
|
||||
`[aiApiClient] uploadAndProcessFlyer: Uploading file '${file.name}' with checksum '${checksum}'`,
|
||||
);
|
||||
|
||||
const response = await authedPostForm('/ai/upload-and-process', formData, { tokenOverride });
|
||||
|
||||
@@ -43,6 +39,7 @@ export const uploadAndProcessFlyer = async (
|
||||
try {
|
||||
errorBody = await response.json();
|
||||
} catch (e) {
|
||||
logger.debug({ err: e }, 'Failed to parse error response as JSON, falling back to text');
|
||||
errorBody = { message: await clonedResponse.text() };
|
||||
}
|
||||
// Throw a structured error so the component can inspect the status and body
|
||||
@@ -91,10 +88,7 @@ export class JobFailedError extends Error {
|
||||
* @returns A promise that resolves to the parsed job status object.
|
||||
* @throws A `JobFailedError` if the job has failed, or a generic `Error` for other issues.
|
||||
*/
|
||||
export const getJobStatus = async (
|
||||
jobId: string,
|
||||
tokenOverride?: string,
|
||||
): Promise<JobStatus> => {
|
||||
export const getJobStatus = async (jobId: string, tokenOverride?: string): Promise<JobStatus> => {
|
||||
console.error(`[aiApiClient] getJobStatus: Fetching status for job '${jobId}'`);
|
||||
const response = await authedGet(`/ai/jobs/${jobId}/status`, { tokenOverride });
|
||||
|
||||
@@ -110,7 +104,10 @@ export const getJobStatus = async (
|
||||
} catch (e) {
|
||||
// The body was not JSON, which is fine for a server error page.
|
||||
// The default message is sufficient.
|
||||
logger.warn('getJobStatus received a non-JSON error response.', { status: response.status });
|
||||
logger.warn(
|
||||
{ err: e, status: response.status },
|
||||
'getJobStatus received a non-JSON error response.',
|
||||
);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
@@ -146,10 +143,7 @@ export const getJobStatus = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const isImageAFlyer = (
|
||||
imageFile: File,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => {
|
||||
export const isImageAFlyer = (imageFile: File, tokenOverride?: string): Promise<Response> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/services/aiService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import { createMockLogger } from '../tests/utils/mockLogger';
|
||||
import type { Logger } from 'pino';
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
AIService,
|
||||
aiService as aiServiceSingleton,
|
||||
DuplicateFlyerError,
|
||||
type RawFlyerItem,
|
||||
} from './aiService.server';
|
||||
import {
|
||||
createMockMasterGroceryItem,
|
||||
@@ -30,14 +29,15 @@ import { logger as mockLoggerInstance } from './logger.server';
|
||||
// Explicitly unmock the service under test to ensure we import the real implementation.
|
||||
vi.unmock('./aiService.server');
|
||||
|
||||
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity } = vi.hoisted(() => {
|
||||
const mockGenerateContent = vi.fn();
|
||||
const mockToBuffer = vi.fn();
|
||||
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
||||
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
||||
const mockAdminLogActivity = vi.fn();
|
||||
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity };
|
||||
});
|
||||
const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity } =
|
||||
vi.hoisted(() => {
|
||||
const mockGenerateContent = vi.fn();
|
||||
const mockToBuffer = vi.fn();
|
||||
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
||||
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
||||
const mockAdminLogActivity = vi.fn();
|
||||
return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity };
|
||||
});
|
||||
|
||||
// Mock sharp, as it's a direct dependency of the service.
|
||||
vi.mock('sharp', () => ({
|
||||
@@ -151,6 +151,7 @@ describe('AI Service (Server)', () => {
|
||||
const resultEmpty = AiFlyerDataSchema.safeParse(dataWithEmpty);
|
||||
|
||||
expect(resultNull.success).toBe(false);
|
||||
expect(resultEmpty.success).toBe(false);
|
||||
// Null checks fail with a generic type error, which is acceptable.
|
||||
});
|
||||
});
|
||||
@@ -275,7 +276,7 @@ describe('AI Service (Server)', () => {
|
||||
};
|
||||
// The adapter strips `useLiteModels` before calling the underlying client,
|
||||
// so we prepare the expected request shape for our assertions.
|
||||
const { useLiteModels, ...apiReq } = request;
|
||||
const { useLiteModels: _useLiteModels, ...apiReq } = request;
|
||||
|
||||
// Act
|
||||
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
|
||||
@@ -291,6 +292,68 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use full models when useLiteModels is explicitly false', async () => {
|
||||
// Arrange
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const { logger } = await import('./logger.server');
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
const models = (serviceWithFallback as any).models;
|
||||
const models_lite = (serviceWithFallback as any).models_lite;
|
||||
const successResponse = { text: 'Success from full model', candidates: [] };
|
||||
|
||||
mockGenerateContent.mockResolvedValue(successResponse);
|
||||
|
||||
const request = {
|
||||
contents: [{ parts: [{ text: 'test prompt' }] }],
|
||||
useLiteModels: false,
|
||||
};
|
||||
const { useLiteModels: _useLiteModels, ...apiReq } = request;
|
||||
|
||||
// Act
|
||||
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(successResponse);
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check that the first model from the FULL list was used, not lite
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith({
|
||||
model: models[0],
|
||||
...apiReq,
|
||||
});
|
||||
// Verify it's actually different from the lite list
|
||||
expect(models[0]).not.toBe(models_lite[0]);
|
||||
});
|
||||
|
||||
it('should use full models when useLiteModels is omitted (default behavior)', async () => {
|
||||
// Arrange
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const { logger } = await import('./logger.server');
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
const models = (serviceWithFallback as any).models;
|
||||
const successResponse = { text: 'Success from full model', candidates: [] };
|
||||
|
||||
mockGenerateContent.mockResolvedValue(successResponse);
|
||||
|
||||
// Note: useLiteModels is NOT included in the request
|
||||
const request = {
|
||||
contents: [{ parts: [{ text: 'test prompt' }] }],
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(successResponse);
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check that the first model from the full list was used
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith({
|
||||
model: models[0],
|
||||
...request,
|
||||
});
|
||||
});
|
||||
|
||||
it('should try the next model if the first one fails with a quota error', async () => {
|
||||
// Arrange
|
||||
const { AIService } = await import('./aiService.server');
|
||||
@@ -314,13 +377,15 @@ describe('AI Service (Server)', () => {
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check first call
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
|
||||
// The first model in the list
|
||||
model: models[0],
|
||||
...request,
|
||||
});
|
||||
|
||||
// Check second call
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
|
||||
// The second model in the list
|
||||
model: models[1],
|
||||
...request,
|
||||
});
|
||||
@@ -340,6 +405,7 @@ describe('AI Service (Server)', () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
const models = (serviceWithFallback as any).models;
|
||||
const firstModel = models[0];
|
||||
|
||||
const nonRetriableError = new Error('Invalid API Key');
|
||||
mockGenerateContent.mockRejectedValueOnce(nonRetriableError);
|
||||
@@ -353,8 +419,8 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: nonRetriableError }, // The first model in the list is now 'gemini-2.5-flash'
|
||||
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
|
||||
{ error: nonRetriableError },
|
||||
`[AIService Adapter] Model '${firstModel}' failed with a non-retriable error.`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -407,7 +473,9 @@ describe('AI Service (Server)', () => {
|
||||
// Access private property for testing purposes
|
||||
const modelsLite = (serviceWithFallback as any).models_lite as string[];
|
||||
// Use a quota error to trigger the fallback logic for each model
|
||||
const errors = modelsLite.map((model, i) => new Error(`Quota error for lite model ${model} (${i})`));
|
||||
const errors = modelsLite.map(
|
||||
(model, i) => new Error(`Quota error for lite model ${model} (${i})`),
|
||||
);
|
||||
const lastError = errors[errors.length - 1];
|
||||
|
||||
// Dynamically setup mocks
|
||||
@@ -421,7 +489,7 @@ describe('AI Service (Server)', () => {
|
||||
};
|
||||
// The adapter strips `useLiteModels` before calling the underlying client,
|
||||
// so we prepare the expected request shape for our assertions.
|
||||
const { useLiteModels, ...apiReq } = request;
|
||||
const { useLiteModels: _useLiteModels, ...apiReq } = request;
|
||||
|
||||
// Act & Assert
|
||||
// Expect the entire operation to reject with the error from the very last model attempt.
|
||||
@@ -454,9 +522,7 @@ describe('AI Service (Server)', () => {
|
||||
const error1 = new Error('Quota exceeded for model 1');
|
||||
const successResponse = { text: 'Success', candidates: [] };
|
||||
|
||||
mockGenerateContent
|
||||
.mockRejectedValueOnce(error1)
|
||||
.mockResolvedValueOnce(successResponse);
|
||||
mockGenerateContent.mockRejectedValueOnce(error1).mockResolvedValueOnce(successResponse);
|
||||
|
||||
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
|
||||
|
||||
@@ -505,7 +571,9 @@ describe('AI Service (Server)', () => {
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { model: models[0], ...request });
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { model: models[1], ...request });
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(`Model '${models[0]}' failed due to quota/rate limit/overload.`));
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Model '${models[0]}' failed due to quota/rate limit/overload.`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail immediately on a 400 Bad Request error without retrying', async () => {
|
||||
@@ -521,7 +589,9 @@ describe('AI Service (Server)', () => {
|
||||
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
|
||||
|
||||
// Act & Assert
|
||||
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(nonRetriableError);
|
||||
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
|
||||
nonRetriableError,
|
||||
);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith({ model: models[0], ...request });
|
||||
@@ -1054,8 +1124,9 @@ describe('AI Service (Server)', () => {
|
||||
filename: 'upload.jpg',
|
||||
originalname: 'orig.jpg',
|
||||
} as Express.Multer.File; // This was a duplicate, fixed.
|
||||
const mockProfile = createMockUserProfile({ user: { user_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' } });
|
||||
|
||||
const mockProfile = createMockUserProfile({
|
||||
user: { user_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Default success mocks. Use createMockFlyer for a more complete mock.
|
||||
@@ -1086,26 +1157,18 @@ describe('AI Service (Server)', () => {
|
||||
it('should throw ValidationError if checksum is missing', async () => {
|
||||
const body = { data: JSON.stringify({}) }; // No checksum
|
||||
await expect(
|
||||
aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw DuplicateFlyerError if checksum exists', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 55 }));
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(
|
||||
createMockFlyer({ flyer_id: 55 }),
|
||||
);
|
||||
const body = { checksum: 'dup-sum' };
|
||||
|
||||
await expect(
|
||||
aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
|
||||
).rejects.toThrow(DuplicateFlyerError);
|
||||
});
|
||||
|
||||
@@ -1225,12 +1288,7 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
// This will eventually throw ValidationError because checksum won't be found
|
||||
await expect(
|
||||
aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
|
||||
).rejects.toThrow(ValidationError);
|
||||
|
||||
// Verify that the error was caught and logged using errMsg logic
|
||||
@@ -1241,19 +1299,17 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
|
||||
it('should log and re-throw the original error if the database transaction fails', async () => {
|
||||
const body = { checksum: 'legacy-fail-checksum', extractedData: { store_name: 'Fail Store' } };
|
||||
const body = {
|
||||
checksum: 'legacy-fail-checksum',
|
||||
extractedData: { store_name: 'Fail Store' },
|
||||
};
|
||||
const dbError = new Error('DB transaction failed');
|
||||
|
||||
// Mock withTransaction to fail
|
||||
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||
|
||||
await expect(
|
||||
aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
|
||||
).rejects.toThrow(dbError);
|
||||
|
||||
// Verify the service-level error logging
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
FlyerInsert,
|
||||
Flyer,
|
||||
} from '../types';
|
||||
import { DatabaseError, FlyerProcessingError } from './processingErrors';
|
||||
import { FlyerProcessingError } from './processingErrors';
|
||||
import * as db from './db/index.db';
|
||||
import { flyerQueue } from './queueService.server';
|
||||
import type { Job } from 'bullmq';
|
||||
@@ -28,10 +28,7 @@ import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor'
|
||||
import { AdminRepository } from './db/admin.db';
|
||||
import path from 'path';
|
||||
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
|
||||
import {
|
||||
AiFlyerDataSchema,
|
||||
ExtractedFlyerItemSchema,
|
||||
} from '../types/ai'; // Import consolidated schemas
|
||||
import { AiFlyerDataSchema, ExtractedFlyerItemSchema } from '../types/ai'; // Import consolidated schemas
|
||||
|
||||
interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
|
||||
checksum?: string;
|
||||
@@ -76,7 +73,10 @@ interface IAiClient {
|
||||
export type RawFlyerItem = z.infer<typeof ExtractedFlyerItemSchema>;
|
||||
|
||||
export class DuplicateFlyerError extends FlyerProcessingError {
|
||||
constructor(message: string, public flyerId: number) {
|
||||
constructor(
|
||||
message: string,
|
||||
public flyerId: number,
|
||||
) {
|
||||
super(message, 'DUPLICATE_FLYER', message);
|
||||
}
|
||||
}
|
||||
@@ -87,29 +87,29 @@ export class AIService {
|
||||
private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
private logger: Logger;
|
||||
|
||||
// OPTIMIZED: Flyer Image Processing (Vision + Long Output)
|
||||
// OPTIMIZED: Flyer Image Processing (Vision + Long Output)
|
||||
// PRIORITIES:
|
||||
// 1. Output Limit: Must be 65k+ (Gemini 2.5/3.0) to avoid cutting off data.
|
||||
// 2. Intelligence: 'Pro' models handle messy layouts better.
|
||||
// 3. Quota Management: 'Preview' and 'Exp' models are added as fallbacks to tap into separate rate limits.
|
||||
private readonly models = [
|
||||
// --- TIER A: The Happy Path (Fast & Stable) ---
|
||||
'gemini-2.5-flash', // Primary workhorse. 65k output.
|
||||
'gemini-2.5-flash-lite', // Cost-saver. 65k output.
|
||||
'gemini-2.5-flash', // Primary workhorse. 65k output.
|
||||
'gemini-2.5-flash-lite', // Cost-saver. 65k output.
|
||||
|
||||
// --- TIER B: The Heavy Lifters (Complex Layouts) ---
|
||||
'gemini-2.5-pro', // High IQ for messy flyers. 65k output.
|
||||
'gemini-2.5-pro', // High IQ for messy flyers. 65k output.
|
||||
|
||||
// --- TIER C: Separate Quota Buckets (Previews) ---
|
||||
'gemini-3-flash-preview', // Newer/Faster. Separate 'Preview' quota. 65k output.
|
||||
'gemini-3-pro-preview', // High IQ. Separate 'Preview' quota. 65k output.
|
||||
|
||||
'gemini-3-flash-preview', // Newer/Faster. Separate 'Preview' quota. 65k output.
|
||||
'gemini-3-pro-preview', // High IQ. Separate 'Preview' quota. 65k output.
|
||||
|
||||
// --- TIER D: Experimental Buckets (High Capacity) ---
|
||||
'gemini-exp-1206', // Excellent reasoning. Separate 'Experimental' quota. 65k output.
|
||||
'gemini-exp-1206', // Excellent reasoning. Separate 'Experimental' quota. 65k output.
|
||||
|
||||
// --- TIER E: Last Resorts (Lower Capacity/Local) ---
|
||||
'gemma-3-27b-it', // Open model fallback.
|
||||
'gemini-2.0-flash-exp' // Exp fallback. WARNING: 8k output limit. Good for small flyers only.
|
||||
'gemma-3-27b-it', // Open model fallback.
|
||||
'gemini-2.0-flash-exp', // Exp fallback. WARNING: 8k output limit. Good for small flyers only.
|
||||
];
|
||||
|
||||
// OPTIMIZED: Simple Text Tasks (Recipes, Shopping Lists, Summaries)
|
||||
@@ -118,22 +118,22 @@ export class AIService {
|
||||
// 2. Output Limit: The 8k limit of Gemini 2.0 is perfectly fine here.
|
||||
private readonly models_lite = [
|
||||
// --- Best Value (Smart + Cheap) ---
|
||||
"gemini-2.5-flash-lite", // Current generation efficiency king.
|
||||
|
||||
'gemini-2.5-flash-lite', // Current generation efficiency king.
|
||||
|
||||
// --- The "Recycled" Gemini 2.0 Models (Perfect for Text) ---
|
||||
"gemini-2.0-flash-lite-001", // Extremely cheap, very capable for text.
|
||||
"gemini-2.0-flash-001", // Smarter than Lite, good for complex recipes.
|
||||
|
||||
'gemini-2.0-flash-lite-001', // Extremely cheap, very capable for text.
|
||||
'gemini-2.0-flash-001', // Smarter than Lite, good for complex recipes.
|
||||
|
||||
// --- Open Models (Good for simple categorization) ---
|
||||
"gemma-3-12b-it", // Solid reasoning for an open model.
|
||||
"gemma-3-4b-it", // Very fast.
|
||||
|
||||
'gemma-3-12b-it', // Solid reasoning for an open model.
|
||||
'gemma-3-4b-it', // Very fast.
|
||||
|
||||
// --- Quota Fallbacks (Experimental/Preview) ---
|
||||
"gemini-2.0-flash-exp", // Use this separate quota bucket if others are exhausted.
|
||||
|
||||
'gemini-2.0-flash-exp', // Use this separate quota bucket if others are exhausted.
|
||||
|
||||
// --- Edge/Nano Models (Simple string manipulation only) ---
|
||||
"gemma-3n-e4b-it", // Corrected name from JSON
|
||||
"gemma-3n-e2b-it" // Corrected name from JSON
|
||||
'gemma-3n-e4b-it', // Corrected name from JSON
|
||||
'gemma-3n-e2b-it', // Corrected name from JSON
|
||||
];
|
||||
|
||||
// Helper to return valid mock data for tests
|
||||
@@ -258,7 +258,7 @@ export class AIService {
|
||||
} else {
|
||||
try {
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
errorMsg = String((error as any).message);
|
||||
errorMsg = String((error as { message: unknown }).message);
|
||||
} else {
|
||||
errorMsg = JSON.stringify(error);
|
||||
}
|
||||
@@ -391,7 +391,9 @@ export class AIService {
|
||||
);
|
||||
|
||||
if (!responseText) {
|
||||
logger.warn('[_parseJsonFromAiResponse] Response text is empty or undefined. Aborting parsing.');
|
||||
logger.warn(
|
||||
'[_parseJsonFromAiResponse] Response text is empty or undefined. Aborting parsing.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -407,7 +409,9 @@ export class AIService {
|
||||
);
|
||||
jsonString = markdownMatch[2].trim();
|
||||
} else {
|
||||
logger.debug('[_parseJsonFromAiResponse] No markdown code block found. Using raw response text.');
|
||||
logger.debug(
|
||||
'[_parseJsonFromAiResponse] No markdown code block found. Using raw response text.',
|
||||
);
|
||||
jsonString = responseText;
|
||||
}
|
||||
|
||||
@@ -537,9 +541,15 @@ export class AIService {
|
||||
submitterIp?: string,
|
||||
userProfileAddress?: string,
|
||||
logger: Logger = this.logger,
|
||||
): Promise<{
|
||||
store_name: string | null; valid_from: string | null; valid_to: string | null; store_address: string | null; items: z.infer<typeof ExtractedFlyerItemSchema>[];
|
||||
} & z.infer<typeof AiFlyerDataSchema>> {
|
||||
): Promise<
|
||||
{
|
||||
store_name: string | null;
|
||||
valid_from: string | null;
|
||||
valid_to: string | null;
|
||||
store_address: string | null;
|
||||
items: z.infer<typeof ExtractedFlyerItemSchema>[];
|
||||
} & z.infer<typeof AiFlyerDataSchema>
|
||||
> {
|
||||
logger.info(
|
||||
`[extractCoreDataFromFlyerImage] Entering method with ${imagePaths.length} image(s).`,
|
||||
);
|
||||
@@ -761,8 +771,7 @@ export class AIService {
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
async enqueueFlyerProcessing(
|
||||
async enqueueFlyerProcessing(
|
||||
file: Express.Multer.File,
|
||||
checksum: string,
|
||||
userProfile: UserProfile | undefined,
|
||||
@@ -821,15 +830,13 @@ async enqueueFlyerProcessing(
|
||||
baseUrl: baseUrl,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Enqueued flyer for processing. File: ${file.originalname}, Job ID: ${job.id}`,
|
||||
);
|
||||
logger.info(`Enqueued flyer for processing. File: ${file.originalname}, Job ID: ${job.id}`);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private _parseLegacyPayload(
|
||||
body: any,
|
||||
body: unknown,
|
||||
logger: Logger,
|
||||
): { parsed: FlyerProcessPayload; extractedData: Partial<ExtractedCoreData> | null | undefined } {
|
||||
logger.debug({ body, type: typeof body }, '[AIService] Starting _parseLegacyPayload');
|
||||
@@ -838,7 +845,10 @@ async enqueueFlyerProcessing(
|
||||
try {
|
||||
parsed = typeof body === 'string' ? JSON.parse(body) : body || {};
|
||||
} catch (e) {
|
||||
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse top-level request body string.');
|
||||
logger.warn(
|
||||
{ error: errMsg(e) },
|
||||
'[AIService] Failed to parse top-level request body string.',
|
||||
);
|
||||
return { parsed: {}, extractedData: {} };
|
||||
}
|
||||
logger.debug({ parsed }, '[AIService] Parsed top-level body');
|
||||
@@ -851,13 +861,19 @@ async enqueueFlyerProcessing(
|
||||
try {
|
||||
potentialPayload = JSON.parse(parsed.data);
|
||||
} catch (e) {
|
||||
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse nested "data" property string.');
|
||||
logger.warn(
|
||||
{ error: errMsg(e) },
|
||||
'[AIService] Failed to parse nested "data" property string.',
|
||||
);
|
||||
}
|
||||
} else if (typeof parsed.data === 'object') {
|
||||
potentialPayload = parsed.data;
|
||||
}
|
||||
}
|
||||
logger.debug({ potentialPayload }, '[AIService] Potential payload after checking "data" property');
|
||||
logger.debug(
|
||||
{ potentialPayload },
|
||||
'[AIService] Potential payload after checking "data" property',
|
||||
);
|
||||
|
||||
// The extracted data is either in an `extractedData` key or is the payload itself.
|
||||
const extractedData = potentialPayload.extractedData ?? potentialPayload;
|
||||
@@ -873,7 +889,7 @@ async enqueueFlyerProcessing(
|
||||
|
||||
async processLegacyFlyerUpload(
|
||||
file: Express.Multer.File,
|
||||
body: any,
|
||||
body: unknown,
|
||||
userProfile: UserProfile | undefined,
|
||||
logger: Logger,
|
||||
): Promise<Flyer> {
|
||||
@@ -889,10 +905,14 @@ async enqueueFlyerProcessing(
|
||||
|
||||
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
|
||||
if (existingFlyer) {
|
||||
throw new DuplicateFlyerError('This flyer has already been processed.', existingFlyer.flyer_id);
|
||||
throw new DuplicateFlyerError(
|
||||
'This flyer has already been processed.',
|
||||
existingFlyer.flyer_id,
|
||||
);
|
||||
}
|
||||
|
||||
const originalFileName = parsed.originalFileName ?? parsed?.data?.originalFileName ?? file.originalname;
|
||||
const originalFileName =
|
||||
parsed.originalFileName ?? parsed?.data?.originalFileName ?? file.originalname;
|
||||
|
||||
if (!extractedData || typeof extractedData !== 'object') {
|
||||
logger.warn({ bodyData: parsed }, 'Missing extractedData in legacy payload.');
|
||||
@@ -900,7 +920,11 @@ async enqueueFlyerProcessing(
|
||||
}
|
||||
|
||||
const rawItems = extractedData.items ?? [];
|
||||
const itemsArray = Array.isArray(rawItems) ? rawItems : typeof rawItems === 'string' ? JSON.parse(rawItems) : [];
|
||||
const itemsArray = Array.isArray(rawItems)
|
||||
? rawItems
|
||||
: typeof rawItems === 'string'
|
||||
? JSON.parse(rawItems)
|
||||
: [];
|
||||
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
|
||||
...item,
|
||||
// Ensure empty or nullish price_display is stored as NULL to satisfy database constraints.
|
||||
@@ -912,7 +936,10 @@ async enqueueFlyerProcessing(
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const storeName = extractedData.store_name && String(extractedData.store_name).trim().length > 0 ? String(extractedData.store_name) : 'Unknown Store (auto)';
|
||||
const storeName =
|
||||
extractedData.store_name && String(extractedData.store_name).trim().length > 0
|
||||
? String(extractedData.store_name)
|
||||
: 'Unknown Store (auto)';
|
||||
if (storeName.startsWith('Unknown')) {
|
||||
logger.warn('extractedData.store_name missing; using fallback store name.');
|
||||
}
|
||||
@@ -950,28 +977,30 @@ async enqueueFlyerProcessing(
|
||||
uploaded_by: userProfile?.user.user_id,
|
||||
};
|
||||
|
||||
return db.withTransaction(async (client) => {
|
||||
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
|
||||
return db
|
||||
.withTransaction(async (client) => {
|
||||
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
|
||||
|
||||
logger.info(
|
||||
`Successfully processed legacy flyer: ${flyer.file_name} (ID: ${flyer.flyer_id}) with ${items.length} items.`,
|
||||
);
|
||||
logger.info(
|
||||
`Successfully processed legacy flyer: ${flyer.file_name} (ID: ${flyer.flyer_id}) with ${items.length} items.`,
|
||||
);
|
||||
|
||||
const transactionalAdminRepo = new AdminRepository(client);
|
||||
await transactionalAdminRepo.logActivity(
|
||||
{
|
||||
userId: userProfile?.user.user_id,
|
||||
action: 'flyer_processed',
|
||||
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
||||
details: { flyerId: flyer.flyer_id, storeName: flyerData.store_name },
|
||||
},
|
||||
logger,
|
||||
);
|
||||
return flyer;
|
||||
}).catch((error) => {
|
||||
logger.error({ err: error, checksum }, 'Legacy flyer upload database transaction failed.');
|
||||
throw error;
|
||||
});
|
||||
const transactionalAdminRepo = new AdminRepository(client);
|
||||
await transactionalAdminRepo.logActivity(
|
||||
{
|
||||
userId: userProfile?.user.user_id,
|
||||
action: 'flyer_processed',
|
||||
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
||||
details: { flyerId: flyer.flyer_id, storeName: flyerData.store_name },
|
||||
},
|
||||
logger,
|
||||
);
|
||||
return flyer;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error({ err: error, checksum }, 'Legacy flyer upload database transaction failed.');
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,8 +94,7 @@ export const apiFetch = async (
|
||||
// unless the path is already a full URL. This works for both browser and Node.js.
|
||||
const fullUrl = url.startsWith('http') ? url : joinUrl(API_BASE_URL, url);
|
||||
|
||||
logger.debug(`apiFetch: ${options.method || 'GET'} ${fullUrl}`);
|
||||
console.error(`[apiClient] apiFetch Request: ${options.method || 'GET'} ${fullUrl}`);
|
||||
logger.debug({ method: options.method || 'GET', url: fullUrl }, 'apiFetch: Request');
|
||||
|
||||
// Create a new headers object to avoid mutating the original options.
|
||||
const headers = new Headers(options.headers || {});
|
||||
@@ -150,7 +149,8 @@ export const apiFetch = async (
|
||||
// --- DEBUG LOGGING for failed requests ---
|
||||
if (!response.ok) {
|
||||
const responseText = await response.clone().text();
|
||||
logger.error({ url: fullUrl, status: response.status, body: responseText },
|
||||
logger.error(
|
||||
{ url: fullUrl, status: response.status, body: responseText },
|
||||
'apiFetch: Request failed',
|
||||
);
|
||||
}
|
||||
@@ -181,7 +181,11 @@ export const authedGet = (endpoint: string, options: ApiOptions = {}): Promise<R
|
||||
};
|
||||
|
||||
/** Helper for authenticated POST requests with a JSON body */
|
||||
export const authedPost = <T>(endpoint: string, body: T, options: ApiOptions = {}): Promise<Response> => {
|
||||
export const authedPost = <T>(
|
||||
endpoint: string,
|
||||
body: T,
|
||||
options: ApiOptions = {},
|
||||
): Promise<Response> => {
|
||||
return apiFetch(
|
||||
endpoint,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
|
||||
@@ -204,7 +208,11 @@ export const authedPostForm = (
|
||||
};
|
||||
|
||||
/** Helper for authenticated PUT requests with a JSON body */
|
||||
export const authedPut = <T>(endpoint: string, body: T, options: ApiOptions = {}): Promise<Response> => {
|
||||
export const authedPut = <T>(
|
||||
endpoint: string,
|
||||
body: T,
|
||||
options: ApiOptions = {},
|
||||
): Promise<Response> => {
|
||||
return apiFetch(
|
||||
endpoint,
|
||||
{ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
|
||||
@@ -406,7 +414,8 @@ export const addWatchedItem = (
|
||||
itemName: string,
|
||||
category: string,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedPost('/users/watched-items', { itemName, category }, { tokenOverride });
|
||||
): Promise<Response> =>
|
||||
authedPost('/users/watched-items', { itemName, category }, { tokenOverride });
|
||||
|
||||
export const removeWatchedItem = (
|
||||
masterItemId: number,
|
||||
@@ -426,10 +435,8 @@ export const fetchBestSalePrices = (tokenOverride?: string): Promise<Response> =
|
||||
export const fetchShoppingLists = (tokenOverride?: string): Promise<Response> =>
|
||||
authedGet('/users/shopping-lists', { tokenOverride });
|
||||
|
||||
export const fetchShoppingListById = (
|
||||
listId: number,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedGet(`/users/shopping-lists/${listId}`, { tokenOverride });
|
||||
export const fetchShoppingListById = (listId: number, tokenOverride?: string): Promise<Response> =>
|
||||
authedGet(`/users/shopping-lists/${listId}`, { tokenOverride });
|
||||
|
||||
export const createShoppingList = (name: string, tokenOverride?: string): Promise<Response> =>
|
||||
authedPost('/users/shopping-lists', { name }, { tokenOverride });
|
||||
@@ -451,10 +458,8 @@ export const updateShoppingListItem = (
|
||||
): Promise<Response> =>
|
||||
authedPut(`/users/shopping-lists/items/${itemId}`, updates, { tokenOverride });
|
||||
|
||||
export const removeShoppingListItem = (
|
||||
itemId: number,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedDelete(`/users/shopping-lists/items/${itemId}`, { tokenOverride });
|
||||
export const removeShoppingListItem = (itemId: number, tokenOverride?: string): Promise<Response> =>
|
||||
authedDelete(`/users/shopping-lists/items/${itemId}`, { tokenOverride });
|
||||
|
||||
/**
|
||||
* Fetches the full profile for the currently authenticated user.
|
||||
@@ -483,10 +488,7 @@ export async function loginUser(
|
||||
* @param receiptImage The image file of the receipt.
|
||||
* @returns A promise that resolves with the backend's response, including the newly created receipt record.
|
||||
*/
|
||||
export const uploadReceipt = (
|
||||
receiptImage: File,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => {
|
||||
export const uploadReceipt = (receiptImage: File, tokenOverride?: string): Promise<Response> => {
|
||||
const formData = new FormData();
|
||||
formData.append('receiptImage', receiptImage);
|
||||
return authedPostForm('/receipts/upload', formData, { tokenOverride });
|
||||
@@ -580,18 +582,14 @@ export const getUserFeed = (
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedGet(`/users/feed?limit=${limit}&offset=${offset}`, { tokenOverride });
|
||||
|
||||
export const forkRecipe = (
|
||||
originalRecipeId: number,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedPostEmpty(`/recipes/${originalRecipeId}/fork`, { tokenOverride });
|
||||
export const forkRecipe = (originalRecipeId: number, tokenOverride?: string): Promise<Response> =>
|
||||
authedPostEmpty(`/recipes/${originalRecipeId}/fork`, { tokenOverride });
|
||||
|
||||
export const followUser = (userIdToFollow: string, tokenOverride?: string): Promise<Response> =>
|
||||
authedPostEmpty(`/users/${userIdToFollow}/follow`, { tokenOverride });
|
||||
|
||||
export const unfollowUser = (
|
||||
userIdToUnfollow: string,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedDelete(`/users/${userIdToUnfollow}/follow`, { tokenOverride });
|
||||
export const unfollowUser = (userIdToUnfollow: string, tokenOverride?: string): Promise<Response> =>
|
||||
authedDelete(`/users/${userIdToUnfollow}/follow`, { tokenOverride });
|
||||
|
||||
// --- Activity Log API Function ---
|
||||
|
||||
@@ -618,15 +616,11 @@ export const fetchActivityLog = (
|
||||
export const getUserFavoriteRecipes = (tokenOverride?: string): Promise<Response> =>
|
||||
authedGet('/users/me/favorite-recipes', { tokenOverride });
|
||||
|
||||
export const addFavoriteRecipe = (
|
||||
recipeId: number,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedPost('/users/me/favorite-recipes', { recipeId }, { tokenOverride });
|
||||
export const addFavoriteRecipe = (recipeId: number, tokenOverride?: string): Promise<Response> =>
|
||||
authedPost('/users/me/favorite-recipes', { recipeId }, { tokenOverride });
|
||||
|
||||
export const removeFavoriteRecipe = (
|
||||
recipeId: number,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedDelete(`/users/me/favorite-recipes/${recipeId}`, { tokenOverride });
|
||||
export const removeFavoriteRecipe = (recipeId: number, tokenOverride?: string): Promise<Response> =>
|
||||
authedDelete(`/users/me/favorite-recipes/${recipeId}`, { tokenOverride });
|
||||
|
||||
// --- Recipe Comments API Functions ---
|
||||
|
||||
@@ -655,10 +649,7 @@ export const addRecipeComment = (
|
||||
* @param tokenOverride Optional token for testing.
|
||||
* @returns A promise that resolves to the API response containing the suggestion.
|
||||
*/
|
||||
export const suggestRecipe = (
|
||||
ingredients: string[],
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => {
|
||||
export const suggestRecipe = (ingredients: string[], tokenOverride?: string): Promise<Response> => {
|
||||
// This is a protected endpoint, so we use authedPost.
|
||||
return authedPost('/recipes/suggest', { ingredients }, { tokenOverride });
|
||||
};
|
||||
@@ -687,7 +678,8 @@ export const updateRecipeCommentStatus = (
|
||||
commentId: number,
|
||||
status: 'visible' | 'hidden' | 'reported',
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedPut(`/admin/comments/${commentId}/status`, { status }, { tokenOverride });
|
||||
): Promise<Response> =>
|
||||
authedPut(`/admin/comments/${commentId}/status`, { status }, { tokenOverride });
|
||||
|
||||
/**
|
||||
* Fetches all brands from the backend. Requires admin privileges.
|
||||
@@ -737,12 +729,11 @@ export const getFlyersForReview = (tokenOverride?: string): Promise<Response> =>
|
||||
export const approveCorrection = (
|
||||
correctionId: number,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedPostEmpty(`/admin/corrections/${correctionId}/approve`, { tokenOverride });
|
||||
): Promise<Response> =>
|
||||
authedPostEmpty(`/admin/corrections/${correctionId}/approve`, { tokenOverride });
|
||||
|
||||
export const rejectCorrection = (
|
||||
correctionId: number,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => authedPostEmpty(`/admin/corrections/${correctionId}/reject`, { tokenOverride });
|
||||
export const rejectCorrection = (correctionId: number, tokenOverride?: string): Promise<Response> =>
|
||||
authedPostEmpty(`/admin/corrections/${correctionId}/reject`, { tokenOverride });
|
||||
|
||||
export const updateSuggestedCorrection = (
|
||||
correctionId: number,
|
||||
@@ -1032,7 +1023,9 @@ export const getSpendingAnalysis = (
|
||||
endDate: string,
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> =>
|
||||
authedGet(`/budgets/spending-analysis?startDate=${startDate}&endDate=${endDate}`, { tokenOverride });
|
||||
authedGet(`/budgets/spending-analysis?startDate=${startDate}&endDate=${endDate}`, {
|
||||
tokenOverride,
|
||||
});
|
||||
|
||||
// --- Gamification API Functions ---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { UserProfile } from '../types';
|
||||
import type * as jsonwebtoken from 'jsonwebtoken';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
const { transactionalUserRepoMocks, transactionalAdminRepoMocks } = vi.hoisted(() => {
|
||||
return {
|
||||
@@ -18,10 +19,14 @@ const { transactionalUserRepoMocks, transactionalAdminRepoMocks } = vi.hoisted((
|
||||
});
|
||||
|
||||
vi.mock('./db/user.db', () => ({
|
||||
UserRepository: vi.fn().mockImplementation(function () { return transactionalUserRepoMocks }),
|
||||
UserRepository: vi.fn().mockImplementation(function () {
|
||||
return transactionalUserRepoMocks;
|
||||
}),
|
||||
}));
|
||||
vi.mock('./db/admin.db', () => ({
|
||||
AdminRepository: vi.fn().mockImplementation(function () { return transactionalAdminRepoMocks }),
|
||||
AdminRepository: vi.fn().mockImplementation(function () {
|
||||
return transactionalAdminRepoMocks;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AuthService', () => {
|
||||
@@ -29,7 +34,7 @@ describe('AuthService', () => {
|
||||
let bcrypt: typeof import('bcrypt');
|
||||
let jwt: typeof jsonwebtoken & { default: typeof jsonwebtoken };
|
||||
let userRepo: typeof import('./db/index.db').userRepo;
|
||||
let adminRepo: typeof import('./db/index.db').adminRepo;
|
||||
let _adminRepo: typeof import('./db/index.db').adminRepo;
|
||||
let logger: typeof import('./logger.server').logger;
|
||||
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
|
||||
let DatabaseError: typeof import('./processingErrors').DatabaseError;
|
||||
@@ -38,7 +43,18 @@ describe('AuthService', () => {
|
||||
let ValidationError: typeof import('./db/errors.db').ValidationError;
|
||||
let withTransaction: typeof import('./db/index.db').withTransaction;
|
||||
|
||||
const reqLog = {}; // Mock request logger object
|
||||
// Mock request logger with all required Logger methods
|
||||
const reqLog = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
silent: vi.fn(),
|
||||
level: 'info',
|
||||
child: vi.fn().mockReturnThis(),
|
||||
} as unknown as Logger;
|
||||
const mockUser = {
|
||||
user_id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
@@ -98,7 +114,7 @@ describe('AuthService', () => {
|
||||
jwt = (await import('jsonwebtoken')) as typeof jwt;
|
||||
const dbModule = await import('./db/index.db');
|
||||
userRepo = dbModule.userRepo;
|
||||
adminRepo = dbModule.adminRepo;
|
||||
_adminRepo = dbModule.adminRepo;
|
||||
logger = (await import('./logger.server')).logger;
|
||||
withTransaction = (await import('./db/index.db')).withTransaction;
|
||||
vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
|
||||
@@ -156,7 +172,7 @@ describe('AuthService', () => {
|
||||
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log and re-throw generic errors on registration failure', async () => {
|
||||
@@ -168,12 +184,18 @@ describe('AuthService', () => {
|
||||
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
||||
).rejects.toThrow(DatabaseError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({ error, email: 'test@example.com' }, `User registration failed with an unexpected error.`);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error, email: 'test@example.com' },
|
||||
`User registration failed with an unexpected error.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ValidationError if password is weak', async () => {
|
||||
const { validatePasswordStrength } = await import('../utils/authUtils');
|
||||
vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: false, feedback: 'Password too weak' });
|
||||
vi.mocked(validatePasswordStrength).mockReturnValue({
|
||||
isValid: false,
|
||||
feedback: 'Password too weak',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.registerUser('test@example.com', 'weak', 'Test User', undefined, reqLog),
|
||||
@@ -248,7 +270,9 @@ describe('AuthService', () => {
|
||||
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
|
||||
|
||||
// The service method now directly propagates the error from the repo.
|
||||
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(error);
|
||||
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(
|
||||
error,
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -305,7 +329,10 @@ describe('AuthService', () => {
|
||||
|
||||
const result = await authService.resetPassword('test@example.com', reqLog);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({ emailError }, `Email send failure during password reset for user`);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ emailError },
|
||||
`Email send failure during password reset for user`,
|
||||
);
|
||||
expect(result).toBe('mocked_random_id');
|
||||
});
|
||||
|
||||
@@ -313,7 +340,9 @@ describe('AuthService', () => {
|
||||
const repoError = new RepositoryError('Repo error', 500);
|
||||
vi.mocked(userRepo.findUserByEmail).mockRejectedValue(repoError);
|
||||
|
||||
await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow(repoError);
|
||||
await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow(
|
||||
repoError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,7 +365,10 @@ describe('AuthService', () => {
|
||||
'new-hashed-password',
|
||||
reqLog,
|
||||
);
|
||||
expect(transactionalUserRepoMocks.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
|
||||
expect(transactionalUserRepoMocks.deleteResetToken).toHaveBeenCalledWith(
|
||||
'hashed-token',
|
||||
reqLog,
|
||||
);
|
||||
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'password_reset' }),
|
||||
reqLog,
|
||||
@@ -351,9 +383,14 @@ describe('AuthService', () => {
|
||||
const dbError = new Error('Transaction failed');
|
||||
vi.mocked(withTransaction).mockRejectedValue(dbError);
|
||||
|
||||
await expect(authService.updatePassword('valid-token', 'newPassword', reqLog)).rejects.toThrow(DatabaseError);
|
||||
await expect(
|
||||
authService.updatePassword('valid-token', 'newPassword', reqLog),
|
||||
).rejects.toThrow(DatabaseError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `An unexpected error occurred during password update.`);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
`An unexpected error occurred during password update.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if token is invalid or not found', async () => {
|
||||
@@ -367,24 +404,34 @@ describe('AuthService', () => {
|
||||
|
||||
it('should throw ValidationError if new password is weak', async () => {
|
||||
const { validatePasswordStrength } = await import('../utils/authUtils');
|
||||
vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: false, feedback: 'Password too weak' });
|
||||
vi.mocked(validatePasswordStrength).mockReturnValue({
|
||||
isValid: false,
|
||||
feedback: 'Password too weak',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.updatePassword('token', 'weak', reqLog),
|
||||
).rejects.toThrow(ValidationError);
|
||||
await expect(authService.updatePassword('token', 'weak', reqLog)).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should re-throw RepositoryError from transaction', async () => {
|
||||
const repoError = new RepositoryError('Repo error', 500);
|
||||
vi.mocked(withTransaction).mockRejectedValue(repoError);
|
||||
|
||||
await expect(authService.updatePassword('token', 'newPass', reqLog)).rejects.toThrow(repoError);
|
||||
await expect(authService.updatePassword('token', 'newPass', reqLog)).rejects.toThrow(
|
||||
repoError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserByRefreshToken', () => {
|
||||
it('should return user profile if token exists', async () => {
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({
|
||||
user_id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
||||
|
||||
const result = await authService.getUserByRefreshToken('valid-token', reqLog);
|
||||
@@ -423,7 +470,9 @@ describe('AuthService', () => {
|
||||
const repoError = new RepositoryError('Some repo error', 500);
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(repoError);
|
||||
|
||||
await expect(authService.getUserByRefreshToken('any-token', reqLog)).rejects.toThrow(repoError);
|
||||
await expect(authService.getUserByRefreshToken('any-token', reqLog)).rejects.toThrow(
|
||||
repoError,
|
||||
);
|
||||
// The original error is re-thrown, so the generic wrapper log should not be called.
|
||||
expect(logger.error).not.toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
@@ -449,7 +498,12 @@ describe('AuthService', () => {
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
it('should return new access token if user found', async () => {
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({
|
||||
user_id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
@@ -475,4 +529,4 @@ describe('AuthService', () => {
|
||||
await expect(authService.refreshAccessToken('any-token', reqLog)).rejects.toThrow(dbError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseError, FlyerProcessingError } from './processingErrors';
|
||||
import { DatabaseError } from './processingErrors';
|
||||
import type { Logger } from 'pino';
|
||||
import { withTransaction, userRepo } from './db/index.db';
|
||||
import { RepositoryError, ValidationError } from './db/errors.db';
|
||||
import { logger } from './logger.server';
|
||||
@@ -18,7 +19,7 @@ class AuthService {
|
||||
password: string,
|
||||
fullName: string | undefined,
|
||||
avatarUrl: string | undefined,
|
||||
reqLog: any,
|
||||
reqLog: Logger,
|
||||
) {
|
||||
const strength = validatePasswordStrength(password);
|
||||
if (!strength.isValid) {
|
||||
@@ -42,10 +43,17 @@ class AuthService {
|
||||
reqLog,
|
||||
);
|
||||
|
||||
logger.info(`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`);
|
||||
logger.info(
|
||||
`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`,
|
||||
);
|
||||
|
||||
await adminRepo.logActivity(
|
||||
{ userId: newUser.user.user_id, action: 'user_registered', displayText: `${email} has registered.`, icon: 'user-plus' },
|
||||
{
|
||||
userId: newUser.user.user_id,
|
||||
action: 'user_registered',
|
||||
displayText: `${email} has registered.`,
|
||||
icon: 'user-plus',
|
||||
},
|
||||
reqLog,
|
||||
);
|
||||
|
||||
@@ -57,7 +65,8 @@ class AuthService {
|
||||
}
|
||||
// For unknown errors, log them and wrap them in a generic DatabaseError
|
||||
// to standardize the error contract of the service layer.
|
||||
const message = error instanceof Error ? error.message : 'An unknown error occurred during registration.';
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'An unknown error occurred during registration.';
|
||||
logger.error({ error, email }, `User registration failed with an unexpected error.`);
|
||||
throw new DatabaseError(message);
|
||||
});
|
||||
@@ -68,15 +77,9 @@ class AuthService {
|
||||
password: string,
|
||||
fullName: string | undefined,
|
||||
avatarUrl: string | undefined,
|
||||
reqLog: any,
|
||||
reqLog: Logger,
|
||||
): Promise<{ newUserProfile: UserProfile; accessToken: string; refreshToken: string }> {
|
||||
const newUserProfile = await this.registerUser(
|
||||
email,
|
||||
password,
|
||||
fullName,
|
||||
avatarUrl,
|
||||
reqLog,
|
||||
);
|
||||
const newUserProfile = await this.registerUser(email, password, fullName, avatarUrl, reqLog);
|
||||
const { accessToken, refreshToken } = await this.handleSuccessfulLogin(newUserProfile, reqLog);
|
||||
return { newUserProfile, accessToken, refreshToken };
|
||||
}
|
||||
@@ -93,19 +96,19 @@ class AuthService {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async saveRefreshToken(userId: string, refreshToken: string, reqLog: any) {
|
||||
async saveRefreshToken(userId: string, refreshToken: string, reqLog: Logger) {
|
||||
// The repository method `saveRefreshToken` already includes robust error handling
|
||||
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
||||
await userRepo.saveRefreshToken(userId, refreshToken, reqLog);
|
||||
}
|
||||
|
||||
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: any) {
|
||||
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: Logger) {
|
||||
const { accessToken, refreshToken } = this.generateAuthTokens(userProfile);
|
||||
await this.saveRefreshToken(userProfile.user.user_id, refreshToken, reqLog);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async resetPassword(email: string, reqLog: any) {
|
||||
async resetPassword(email: string, reqLog: Logger) {
|
||||
try {
|
||||
logger.debug(`[API /forgot-password] Received request for email: ${email}`);
|
||||
const user = await userRepo.findUserByEmail(email, reqLog);
|
||||
@@ -124,7 +127,13 @@ class AuthService {
|
||||
// Wrap the token creation in a transaction to ensure atomicity of the DELETE and INSERT operations.
|
||||
await withTransaction(async (client) => {
|
||||
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
||||
await transactionalUserRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, reqLog, client);
|
||||
await transactionalUserRepo.createPasswordResetToken(
|
||||
user.user_id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
reqLog,
|
||||
client,
|
||||
);
|
||||
});
|
||||
|
||||
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
|
||||
@@ -146,12 +155,15 @@ class AuthService {
|
||||
}
|
||||
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||
logger.error({ error, email }, `An unexpected error occurred during password reset for email: ${email}`);
|
||||
logger.error(
|
||||
{ error, email },
|
||||
`An unexpected error occurred during password reset for email: ${email}`,
|
||||
);
|
||||
throw new DatabaseError(message);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePassword(token: string, newPassword: string, reqLog: any) {
|
||||
async updatePassword(token: string, newPassword: string, reqLog: Logger) {
|
||||
const strength = validatePasswordStrength(newPassword);
|
||||
if (!strength.isValid) {
|
||||
throw new ValidationError([], strength.feedback);
|
||||
@@ -184,7 +196,12 @@ class AuthService {
|
||||
await transactionalUserRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, reqLog);
|
||||
await transactionalUserRepo.deleteResetToken(tokenRecord.token_hash, reqLog);
|
||||
await adminRepo.logActivity(
|
||||
{ userId: tokenRecord.user_id, action: 'password_reset', displayText: `User ID ${tokenRecord.user_id} has reset their password.`, icon: 'key' },
|
||||
{
|
||||
userId: tokenRecord.user_id,
|
||||
action: 'password_reset',
|
||||
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
|
||||
icon: 'key',
|
||||
},
|
||||
reqLog,
|
||||
);
|
||||
|
||||
@@ -201,7 +218,7 @@ class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByRefreshToken(refreshToken: string, reqLog: any) {
|
||||
async getUserByRefreshToken(refreshToken: string, reqLog: Logger) {
|
||||
try {
|
||||
const basicUser = await userRepo.findUserByRefreshToken(refreshToken, reqLog);
|
||||
if (!basicUser) {
|
||||
@@ -216,19 +233,25 @@ class AuthService {
|
||||
}
|
||||
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||
logger.error({ error, refreshToken }, 'An unexpected error occurred while fetching user by refresh token.');
|
||||
logger.error(
|
||||
{ error, refreshToken },
|
||||
'An unexpected error occurred while fetching user by refresh token.',
|
||||
);
|
||||
throw new DatabaseError(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async logout(refreshToken: string, reqLog: any) {
|
||||
async logout(refreshToken: string, reqLog: Logger) {
|
||||
// The repository method `deleteRefreshToken` now includes robust error handling
|
||||
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
||||
// The original implementation also swallowed errors, which is now fixed.
|
||||
await userRepo.deleteRefreshToken(refreshToken, reqLog);
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string, reqLog: any): Promise<{ accessToken: string } | null> {
|
||||
async refreshAccessToken(
|
||||
refreshToken: string,
|
||||
reqLog: Logger,
|
||||
): Promise<{ accessToken: string } | null> {
|
||||
const user = await this.getUserByRefreshToken(refreshToken, reqLog);
|
||||
if (!user) {
|
||||
return null;
|
||||
@@ -238,4 +261,4 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
export const authService = new AuthService();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { UniqueConstraintError, NotFoundError, handleDbError } from './errors.db';
|
||||
import { NotFoundError, handleDbError } from './errors.db';
|
||||
import { Address } from '../../types';
|
||||
|
||||
export class AddressRepository {
|
||||
@@ -30,9 +30,15 @@ export class AddressRepository {
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getAddressById', { addressId }, {
|
||||
defaultMessage: 'Failed to retrieve address.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getAddressById',
|
||||
{ addressId },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve address.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +82,16 @@ export class AddressRepository {
|
||||
const res = await this.db.query<{ address_id: number }>(query, values);
|
||||
return res.rows[0].address_id;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in upsertAddress', { address }, {
|
||||
uniqueMessage: 'An identical address already exists.',
|
||||
defaultMessage: 'Failed to upsert address.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in upsertAddress',
|
||||
{ address },
|
||||
{
|
||||
uniqueMessage: 'An identical address already exists.',
|
||||
defaultMessage: 'Failed to upsert address.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/services/db/admin.db.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { AdminRepository } from './admin.db';
|
||||
import type { SuggestedCorrection, AdminUserView, Profile, Flyer } from '../../types';
|
||||
@@ -84,10 +84,7 @@ describe('Admin DB Service', () => {
|
||||
mockDb.query.mockResolvedValue({ rows: [] }); // Mock the function call
|
||||
await adminRepo.approveCorrection(123, mockLogger);
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
'SELECT public.approve_correction($1)',
|
||||
[123],
|
||||
);
|
||||
expect(mockDb.query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]);
|
||||
});
|
||||
|
||||
it('should throw an error if the database function fails', async () => {
|
||||
@@ -223,9 +220,7 @@ describe('Admin DB Service', () => {
|
||||
|
||||
const result = await adminRepo.getDailyStatsForLast30Days(mockLogger);
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WITH date_series AS'),
|
||||
);
|
||||
expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('WITH date_series AS'));
|
||||
expect(result).toEqual(mockStats);
|
||||
});
|
||||
|
||||
@@ -347,10 +342,10 @@ describe('Admin DB Service', () => {
|
||||
const mockRecipe = { recipe_id: 1, status: 'public' };
|
||||
mockDb.query.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
|
||||
const result = await adminRepo.updateRecipeStatus(1, 'public', mockLogger);
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.recipes'),
|
||||
['public', 1],
|
||||
);
|
||||
expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), [
|
||||
'public',
|
||||
1,
|
||||
]);
|
||||
expect(result).toEqual(mockRecipe);
|
||||
});
|
||||
|
||||
@@ -592,10 +587,10 @@ describe('Admin DB Service', () => {
|
||||
const mockReceipt = { receipt_id: 1, status: 'completed' };
|
||||
mockDb.query.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 });
|
||||
const result = await adminRepo.updateReceiptStatus(1, 'completed', mockLogger);
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE public.receipts'),
|
||||
['completed', 1],
|
||||
);
|
||||
expect(mockDb.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), [
|
||||
'completed',
|
||||
1,
|
||||
]);
|
||||
expect(result).toEqual(mockReceipt);
|
||||
});
|
||||
|
||||
@@ -748,7 +743,10 @@ describe('Admin DB Service', () => {
|
||||
await expect(adminRepo.getFlyersForReview(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve flyers for review.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getFlyersForReview');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
'Database error in getFlyersForReview',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/services/db/admin.db.ts
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool, withTransaction } from './connection.db';
|
||||
import { ForeignKeyConstraintError, NotFoundError, CheckConstraintError, handleDbError } from './errors.db';
|
||||
import { NotFoundError, handleDbError } from './errors.db';
|
||||
import type { Logger } from 'pino';
|
||||
import {
|
||||
SuggestedCorrection,
|
||||
@@ -262,9 +262,15 @@ export class AdminRepository {
|
||||
const res = await this.db.query<MostFrequentSaleItem>(query, [days, limit]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getMostFrequentSaleItems', { days, limit }, {
|
||||
defaultMessage: 'Failed to get most frequent sale items.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getMostFrequentSaleItems',
|
||||
{ days, limit },
|
||||
{
|
||||
defaultMessage: 'Failed to get most frequent sale items.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,10 +298,16 @@ export class AdminRepository {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
handleDbError(error, logger, 'Database error in updateRecipeCommentStatus', { commentId, status }, {
|
||||
checkMessage: 'Invalid status provided for recipe comment.',
|
||||
defaultMessage: 'Failed to update recipe comment status.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in updateRecipeCommentStatus',
|
||||
{ commentId, status },
|
||||
{
|
||||
checkMessage: 'Invalid status provided for recipe comment.',
|
||||
defaultMessage: 'Failed to update recipe comment status.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,9 +338,15 @@ export class AdminRepository {
|
||||
const res = await this.db.query<UnmatchedFlyerItem>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getUnmatchedFlyerItems', {}, {
|
||||
defaultMessage: 'Failed to retrieve unmatched flyer items.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getUnmatchedFlyerItems',
|
||||
{},
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve unmatched flyer items.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,10 +372,16 @@ export class AdminRepository {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
handleDbError(error, logger, 'Database error in updateRecipeStatus', { recipeId, status }, {
|
||||
checkMessage: 'Invalid status provided for recipe.',
|
||||
defaultMessage: 'Failed to update recipe status.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in updateRecipeStatus',
|
||||
{ recipeId, status },
|
||||
{
|
||||
checkMessage: 'Invalid status provided for recipe.',
|
||||
defaultMessage: 'Failed to update recipe status.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +438,10 @@ export class AdminRepository {
|
||||
logger,
|
||||
'Database transaction error in resolveUnmatchedFlyerItem',
|
||||
{ unmatchedFlyerItemId, masterItemId },
|
||||
{ fkMessage: 'The specified master item ID does not exist.', defaultMessage: 'Failed to resolve unmatched flyer item.' },
|
||||
{
|
||||
fkMessage: 'The specified master item ID does not exist.',
|
||||
defaultMessage: 'Failed to resolve unmatched flyer item.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -587,10 +614,16 @@ export class AdminRepository {
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
handleDbError(error, logger, 'Database error in updateReceiptStatus', { receiptId, status }, {
|
||||
checkMessage: 'Invalid status provided for receipt.',
|
||||
defaultMessage: 'Failed to update receipt status.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in updateReceiptStatus',
|
||||
{ receiptId, status },
|
||||
{
|
||||
checkMessage: 'Invalid status provided for receipt.',
|
||||
defaultMessage: 'Failed to update receipt status.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,9 +636,15 @@ export class AdminRepository {
|
||||
const res = await this.db.query<AdminUserView>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getAllUsers', {}, {
|
||||
defaultMessage: 'Failed to retrieve all users.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getAllUsers',
|
||||
{},
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve all users.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,11 +668,17 @@ export class AdminRepository {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
handleDbError(error, logger, 'Database error in updateUserRole', { userId, role }, {
|
||||
fkMessage: 'The specified user does not exist.',
|
||||
checkMessage: 'Invalid role provided for user.',
|
||||
defaultMessage: 'Failed to update user role.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in updateUserRole',
|
||||
{ userId, role },
|
||||
{
|
||||
fkMessage: 'The specified user does not exist.',
|
||||
checkMessage: 'Invalid role provided for user.',
|
||||
defaultMessage: 'Failed to update user role.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,9 +705,15 @@ export class AdminRepository {
|
||||
const res = await this.db.query<Flyer>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getFlyersForReview', {}, {
|
||||
defaultMessage: 'Failed to retrieve flyers for review.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getFlyersForReview',
|
||||
{},
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve flyers for review.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ForeignKeyConstraintError } from './errors.db';
|
||||
vi.unmock('./budget.db');
|
||||
|
||||
import { BudgetRepository } from './budget.db';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { PoolClient } from 'pg';
|
||||
import type { Budget, SpendingByCategory } from '../../types';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
@@ -260,7 +260,6 @@ describe('Budget DB Service', () => {
|
||||
).rejects.toThrow('Budget not found or user does not have permission to update.');
|
||||
});
|
||||
|
||||
|
||||
it('should throw an error if no rows are updated', async () => {
|
||||
// Arrange: Mock the query to return 0 rows affected
|
||||
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
@@ -56,11 +56,11 @@ describe('DB Connection Service', () => {
|
||||
// Reset specific method behaviors
|
||||
mocks.mockPoolInstance.query.mockReset();
|
||||
|
||||
// Mock pool.on to capture the error handler
|
||||
let capturedErrorHandler: ((err: Error, client: PoolClient) => void) | undefined;
|
||||
// Mock pool.on to capture the error handler (kept for potential future use in error handling tests)
|
||||
let _capturedErrorHandler: ((err: Error, client: PoolClient) => void) | undefined;
|
||||
vi.mocked(mocks.mockPoolInstance.on).mockImplementation((event, handler) => {
|
||||
if (event === 'error') {
|
||||
capturedErrorHandler = handler as (err: Error, client: PoolClient) => void;
|
||||
_capturedErrorHandler = handler as (err: Error, client: PoolClient) => void;
|
||||
}
|
||||
return mocks.mockPoolInstance; // Return the mock instance for chaining
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export const conversionRepo = {
|
||||
const { masterItemId } = filters;
|
||||
try {
|
||||
let query = 'SELECT * FROM public.unit_conversions';
|
||||
const params: any[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (masterItemId) {
|
||||
query += ' WHERE master_item_id = $1';
|
||||
@@ -27,9 +27,15 @@ export const conversionRepo = {
|
||||
const result = await getPool().query<UnitConversion>(query, params);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getConversions', { filters }, {
|
||||
defaultMessage: 'Failed to retrieve unit conversions.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getConversions',
|
||||
{ filters },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve unit conversions.',
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -48,12 +54,19 @@ export const conversionRepo = {
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in createConversion', { conversionData }, {
|
||||
fkMessage: 'The specified master item does not exist.',
|
||||
uniqueMessage: 'This conversion rule already exists for this item.',
|
||||
checkMessage: 'Invalid unit conversion data provided (e.g., factor must be > 0, units cannot be the same).',
|
||||
defaultMessage: 'Failed to create unit conversion.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in createConversion',
|
||||
{ conversionData },
|
||||
{
|
||||
fkMessage: 'The specified master item does not exist.',
|
||||
uniqueMessage: 'This conversion rule already exists for this item.',
|
||||
checkMessage:
|
||||
'Invalid unit conversion data provided (e.g., factor must be > 0, units cannot be the same).',
|
||||
defaultMessage: 'Failed to create unit conversion.',
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,9 +83,15 @@ export const conversionRepo = {
|
||||
throw new NotFoundError(`Unit conversion with ID ${conversionId} not found.`);
|
||||
}
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in deleteConversion', { conversionId }, {
|
||||
defaultMessage: 'Failed to delete unit conversion.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in deleteConversion',
|
||||
{ conversionId },
|
||||
{
|
||||
defaultMessage: 'Failed to delete unit conversion.',
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/services/db/flyer.db.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import {
|
||||
@@ -162,7 +162,7 @@ describe('Flyer DB Service', () => {
|
||||
|
||||
expect(result).toEqual(mockFlyer);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO flyers'),
|
||||
[
|
||||
'test.jpg',
|
||||
@@ -408,7 +408,7 @@ describe('Flyer DB Service', () => {
|
||||
expect(queryValues).toEqual([
|
||||
1, // flyerId for item 1
|
||||
'Free Item',
|
||||
"N/A", // Sanitized price_display for item 1
|
||||
'N/A', // Sanitized price_display for item 1
|
||||
0,
|
||||
'1',
|
||||
'Promo',
|
||||
@@ -416,7 +416,7 @@ describe('Flyer DB Service', () => {
|
||||
0,
|
||||
1, // flyerId for item 2
|
||||
'Whitespace Item',
|
||||
"N/A", // Sanitized price_display for item 2
|
||||
'N/A', // Sanitized price_display for item 2
|
||||
null,
|
||||
'1',
|
||||
'Promo',
|
||||
@@ -428,7 +428,8 @@ describe('Flyer DB Service', () => {
|
||||
|
||||
describe('createFlyerAndItems', () => {
|
||||
it('should execute find/create store, insert flyer, and insert items using the provided client', async () => {
|
||||
const flyerData: FlyerInsert = { // This was a duplicate, fixed.
|
||||
const flyerData: FlyerInsert = {
|
||||
// This was a duplicate, fixed.
|
||||
file_name: 'transact.jpg',
|
||||
store_name: 'Transaction Store',
|
||||
} as FlyerInsert;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool, withTransaction } from './connection.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { UniqueConstraintError, NotFoundError, handleDbError } from './errors.db';
|
||||
import { NotFoundError, handleDbError } from './errors.db';
|
||||
import { cacheService, CACHE_TTL, CACHE_PREFIX } from '../cacheService.server';
|
||||
import type {
|
||||
Flyer,
|
||||
@@ -51,10 +51,16 @@ export class FlyerRepository {
|
||||
return result.rows[0].store_id;
|
||||
} catch (error) {
|
||||
// Use the centralized error handler for any unexpected database errors.
|
||||
handleDbError(error, logger, 'Database error in findOrCreateStore', { storeName }, {
|
||||
// Any error caught here is unexpected, so we use a generic message.
|
||||
defaultMessage: 'Failed to find or create store in database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in findOrCreateStore',
|
||||
{ storeName },
|
||||
{
|
||||
// Any error caught here is unexpected, so we use a generic message.
|
||||
defaultMessage: 'Failed to find or create store in database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +70,13 @@ export class FlyerRepository {
|
||||
* @returns The newly created flyer record with its ID.
|
||||
*/
|
||||
async insertFlyer(flyerData: FlyerDbInsert, logger: Logger): Promise<Flyer> {
|
||||
console.error('[DB DEBUG] FlyerRepository.insertFlyer called with:', JSON.stringify(flyerData, null, 2));
|
||||
console.error(
|
||||
'[DB DEBUG] FlyerRepository.insertFlyer called with:',
|
||||
JSON.stringify(flyerData, null, 2),
|
||||
);
|
||||
// Sanitize icon_url: Ensure empty strings become NULL to avoid regex constraint violations
|
||||
let iconUrl = flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null;
|
||||
let iconUrl =
|
||||
flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null;
|
||||
let imageUrl = flyerData.image_url || 'placeholder.jpg';
|
||||
|
||||
try {
|
||||
@@ -84,12 +94,12 @@ export class FlyerRepository {
|
||||
}
|
||||
|
||||
if (imageUrl && !imageUrl.startsWith('http')) {
|
||||
const cleanPath = imageUrl.startsWith('/') ? imageUrl.substring(1) : imageUrl;
|
||||
imageUrl = `${baseUrl}/${cleanPath}`;
|
||||
const cleanPath = imageUrl.startsWith('/') ? imageUrl.substring(1) : imageUrl;
|
||||
imageUrl = `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
if (iconUrl && !iconUrl.startsWith('http')) {
|
||||
const cleanPath = iconUrl.startsWith('/') ? iconUrl.substring(1) : iconUrl;
|
||||
iconUrl = `${baseUrl}/${cleanPath}`;
|
||||
const cleanPath = iconUrl.startsWith('/') ? iconUrl.substring(1) : iconUrl;
|
||||
iconUrl = `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
|
||||
console.error('[DB DEBUG] Final URLs for insert:', { imageUrl, iconUrl });
|
||||
@@ -136,10 +146,11 @@ export class FlyerRepository {
|
||||
offendingData: {
|
||||
image_url: flyerData.image_url,
|
||||
icon_url: flyerData.icon_url, // Log raw input
|
||||
sanitized_icon_url: flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null
|
||||
}
|
||||
sanitized_icon_url:
|
||||
flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null,
|
||||
},
|
||||
},
|
||||
'[DB ERROR] URL Check Constraint Failed. Inspecting URLs.'
|
||||
'[DB ERROR] URL Check Constraint Failed. Inspecting URLs.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,12 +163,18 @@ export class FlyerRepository {
|
||||
checkMsg = `[URL_CHECK_FAIL] Invalid URL format. Image: '${imageUrl}', Icon: '${iconUrl}'`;
|
||||
}
|
||||
|
||||
handleDbError(error, logger, 'Database error in insertFlyer', { flyerData }, {
|
||||
uniqueMessage: 'A flyer with this checksum already exists.',
|
||||
fkMessage: 'The specified user or store for this flyer does not exist.',
|
||||
checkMessage: checkMsg,
|
||||
defaultMessage: 'Failed to insert flyer into database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in insertFlyer',
|
||||
{ flyerData },
|
||||
{
|
||||
uniqueMessage: 'A flyer with this checksum already exists.',
|
||||
fkMessage: 'The specified user or store for this flyer does not exist.',
|
||||
checkMessage: checkMsg,
|
||||
defaultMessage: 'Failed to insert flyer into database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,9 +206,7 @@ export class FlyerRepository {
|
||||
// Sanitize price_display. The database requires a non-empty string.
|
||||
// We provide a default value if the input is null, undefined, or an empty string.
|
||||
const priceDisplay =
|
||||
item.price_display && item.price_display.trim() !== ''
|
||||
? item.price_display
|
||||
: 'N/A';
|
||||
item.price_display && item.price_display.trim() !== '' ? item.price_display : 'N/A';
|
||||
|
||||
values.push(
|
||||
flyerId,
|
||||
@@ -221,10 +236,16 @@ export class FlyerRepository {
|
||||
const result = await this.db.query<FlyerItem>(query, values);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in insertFlyerItems', { flyerId }, {
|
||||
fkMessage: 'The specified flyer, category, master item, or product does not exist.',
|
||||
defaultMessage: 'An unknown error occurred while inserting flyer items.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in insertFlyerItems',
|
||||
{ flyerId },
|
||||
{
|
||||
fkMessage: 'The specified flyer, category, master item, or product does not exist.',
|
||||
defaultMessage: 'An unknown error occurred while inserting flyer items.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,9 +269,15 @@ export class FlyerRepository {
|
||||
const res = await this.db.query<Brand>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getAllBrands', {}, {
|
||||
defaultMessage: 'Failed to retrieve brands from database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getAllBrands',
|
||||
{},
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve brands from database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
{ ttl: CACHE_TTL.BRANDS, logger },
|
||||
@@ -298,9 +325,15 @@ export class FlyerRepository {
|
||||
const res = await this.db.query<Flyer>(query, [limit, offset]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getFlyers', { limit, offset }, {
|
||||
defaultMessage: 'Failed to retrieve flyers from database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getFlyers',
|
||||
{ limit, offset },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve flyers from database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
{ ttl: CACHE_TTL.FLYERS, logger },
|
||||
@@ -326,9 +359,15 @@ export class FlyerRepository {
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getFlyerItems', { flyerId }, {
|
||||
defaultMessage: 'Failed to retrieve flyer items from database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getFlyerItems',
|
||||
{ flyerId },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve flyer items from database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
{ ttl: CACHE_TTL.FLYER_ITEMS, logger },
|
||||
@@ -348,9 +387,15 @@ export class FlyerRepository {
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getFlyerItemsForFlyers', { flyerIds }, {
|
||||
defaultMessage: 'Failed to retrieve flyer items in batch from database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getFlyerItemsForFlyers',
|
||||
{ flyerIds },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve flyer items in batch from database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,9 +415,15 @@ export class FlyerRepository {
|
||||
);
|
||||
return parseInt(res.rows[0].count, 10);
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in countFlyerItemsForFlyers', { flyerIds }, {
|
||||
defaultMessage: 'Failed to count flyer items in batch from database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in countFlyerItemsForFlyers',
|
||||
{ flyerIds },
|
||||
{
|
||||
defaultMessage: 'Failed to count flyer items in batch from database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,9 +439,15 @@ export class FlyerRepository {
|
||||
]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in findFlyerByChecksum', { checksum }, {
|
||||
defaultMessage: 'Failed to find flyer by checksum in database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in findFlyerByChecksum',
|
||||
{ checksum },
|
||||
{
|
||||
defaultMessage: 'Failed to find flyer by checksum in database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,9 +503,15 @@ export class FlyerRepository {
|
||||
// Invalidate cache after successful deletion
|
||||
await cacheService.invalidateFlyer(flyerId, logger);
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database transaction error in deleteFlyer', { flyerId }, {
|
||||
defaultMessage: 'Failed to delete flyer.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database transaction error in deleteFlyer',
|
||||
{ flyerId },
|
||||
{
|
||||
defaultMessage: 'Failed to delete flyer.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// src/services/db/gamification.db.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Pool } from 'pg';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
|
||||
// FIX 2: Un-mock the module we are testing.
|
||||
vi.unmock('./gamification.db');
|
||||
@@ -34,7 +32,7 @@ describe('Gamification DB Service', () => {
|
||||
// Instantiate the repository with the mock pool for each test
|
||||
gamificationRepo = new GamificationRepository(mockDb);
|
||||
});
|
||||
|
||||
|
||||
describe('getAllAchievements', () => {
|
||||
it('should execute the correct SELECT query and return achievements', async () => {
|
||||
const mockAchievements: Achievement[] = [
|
||||
@@ -87,7 +85,7 @@ describe('Gamification DB Service', () => {
|
||||
mockDb.query.mockResolvedValue({ rows: mockUserAchievements });
|
||||
|
||||
const result = await gamificationRepo.getUserAchievements('user-123', mockLogger);
|
||||
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM public.user_achievements ua'),
|
||||
['user-123'],
|
||||
@@ -113,10 +111,10 @@ describe('Gamification DB Service', () => {
|
||||
mockDb.query.mockResolvedValue({ rows: [] }); // The function returns void
|
||||
await gamificationRepo.awardAchievement('user-123', 'Test Achievement', mockLogger);
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
'SELECT public.award_achievement($1, $2)',
|
||||
['user-123', 'Test Achievement'],
|
||||
);
|
||||
expect(mockDb.query).toHaveBeenCalledWith('SELECT public.award_achievement($1, $2)', [
|
||||
'user-123',
|
||||
'Test Achievement',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw ForeignKeyConstraintError if user or achievement does not exist', async () => {
|
||||
@@ -159,8 +157,8 @@ describe('Gamification DB Service', () => {
|
||||
describe('getLeaderboard', () => {
|
||||
it('should execute the correct SELECT query with a LIMIT and return leaderboard users', async () => {
|
||||
const mockLeaderboard: LeaderboardUser[] = [
|
||||
{ user_id: 'user-1', full_name: 'User One', avatar_url: null, points: 500, rank: '1' },
|
||||
{ user_id: 'user-2', full_name: 'User Two', avatar_url: null, points: 450, rank: '2' }
|
||||
{ user_id: 'user-1', full_name: 'User One', avatar_url: null, points: 500, rank: '1' },
|
||||
{ user_id: 'user-2', full_name: 'User Two', avatar_url: null, points: 450, rank: '2' },
|
||||
];
|
||||
mockDb.query.mockResolvedValue({ rows: mockLeaderboard });
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import type { Notification } from '../../types';
|
||||
import { createMockNotification } from '../../tests/utils/mockFactories';
|
||||
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('../logger.server', () => ({
|
||||
logger: {
|
||||
@@ -24,9 +23,6 @@ import { logger as mockLogger } from '../logger.server';
|
||||
|
||||
describe('Notification DB Service', () => {
|
||||
let notificationRepo: NotificationRepository;
|
||||
const mockDb = {
|
||||
query: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -283,9 +279,9 @@ describe('Notification DB Service', () => {
|
||||
it('should not mark a notification as read if the user does not own it', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
|
||||
await expect(notificationRepo.markNotificationAsRead(1, 'wrong-user', mockLogger)).rejects.toThrow(
|
||||
'Notification not found or user does not have permission.',
|
||||
);
|
||||
await expect(
|
||||
notificationRepo.markNotificationAsRead(1, 'wrong-user', mockLogger),
|
||||
).rejects.toThrow('Notification not found or user does not have permission.');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/services/db/reaction.db.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { ReactionRepository } from './reaction.db';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import { withTransaction } from './connection.db';
|
||||
@@ -214,13 +214,13 @@ describe('Reaction DB Service', () => {
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(
|
||||
reactionRepo.getReactionSummary('recipe', '123', mockLogger),
|
||||
).rejects.toThrow('Failed to retrieve reaction summary.');
|
||||
await expect(reactionRepo.getReactionSummary('recipe', '123', mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve reaction summary.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, entityType: 'recipe', entityId: '123' },
|
||||
'Database error in getReactionSummary',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ReactionRepository {
|
||||
const { userId, entityType, entityId } = filters;
|
||||
try {
|
||||
let query = 'SELECT * FROM public.user_reactions WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (userId) {
|
||||
@@ -50,9 +50,15 @@ export class ReactionRepository {
|
||||
const result = await this.db.query<UserReaction>(query, params);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getReactions', { filters }, {
|
||||
defaultMessage: 'Failed to retrieve user reactions.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getReactions',
|
||||
{ filters },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve user reactions.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +94,16 @@ export class ReactionRepository {
|
||||
return insertRes.rows[0];
|
||||
});
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in toggleReaction', { reactionData }, {
|
||||
fkMessage: 'The specified user or entity does not exist.',
|
||||
defaultMessage: 'Failed to toggle user reaction.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in toggleReaction',
|
||||
{ reactionData },
|
||||
{
|
||||
fkMessage: 'The specified user or entity does not exist.',
|
||||
defaultMessage: 'Failed to toggle user reaction.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,14 +130,23 @@ export class ReactionRepository {
|
||||
GROUP BY reaction_type
|
||||
ORDER BY count DESC;
|
||||
`;
|
||||
const result = await getPool().query<{ reaction_type: string; count: number }>(query, [entityType, entityId]);
|
||||
const result = await getPool().query<{ reaction_type: string; count: number }>(query, [
|
||||
entityType,
|
||||
entityId,
|
||||
]);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getReactionSummary', { entityType, entityId }, {
|
||||
defaultMessage: 'Failed to retrieve reaction summary.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getReactionSummary',
|
||||
{ entityType, entityId },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve reaction summary.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reactionRepo = new ReactionRepository();
|
||||
export const reactionRepo = new ReactionRepository();
|
||||
|
||||
@@ -27,7 +27,7 @@ import { UserRepository, exportUserData } from './user.db';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import { createMockUserProfile, createMockUser } from '../../tests/utils/mockFactories';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import type { Profile, ActivityLogItem, SearchQuery, UserProfile, User } from '../../types';
|
||||
import type { ActivityLogItem, SearchQuery, UserProfile } from '../../types';
|
||||
import { ShoppingRepository } from './shopping.db';
|
||||
import { PersonalizationRepository } from './personalization.db';
|
||||
|
||||
@@ -793,7 +793,13 @@ describe('User DB Service', () => {
|
||||
it('should execute DELETE and INSERT queries', async () => {
|
||||
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
|
||||
const expires = new Date();
|
||||
await userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient);
|
||||
await userRepo.createPasswordResetToken(
|
||||
'123',
|
||||
'token-hash',
|
||||
expires,
|
||||
mockLogger,
|
||||
mockClient as unknown as PoolClient,
|
||||
);
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.password_reset_tokens WHERE user_id = $1',
|
||||
['123'],
|
||||
@@ -809,7 +815,13 @@ describe('User DB Service', () => {
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
|
||||
await expect(
|
||||
userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger, mockClient as unknown as PoolClient),
|
||||
userRepo.createPasswordResetToken(
|
||||
'non-existent-user',
|
||||
'hash',
|
||||
new Date(),
|
||||
mockLogger,
|
||||
mockClient as unknown as PoolClient,
|
||||
),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
});
|
||||
|
||||
@@ -818,7 +830,13 @@ describe('User DB Service', () => {
|
||||
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
|
||||
const expires = new Date();
|
||||
await expect(
|
||||
userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger, mockClient as unknown as PoolClient),
|
||||
userRepo.createPasswordResetToken(
|
||||
'123',
|
||||
'token-hash',
|
||||
expires,
|
||||
mockLogger,
|
||||
mockClient as unknown as PoolClient,
|
||||
),
|
||||
).rejects.toThrow('Failed to create password reset token.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: '123' },
|
||||
@@ -901,7 +919,9 @@ describe('User DB Service', () => {
|
||||
it('should call profile, watched items, and shopping list functions', async () => {
|
||||
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
||||
findProfileSpy.mockResolvedValue(
|
||||
createMockUserProfile({ user: createMockUser({ user_id: '123', email: '123@example.com' }) }),
|
||||
createMockUserProfile({
|
||||
user: createMockUser({ user_id: '123', email: '123@example.com' }),
|
||||
}),
|
||||
);
|
||||
const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems');
|
||||
getWatchedItemsSpy.mockResolvedValue([]);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { NotFoundError, handleDbError, UniqueConstraintError } from './errors.db';
|
||||
import { NotFoundError, handleDbError } from './errors.db';
|
||||
import {
|
||||
Profile,
|
||||
MasterGroceryItem,
|
||||
@@ -55,9 +55,15 @@ export class UserRepository {
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in findUserByEmail', { email }, {
|
||||
defaultMessage: 'Failed to retrieve user from database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in findUserByEmail',
|
||||
{ email },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve user from database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,17 +148,28 @@ export class UserRepository {
|
||||
});
|
||||
} else {
|
||||
// If this.db is already a PoolClient, we're inside a transaction. Use it directly.
|
||||
return await this._createUser(this.db as PoolClient, email, passwordHash, profileData, logger);
|
||||
return await this._createUser(
|
||||
this.db as PoolClient,
|
||||
email,
|
||||
passwordHash,
|
||||
profileData,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Error during createUser', { email }, {
|
||||
uniqueMessage: 'A user with this email address already exists.',
|
||||
defaultMessage: 'Failed to create user in database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Error during createUser',
|
||||
{ email },
|
||||
{
|
||||
uniqueMessage: 'A user with this email address already exists.',
|
||||
defaultMessage: 'Failed to create user in database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds a user by their email and joins their profile data.
|
||||
* This is used by the LocalStrategy to get all necessary data for authentication and session creation in one query.
|
||||
@@ -207,9 +224,15 @@ export class UserRepository {
|
||||
|
||||
return authableProfile;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in findUserWithProfileByEmail', { email }, {
|
||||
defaultMessage: 'Failed to retrieve user with profile from database.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in findUserWithProfileByEmail',
|
||||
{ email },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve user with profile from database.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,10 +474,7 @@ export class UserRepository {
|
||||
* @param refreshToken The refresh token to look up.
|
||||
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
|
||||
*/
|
||||
async findUserByRefreshToken(
|
||||
refreshToken: string,
|
||||
logger: Logger,
|
||||
): Promise<User | undefined> {
|
||||
async findUserByRefreshToken(refreshToken: string, logger: Logger): Promise<User | undefined> {
|
||||
try {
|
||||
const res = await this.db.query<User>(
|
||||
'SELECT user_id, email, created_at, updated_at FROM public.users WHERE refresh_token = $1',
|
||||
@@ -465,9 +485,15 @@ export class UserRepository {
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in findUserByRefreshToken', {}, {
|
||||
defaultMessage: 'Failed to find user by refresh token.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in findUserByRefreshToken',
|
||||
{},
|
||||
{
|
||||
defaultMessage: 'Failed to find user by refresh token.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,9 +585,15 @@ export class UserRepository {
|
||||
);
|
||||
return res.rowCount ?? 0;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in deleteExpiredResetTokens', {}, {
|
||||
defaultMessage: 'Failed to delete expired password reset tokens.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in deleteExpiredResetTokens',
|
||||
{},
|
||||
{
|
||||
defaultMessage: 'Failed to delete expired password reset tokens.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -576,11 +608,17 @@ export class UserRepository {
|
||||
[followerId, followingId],
|
||||
);
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in followUser', { followerId, followingId }, {
|
||||
fkMessage: 'One or both users do not exist.',
|
||||
checkMessage: 'A user cannot follow themselves.',
|
||||
defaultMessage: 'Failed to follow user.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in followUser',
|
||||
{ followerId, followingId },
|
||||
{
|
||||
fkMessage: 'One or both users do not exist.',
|
||||
checkMessage: 'A user cannot follow themselves.',
|
||||
defaultMessage: 'Failed to follow user.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,9 +634,15 @@ export class UserRepository {
|
||||
[followerId, followingId],
|
||||
);
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in unfollowUser', { followerId, followingId }, {
|
||||
defaultMessage: 'Failed to unfollow user.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in unfollowUser',
|
||||
{ followerId, followingId },
|
||||
{
|
||||
defaultMessage: 'Failed to unfollow user.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,9 +672,15 @@ export class UserRepository {
|
||||
const res = await this.db.query<ActivityLogItem>(query, [userId, limit, offset]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in getUserFeed', { userId, limit, offset }, {
|
||||
defaultMessage: 'Failed to retrieve user feed.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in getUserFeed',
|
||||
{ userId, limit, offset },
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve user feed.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,10 +701,16 @@ export class UserRepository {
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in logSearchQuery', { queryData }, {
|
||||
fkMessage: 'The specified user does not exist.',
|
||||
defaultMessage: 'Failed to log search query.',
|
||||
});
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Database error in logSearchQuery',
|
||||
{ queryData },
|
||||
{
|
||||
fkMessage: 'The specified user does not exist.',
|
||||
defaultMessage: 'Failed to log search query.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* This is particularly useful for broadcasting application-wide events, such as session expiry.
|
||||
*/
|
||||
|
||||
type EventCallback = (data?: any) => void;
|
||||
type EventCallback = (data?: unknown) => void;
|
||||
|
||||
export class EventBus {
|
||||
private listeners: { [key: string]: EventCallback[] } = {};
|
||||
@@ -22,10 +22,10 @@ export class EventBus {
|
||||
this.listeners[event] = this.listeners[event].filter((l) => l !== callback);
|
||||
}
|
||||
|
||||
dispatch(event: string, data?: any): void {
|
||||
dispatch(event: string, data?: unknown): void {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event].forEach((callback) => callback(data));
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
export const eventBus = new EventBus();
|
||||
|
||||
@@ -5,11 +5,7 @@ import type { AIService } from './aiService.server';
|
||||
import type { PersonalizationRepository } from './db/personalization.db';
|
||||
import { AiDataValidationError } from './processingErrors';
|
||||
import type { FlyerJobData } from '../types/job-data';
|
||||
import {
|
||||
AiFlyerDataSchema,
|
||||
ExtractedFlyerItemSchema,
|
||||
requiredString,
|
||||
} from '../types/ai'; // Import consolidated schemas and helper
|
||||
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schemas and helper
|
||||
|
||||
export type ValidatedAiDataType = z.infer<typeof AiFlyerDataSchema>;
|
||||
|
||||
@@ -31,10 +27,7 @@ export class FlyerAiProcessor {
|
||||
/**
|
||||
* Validates the raw data from the AI against the Zod schema.
|
||||
*/
|
||||
private _validateAiData(
|
||||
extractedData: unknown,
|
||||
logger: Logger,
|
||||
): AiProcessorResult {
|
||||
private _validateAiData(extractedData: unknown, logger: Logger): AiProcessorResult {
|
||||
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.flatten();
|
||||
@@ -91,7 +84,9 @@ export class FlyerAiProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`AI extracted ${validationResult.data.items.length} items. Needs Review: ${needsReview}`);
|
||||
logger.info(
|
||||
`AI extracted ${validationResult.data.items.length} items. Needs Review: ${needsReview}`,
|
||||
);
|
||||
return { data: validationResult.data, needsReview };
|
||||
}
|
||||
|
||||
@@ -103,7 +98,9 @@ export class FlyerAiProcessor {
|
||||
jobData: FlyerJobData,
|
||||
logger: Logger,
|
||||
): Promise<AiProcessorResult> {
|
||||
console.error(`[WORKER DEBUG] FlyerAiProcessor: extractAndValidateData called with ${imagePaths.length} images`);
|
||||
console.error(
|
||||
`[WORKER DEBUG] FlyerAiProcessor: extractAndValidateData called with ${imagePaths.length} images`,
|
||||
);
|
||||
logger.info(`Starting AI data extraction for ${imagePaths.length} pages.`);
|
||||
const { submitterIp, userProfileAddress } = jobData;
|
||||
const masterItems = await this.personalizationRepo.getAllMasterItems(logger);
|
||||
@@ -125,7 +122,9 @@ export class FlyerAiProcessor {
|
||||
items: [],
|
||||
};
|
||||
|
||||
logger.info(`Processing ${imagePaths.length} pages in ${batches.length} batches (Batch Size: ${BATCH_SIZE}).`);
|
||||
logger.info(
|
||||
`Processing ${imagePaths.length} pages in ${batches.length} batches (Batch Size: ${BATCH_SIZE}).`,
|
||||
);
|
||||
|
||||
for (const [index, batch] of batches.entries()) {
|
||||
logger.info(`Processing batch ${index + 1}/${batches.length} (${batch.length} pages)...`);
|
||||
@@ -149,10 +148,14 @@ export class FlyerAiProcessor {
|
||||
mergedData.valid_to = batchResult.valid_to;
|
||||
mergedData.store_address = batchResult.store_address;
|
||||
} else {
|
||||
if (!mergedData.store_name && batchResult.store_name) mergedData.store_name = batchResult.store_name;
|
||||
if (!mergedData.valid_from && batchResult.valid_from) mergedData.valid_from = batchResult.valid_from;
|
||||
if (!mergedData.valid_to && batchResult.valid_to) mergedData.valid_to = batchResult.valid_to;
|
||||
if (!mergedData.store_address && batchResult.store_address) mergedData.store_address = batchResult.store_address;
|
||||
if (!mergedData.store_name && batchResult.store_name)
|
||||
mergedData.store_name = batchResult.store_name;
|
||||
if (!mergedData.valid_from && batchResult.valid_from)
|
||||
mergedData.valid_from = batchResult.valid_from;
|
||||
if (!mergedData.valid_to && batchResult.valid_to)
|
||||
mergedData.valid_to = batchResult.valid_to;
|
||||
if (!mergedData.store_address && batchResult.store_address)
|
||||
mergedData.store_address = batchResult.store_address;
|
||||
}
|
||||
|
||||
// 2. Items: Append all found items to the master list.
|
||||
@@ -160,9 +163,12 @@ export class FlyerAiProcessor {
|
||||
}
|
||||
|
||||
logger.info(`Batch processing complete. Total items extracted: ${mergedData.items.length}`);
|
||||
console.error(`[WORKER DEBUG] FlyerAiProcessor: Merged AI Data:`, JSON.stringify(mergedData, null, 2));
|
||||
console.error(
|
||||
`[WORKER DEBUG] FlyerAiProcessor: Merged AI Data:`,
|
||||
JSON.stringify(mergedData, null, 2),
|
||||
);
|
||||
|
||||
// Validate the final merged dataset
|
||||
return this._validateAiData(mergedData, logger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FlyerDataTransformer } from './flyerDataTransformer';
|
||||
import { logger as mockLogger } from './logger.server';
|
||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||
import type { AiProcessorResult } from './flyerAiProcessor.server';
|
||||
import type { FlyerItemInsert } from '../types';
|
||||
import { getBaseUrl } from '../utils/serverUtils';
|
||||
|
||||
// Mock the dependencies
|
||||
@@ -30,7 +29,7 @@ describe('FlyerDataTransformer', () => {
|
||||
// Prioritize FRONTEND_URL to match the updated service logic.
|
||||
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
||||
vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic
|
||||
vi.stubEnv('PORT', ''); // Ensure this is not used
|
||||
vi.stubEnv('PORT', ''); // Ensure this is not used
|
||||
|
||||
// Provide a default mock implementation for generateFlyerIcon
|
||||
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp');
|
||||
@@ -126,7 +125,6 @@ describe('FlyerDataTransformer', () => {
|
||||
click_count: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
it('should handle missing optional data gracefully', async () => {
|
||||
@@ -238,14 +236,22 @@ describe('FlyerDataTransformer', () => {
|
||||
// Check Case 1 (null/undefined values)
|
||||
expect(itemsForDb[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
item: 'Unknown Item', price_display: '', quantity: '', category_name: 'Other/Miscellaneous', master_item_id: undefined,
|
||||
item: 'Unknown Item',
|
||||
price_display: '',
|
||||
quantity: '',
|
||||
category_name: 'Other/Miscellaneous',
|
||||
master_item_id: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// Check Case 2 (empty string values)
|
||||
expect(itemsForDb[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
item: 'Unknown Item', price_display: '', quantity: '', category_name: 'Other/Miscellaneous', master_item_id: 20,
|
||||
item: 'Unknown Item',
|
||||
price_display: '',
|
||||
quantity: '',
|
||||
category_name: 'Other/Miscellaneous',
|
||||
master_item_id: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -434,8 +440,8 @@ describe('FlyerDataTransformer', () => {
|
||||
const { itemsForDb } = await transformer.transform(
|
||||
aiResult,
|
||||
'file.pdf',
|
||||
'flyer-page-1.jpg',
|
||||
'icon-flyer-page-1.webp',
|
||||
'flyer-page-1.jpg',
|
||||
'icon-flyer-page-1.webp',
|
||||
'checksum',
|
||||
'user-1',
|
||||
mockLogger,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// src/services/flyerDataTransformer.ts
|
||||
import path from 'path';
|
||||
import type { z } from 'zod';
|
||||
import type { Logger } from 'pino';
|
||||
import type { FlyerInsert, FlyerItemInsert } from '../types';
|
||||
@@ -33,12 +32,12 @@ export class FlyerDataTransformer {
|
||||
...item,
|
||||
// Use nullish coalescing and trim for robustness.
|
||||
// An empty or whitespace-only name falls back to 'Unknown Item'.
|
||||
item: (String(item.item ?? '')).trim() || 'Unknown Item',
|
||||
item: String(item.item ?? '').trim() || 'Unknown Item',
|
||||
// Default null/undefined to an empty string and trim.
|
||||
price_display: (String(item.price_display ?? '')).trim(),
|
||||
quantity: (String(item.quantity ?? '')).trim(),
|
||||
price_display: String(item.price_display ?? '').trim(),
|
||||
quantity: String(item.quantity ?? '').trim(),
|
||||
// An empty or whitespace-only category falls back to 'Other/Miscellaneous'.
|
||||
category_name: (String(item.category_name ?? '')).trim() || 'Other/Miscellaneous',
|
||||
category_name: String(item.category_name ?? '').trim() || 'Other/Miscellaneous',
|
||||
// Overwrite price_in_cents with our calculated value.
|
||||
price_in_cents: finalPriceInCents,
|
||||
// Use nullish coalescing to convert null to undefined for the database.
|
||||
@@ -62,10 +61,17 @@ export class FlyerDataTransformer {
|
||||
baseUrl: string,
|
||||
logger: Logger,
|
||||
): { imageUrl: string; iconUrl: string } {
|
||||
console.error('[DEBUG] FlyerDataTransformer._buildUrls inputs:', { imageFileName, iconFileName, baseUrl });
|
||||
console.error('[DEBUG] FlyerDataTransformer._buildUrls inputs:', {
|
||||
imageFileName,
|
||||
iconFileName,
|
||||
baseUrl,
|
||||
});
|
||||
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
|
||||
const finalBaseUrl = baseUrl || getBaseUrl(logger);
|
||||
console.error('[DEBUG] FlyerDataTransformer._buildUrls finalBaseUrl resolved to:', finalBaseUrl);
|
||||
console.error(
|
||||
'[DEBUG] FlyerDataTransformer._buildUrls finalBaseUrl resolved to:',
|
||||
finalBaseUrl,
|
||||
);
|
||||
const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
|
||||
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
||||
console.error('[DEBUG] FlyerDataTransformer._buildUrls constructed:', { imageUrl, iconUrl });
|
||||
@@ -101,7 +107,9 @@ export class FlyerDataTransformer {
|
||||
|
||||
const { imageUrl, iconUrl } = this._buildUrls(imageFileName, iconFileName, baseUrl, logger);
|
||||
|
||||
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item));
|
||||
const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) =>
|
||||
this._normalizeItem(item),
|
||||
);
|
||||
|
||||
const storeName = extractedData.store_name || 'Unknown Store (auto)';
|
||||
if (!extractedData.store_name) {
|
||||
|
||||
@@ -23,6 +23,9 @@ export class FlyerPersistenceService {
|
||||
* @internal
|
||||
*/
|
||||
_setWithTransaction(fn: WithTransactionFn): void {
|
||||
console.error(
|
||||
`[DEBUG] FlyerPersistenceService._setWithTransaction called, replacing withTransaction function`,
|
||||
);
|
||||
this.withTransaction = fn;
|
||||
}
|
||||
|
||||
@@ -36,6 +39,12 @@ export class FlyerPersistenceService {
|
||||
userId: string | undefined,
|
||||
logger: Logger,
|
||||
): Promise<Flyer> {
|
||||
console.error(
|
||||
`[DEBUG] FlyerPersistenceService.saveFlyer called, about to invoke withTransaction`,
|
||||
);
|
||||
console.error(
|
||||
`[DEBUG] withTransaction function name: ${this.withTransaction.name || 'anonymous'}`,
|
||||
);
|
||||
const flyer = await this.withTransaction(async (client) => {
|
||||
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/services/flyerProcessingService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { Job, UnrecoverableError } from 'bullmq';
|
||||
import { AiFlyerDataSchema } from '../types/ai';
|
||||
import type { FlyerInsert } from '../types';
|
||||
import type { CleanupJobData, FlyerJobData } from '../types/job-data';
|
||||
|
||||
@@ -36,13 +35,12 @@ import {
|
||||
AiDataValidationError,
|
||||
PdfConversionError,
|
||||
UnsupportedFileTypeError,
|
||||
TransformationError,
|
||||
DatabaseError,
|
||||
} from './processingErrors';
|
||||
import { NotFoundError } from './db/errors.db';
|
||||
import { FlyerFileHandler } from './flyerFileHandler.server';
|
||||
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
|
||||
import type { IFileSystem } from './flyerFileHandler.server';
|
||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||
import type { AIService } from './aiService.server';
|
||||
import { FlyerPersistenceService } from './flyerPersistenceService.server';
|
||||
@@ -169,12 +167,14 @@ describe('FlyerProcessingService', () => {
|
||||
createdImagePaths: [],
|
||||
});
|
||||
|
||||
mockPersistenceService.saveFlyer.mockResolvedValue(createMockFlyer({
|
||||
flyer_id: 1,
|
||||
file_name: 'test.jpg',
|
||||
image_url: 'https://example.com/test.jpg',
|
||||
item_count: 1,
|
||||
}));
|
||||
mockPersistenceService.saveFlyer.mockResolvedValue(
|
||||
createMockFlyer({
|
||||
flyer_id: 1,
|
||||
file_name: 'test.jpg',
|
||||
image_url: 'https://example.com/test.jpg',
|
||||
item_count: 1,
|
||||
}),
|
||||
);
|
||||
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([]);
|
||||
@@ -225,16 +225,27 @@ describe('FlyerProcessingService', () => {
|
||||
expect(result).toEqual({ flyerId: 1 });
|
||||
|
||||
// 1. File handler was called
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
|
||||
job.data.filePath,
|
||||
job,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// 2. Optimization was called
|
||||
expect(mockFileHandler.optimizeImages).toHaveBeenCalledWith(expect.any(Array), expect.any(Object));
|
||||
expect(mockFileHandler.optimizeImages).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// 3. AI processor was called
|
||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 4. Icon was generated from the processed image
|
||||
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-processed.jpeg', '/tmp/icons', expect.any(Object));
|
||||
expect(generateFlyerIcon).toHaveBeenCalledWith(
|
||||
'/tmp/flyer-processed.jpeg',
|
||||
'/tmp/icons',
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// 5. Transformer was called with the correct filenames
|
||||
expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(
|
||||
@@ -288,10 +299,18 @@ describe('FlyerProcessingService', () => {
|
||||
await service.processJob(job);
|
||||
|
||||
// Verify transaction and inner calls
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
|
||||
'/tmp/flyer.pdf',
|
||||
job,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||
// Verify icon generation was called for the first page
|
||||
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-1.jpg', '/tmp/icons', expect.any(Object));
|
||||
expect(generateFlyerIcon).toHaveBeenCalledWith(
|
||||
'/tmp/flyer-1.jpg',
|
||||
'/tmp/icons',
|
||||
expect.any(Object),
|
||||
);
|
||||
// Verify cleanup job includes original PDF and all generated/processed images
|
||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||
'cleanup-flyer-files',
|
||||
@@ -320,9 +339,24 @@ describe('FlyerProcessingService', () => {
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
message: 'AI model exploded',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'AI model exploded' },
|
||||
{
|
||||
name: 'Preparing Inputs',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: '1 page(s) ready for AI.',
|
||||
},
|
||||
{
|
||||
name: 'Image Optimization',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: 'Compressing and resizing images...',
|
||||
},
|
||||
{
|
||||
name: 'Extracting Data with AI',
|
||||
status: 'failed',
|
||||
critical: true,
|
||||
detail: 'AI model exploded',
|
||||
},
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
],
|
||||
@@ -346,9 +380,24 @@ describe('FlyerProcessingService', () => {
|
||||
errorCode: 'QUOTA_EXCEEDED',
|
||||
message: 'An AI quota has been exceeded. Please try again later.',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'AI model quota exceeded' },
|
||||
{
|
||||
name: 'Preparing Inputs',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: '1 page(s) ready for AI.',
|
||||
},
|
||||
{
|
||||
name: 'Image Optimization',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: 'Compressing and resizing images...',
|
||||
},
|
||||
{
|
||||
name: 'Extracting Data with AI',
|
||||
status: 'failed',
|
||||
critical: true,
|
||||
detail: 'AI model quota exceeded',
|
||||
},
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
],
|
||||
@@ -374,7 +423,13 @@ describe('FlyerProcessingService', () => {
|
||||
'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.', // This was a duplicate, fixed.
|
||||
stderr: 'pdftocairo error',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'failed', critical: true, detail: 'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.' },
|
||||
{
|
||||
name: 'Preparing Inputs',
|
||||
status: 'failed',
|
||||
critical: true,
|
||||
detail:
|
||||
'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.',
|
||||
},
|
||||
{ name: 'Image Optimization', status: 'skipped', critical: true },
|
||||
{ name: 'Extracting Data with AI', status: 'skipped', critical: true },
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
@@ -400,7 +455,8 @@ describe('FlyerProcessingService', () => {
|
||||
{
|
||||
err: validationError,
|
||||
errorCode: 'AI_VALIDATION_FAILED',
|
||||
message: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
message:
|
||||
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
validationErrors: {},
|
||||
rawData: {},
|
||||
stages: expect.any(Array), // Stages will be dynamically generated
|
||||
@@ -416,9 +472,25 @@ describe('FlyerProcessingService', () => {
|
||||
validationErrors: {},
|
||||
rawData: {},
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer." },
|
||||
{
|
||||
name: 'Preparing Inputs',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: '1 page(s) ready for AI.',
|
||||
},
|
||||
{
|
||||
name: 'Image Optimization',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: 'Compressing and resizing images...',
|
||||
},
|
||||
{
|
||||
name: 'Extracting Data with AI',
|
||||
status: 'failed',
|
||||
critical: true,
|
||||
detail:
|
||||
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
},
|
||||
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
||||
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
||||
],
|
||||
@@ -443,10 +515,18 @@ describe('FlyerProcessingService', () => {
|
||||
await service.processJob(job);
|
||||
|
||||
// Verify transaction and inner calls
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object));
|
||||
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
|
||||
'/tmp/flyer.gif',
|
||||
job,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
||||
// Verify icon generation was called for the converted image
|
||||
expect(generateFlyerIcon).toHaveBeenCalledWith(convertedPath, '/tmp/icons', expect.any(Object));
|
||||
expect(generateFlyerIcon).toHaveBeenCalledWith(
|
||||
convertedPath,
|
||||
'/tmp/icons',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
||||
'cleanup-flyer-files',
|
||||
{
|
||||
@@ -464,9 +544,9 @@ describe('FlyerProcessingService', () => {
|
||||
it('should throw an error and not enqueue cleanup if the database service fails', async () => {
|
||||
const job = createMockJob({});
|
||||
const { logger } = await import('./logger.server');
|
||||
const dbError = new Error('Database transaction failed');
|
||||
const dbError = new DatabaseError('Database transaction failed');
|
||||
|
||||
mockPersistenceService.saveFlyer.mockRejectedValue(new DatabaseError('Database transaction failed'));
|
||||
mockPersistenceService.saveFlyer.mockRejectedValue(dbError);
|
||||
|
||||
// The service wraps the generic DB error in a DatabaseError.
|
||||
await expect(service.processJob(job)).rejects.toThrow(DatabaseError);
|
||||
@@ -476,11 +556,31 @@ describe('FlyerProcessingService', () => {
|
||||
errorCode: 'DATABASE_ERROR',
|
||||
message: 'A database operation failed. Please try again later.',
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
|
||||
{ name: 'Image Optimization', status: 'completed', critical: true, detail: 'Compressing and resizing images...' },
|
||||
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
|
||||
{
|
||||
name: 'Preparing Inputs',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: '1 page(s) ready for AI.',
|
||||
},
|
||||
{
|
||||
name: 'Image Optimization',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: 'Compressing and resizing images...',
|
||||
},
|
||||
{
|
||||
name: 'Extracting Data with AI',
|
||||
status: 'completed',
|
||||
critical: true,
|
||||
detail: 'Communicating with AI model...',
|
||||
},
|
||||
{ name: 'Transforming AI Data', status: 'completed', critical: true },
|
||||
{ name: 'Saving to Database', status: 'failed', critical: true, detail: 'A database operation failed. Please try again later.' },
|
||||
{
|
||||
name: 'Saving to Database',
|
||||
status: 'failed',
|
||||
critical: true,
|
||||
detail: 'A database operation failed. Please try again later.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
@@ -494,7 +594,9 @@ describe('FlyerProcessingService', () => {
|
||||
filePath: '/tmp/document.txt',
|
||||
originalFileName: 'document.txt',
|
||||
});
|
||||
const fileTypeError = new UnsupportedFileTypeError('Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.');
|
||||
const fileTypeError = new UnsupportedFileTypeError(
|
||||
'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
|
||||
);
|
||||
mockFileHandler.prepareImageInputs.mockRejectedValue(fileTypeError);
|
||||
const { logger } = await import('./logger.server');
|
||||
|
||||
@@ -502,7 +604,12 @@ describe('FlyerProcessingService', () => {
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow(UnsupportedFileTypeError);
|
||||
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(fileTypeError, job, expect.any(Object), expect.any(Array));
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(
|
||||
fileTypeError,
|
||||
job,
|
||||
expect.any(Object),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||
@@ -519,7 +626,12 @@ describe('FlyerProcessingService', () => {
|
||||
|
||||
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
||||
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(iconGenError, job, expect.any(Object), expect.any(Array));
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(
|
||||
iconGenError,
|
||||
job,
|
||||
expect.any(Object),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
||||
@@ -539,7 +651,9 @@ describe('FlyerProcessingService', () => {
|
||||
];
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(genericError, job, logger, initialStages)).rejects.toThrow(genericError);
|
||||
await expect(privateMethod(genericError, job, logger, initialStages)).rejects.toThrow(
|
||||
genericError,
|
||||
);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
@@ -565,15 +679,24 @@ describe('FlyerProcessingService', () => {
|
||||
];
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(validationError, job, logger, initialStages)).rejects.toThrow(validationError);
|
||||
await expect(privateMethod(validationError, job, logger, initialStages)).rejects.toThrow(
|
||||
validationError,
|
||||
);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'AI_VALIDATION_FAILED',
|
||||
message: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
message:
|
||||
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
validationErrors: { foo: 'bar' },
|
||||
rawData: { raw: 'data' },
|
||||
stages: [
|
||||
{ name: 'Extracting Data with AI', status: 'failed', critical: true, detail: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer." },
|
||||
{
|
||||
name: 'Extracting Data with AI',
|
||||
status: 'failed',
|
||||
critical: true,
|
||||
detail:
|
||||
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -584,9 +707,7 @@ describe('FlyerProcessingService', () => {
|
||||
const quotaError = new Error('RESOURCE_EXHAUSTED');
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(quotaError, job, logger, [])).rejects.toThrow(
|
||||
UnrecoverableError,
|
||||
);
|
||||
await expect(privateMethod(quotaError, job, logger, [])).rejects.toThrow(UnrecoverableError);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith({
|
||||
errorCode: 'QUOTA_EXCEEDED',
|
||||
@@ -601,9 +722,7 @@ describe('FlyerProcessingService', () => {
|
||||
const nonError = 'just a string error';
|
||||
const privateMethod = (service as any)._reportErrorAndThrow;
|
||||
|
||||
await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow(
|
||||
'just a string error',
|
||||
);
|
||||
await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow('just a string error');
|
||||
});
|
||||
|
||||
it('should correctly identify the failed stage based on error code', async () => {
|
||||
@@ -618,12 +737,19 @@ describe('FlyerProcessingService', () => {
|
||||
|
||||
await expect(privateMethod(pdfError, job, logger, initialStages)).rejects.toThrow(pdfError);
|
||||
|
||||
expect(job.updateProgress).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stages: [
|
||||
{ name: 'Preparing Inputs', status: 'failed', critical: true, detail: expect.any(String) },
|
||||
{ name: 'Extracting Data with AI', status: 'skipped', critical: true },
|
||||
],
|
||||
}));
|
||||
expect(job.updateProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stages: [
|
||||
{
|
||||
name: 'Preparing Inputs',
|
||||
status: 'failed',
|
||||
critical: true,
|
||||
detail: expect.any(String),
|
||||
},
|
||||
{ name: 'Extracting Data with AI', status: 'skipped', critical: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -717,7 +843,9 @@ describe('FlyerProcessingService', () => {
|
||||
expect(result).toEqual({ status: 'success', deletedCount: 2 });
|
||||
expect(mocks.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/flyer-abc.jpg');
|
||||
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/icons/icon-flyer-abc.webp');
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(
|
||||
'/var/www/app/flyer-images/icons/icon-flyer-abc.webp',
|
||||
);
|
||||
const { logger } = await import('./logger.server');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/services/monitoringService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Job, Queue } from 'bullmq';
|
||||
import type { Job } from 'bullmq';
|
||||
import { NotFoundError, ValidationError } from './db/errors.db';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
@@ -131,9 +131,9 @@ describe('MonitoringService', () => {
|
||||
const jobId = 'failed-job-1';
|
||||
|
||||
it('should throw NotFoundError for an unknown queue name', async () => {
|
||||
await expect(monitoringService.retryFailedJob('unknown-queue', jobId, userId)).rejects.toThrow(
|
||||
new NotFoundError(`Queue 'unknown-queue' not found.`),
|
||||
);
|
||||
await expect(
|
||||
monitoringService.retryFailedJob('unknown-queue', jobId, userId),
|
||||
).rejects.toThrow(new NotFoundError(`Queue 'unknown-queue' not found.`));
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if the job does not exist in the queue', async () => {
|
||||
@@ -141,7 +141,9 @@ describe('MonitoringService', () => {
|
||||
|
||||
await expect(
|
||||
monitoringService.retryFailedJob('flyer-processing', jobId, userId),
|
||||
).rejects.toThrow(new NotFoundError(`Job with ID '${jobId}' not found in queue 'flyer-processing'.`));
|
||||
).rejects.toThrow(
|
||||
new NotFoundError(`Job with ID '${jobId}' not found in queue 'flyer-processing'.`),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ValidationError if the job is not in a 'failed' state", async () => {
|
||||
@@ -154,7 +156,9 @@ describe('MonitoringService', () => {
|
||||
|
||||
await expect(
|
||||
monitoringService.retryFailedJob('flyer-processing', jobId, userId),
|
||||
).rejects.toThrow(new ValidationError([], `Job is not in a 'failed' state. Current state: completed.`));
|
||||
).rejects.toThrow(
|
||||
new ValidationError([], `Job is not in a 'failed' state. Current state: completed.`),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call job.retry() and log if the job is in a 'failed' state", async () => {
|
||||
@@ -206,4 +210,4 @@ describe('MonitoringService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
flyerWorker,
|
||||
weeklyAnalyticsWorker,
|
||||
} from './workers.server';
|
||||
import type { Job, Queue } from 'bullmq';
|
||||
import type { Queue } from 'bullmq';
|
||||
import { NotFoundError, ValidationError } from './db/errors.db';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
@@ -23,7 +23,13 @@ class MonitoringService {
|
||||
* @returns A promise that resolves to an array of worker statuses.
|
||||
*/
|
||||
async getWorkerStatuses() {
|
||||
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker];
|
||||
const workers = [
|
||||
flyerWorker,
|
||||
emailWorker,
|
||||
analyticsWorker,
|
||||
cleanupWorker,
|
||||
weeklyAnalyticsWorker,
|
||||
];
|
||||
return Promise.all(
|
||||
workers.map(async (worker) => ({
|
||||
name: worker.name,
|
||||
@@ -80,10 +86,7 @@ class MonitoringService {
|
||||
|
||||
const jobState = await job.getState();
|
||||
if (jobState !== 'failed') {
|
||||
throw new ValidationError(
|
||||
[],
|
||||
`Job is not in a 'failed' state. Current state: ${jobState}.`,
|
||||
);
|
||||
throw new ValidationError([], `Job is not in a 'failed' state. Current state: ${jobState}.`);
|
||||
}
|
||||
|
||||
await job.retry();
|
||||
@@ -95,7 +98,15 @@ class MonitoringService {
|
||||
* @param jobId The ID of the job to retrieve.
|
||||
* @returns A promise that resolves to a simplified job status object.
|
||||
*/
|
||||
async getFlyerJobStatus(jobId: string): Promise<{ id: string; state: string; progress: number | object | string | boolean; returnValue: any; failedReason: string | null; }> {
|
||||
async getFlyerJobStatus(
|
||||
jobId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
state: string;
|
||||
progress: number | object | string | boolean;
|
||||
returnValue: unknown;
|
||||
failedReason: string | null;
|
||||
}> {
|
||||
const job = await flyerQueue.getJob(jobId);
|
||||
if (!job) {
|
||||
throw new NotFoundError('Job not found.');
|
||||
@@ -108,4 +119,4 @@ class MonitoringService {
|
||||
}
|
||||
}
|
||||
|
||||
export const monitoringService = new MonitoringService();
|
||||
export const monitoringService = new MonitoringService();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/services/notificationService.ts
|
||||
import toast, { ToastOptions } from '../lib/toast';
|
||||
import { logger } from './logger.client';
|
||||
|
||||
const commonToastOptions: ToastOptions = {
|
||||
style: {
|
||||
@@ -24,7 +25,7 @@ export interface Toaster {
|
||||
export const notifySuccess = (message: string, toaster: Toaster = toast) => {
|
||||
// Defensive check: Ensure the toaster instance is valid
|
||||
if (!toaster || typeof toaster.success !== 'function') {
|
||||
console.warn('[NotificationService] toast.success is not available. Message:', message);
|
||||
logger.warn({ message }, '[NotificationService] toast.success is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,7 +46,7 @@ export const notifySuccess = (message: string, toaster: Toaster = toast) => {
|
||||
export const notifyError = (message: string, toaster: Toaster = toast) => {
|
||||
// Defensive check
|
||||
if (!toaster || typeof toaster.error !== 'function') {
|
||||
console.warn('[NotificationService] toast.error is not available. Message:', message);
|
||||
logger.warn({ message }, '[NotificationService] toast.error is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user