Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4d830ab90 | ||
| b6a62a036f | |||
| 2d2cd52011 | |||
| 379b8bf532 |
@@ -166,8 +166,8 @@ jobs:
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
echo "Cleaning up errored or stopped PRODUCTION PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); const prodProcesses = ['flyer-crawler-api', 'flyer-crawler-worker', 'flyer-crawler-analytics-worker']; list.forEach(p => { if ((p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') && prodProcesses.includes(p.name)) { console.log('Deleting ' + p.pm2_env.status + ' production process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); console.log('✅ Production process cleanup complete.'); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
|
||||
@@ -490,8 +490,8 @@ jobs:
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
echo "Cleaning up errored or stopped TEST PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if ((p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') && p.name && p.name.endsWith('-test')) { console.log('Deleting ' + p.pm2_env.status + ' test process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
|
||||
# Use `startOrReload` with the TEST ecosystem file. This starts test-specific processes
|
||||
# (flyer-crawler-api-test, flyer-crawler-worker-test, flyer-crawler-analytics-worker-test)
|
||||
|
||||
@@ -56,9 +56,9 @@ jobs:
|
||||
|
||||
- name: Step 1 - Stop Application Server
|
||||
run: |
|
||||
echo "Stopping all PM2 processes to release database connections..."
|
||||
pm2 stop all || echo "PM2 processes were not running."
|
||||
echo "✅ Application server stopped."
|
||||
echo "Stopping PRODUCTION PM2 processes to release database connections..."
|
||||
pm2 stop flyer-crawler-api flyer-crawler-worker flyer-crawler-analytics-worker || echo "Production PM2 processes were not running."
|
||||
echo "✅ Production application server stopped."
|
||||
|
||||
- name: Step 2 - Drop and Recreate Database
|
||||
run: |
|
||||
|
||||
@@ -139,8 +139,8 @@ jobs:
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
echo "Cleaning up errored or stopped PRODUCTION PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); const prodProcesses = ['flyer-crawler-api', 'flyer-crawler-worker', 'flyer-crawler-analytics-worker']; list.forEach(p => { if ((p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') && prodProcesses.includes(p.name)) { console.log('Deleting ' + p.pm2_env.status + ' production process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); console.log('✅ Production process cleanup complete.'); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,6 +14,10 @@ dist-ssr
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
|
||||
# tsoa generated files (regenerated on build)
|
||||
src/routes/tsoa-generated.ts
|
||||
src/config/tsoa-spec.json
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
42
CLAUDE.md
42
CLAUDE.md
@@ -45,6 +45,48 @@ Out-of-sync = test failures.
|
||||
- Maximum 3 fix commands at a time (errors may cascade)
|
||||
- Always verify after fixes complete
|
||||
|
||||
### PM2 Process Isolation (Production/Test Servers)
|
||||
|
||||
**CRITICAL**: Production and test environments share the same PM2 daemon on the server.
|
||||
|
||||
| Environment | Processes | Config File |
|
||||
| ----------- | -------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| Production | `flyer-crawler-api`, `flyer-crawler-worker`, `flyer-crawler-analytics-worker` | `ecosystem.config.cjs` |
|
||||
| Test | `flyer-crawler-api-test`, `flyer-crawler-worker-test`, `flyer-crawler-analytics-worker-test` | `ecosystem-test.config.cjs` |
|
||||
| Development | `flyer-crawler-api-dev`, `flyer-crawler-worker-dev`, `flyer-crawler-vite-dev` | `ecosystem.dev.config.cjs` |
|
||||
|
||||
**Deployment Scripts MUST:**
|
||||
|
||||
- ✅ Filter PM2 commands by exact process names or name patterns (e.g., `endsWith('-test')`)
|
||||
- ❌ NEVER use `pm2 stop all`, `pm2 delete all`, or `pm2 restart all`
|
||||
- ❌ NEVER delete/stop processes based solely on status without name filtering
|
||||
- ✅ Always verify process names match the target environment before any operation
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# ✅ CORRECT - Production cleanup (filter by name)
|
||||
pm2 stop flyer-crawler-api flyer-crawler-worker flyer-crawler-analytics-worker
|
||||
|
||||
# ✅ CORRECT - Test cleanup (filter by name pattern)
|
||||
# Only delete test processes that are errored/stopped
|
||||
list.forEach(p => {
|
||||
if ((p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') &&
|
||||
p.name && p.name.endsWith('-test')) {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
});
|
||||
|
||||
# ❌ WRONG - Affects all environments
|
||||
pm2 stop all
|
||||
pm2 delete all
|
||||
|
||||
# ❌ WRONG - No name filtering (could delete test processes during prod deploy)
|
||||
if (p.pm2_env.status === 'errored') {
|
||||
exec('pm2 delete ' + p.pm2_env.pm_id);
|
||||
}
|
||||
```
|
||||
|
||||
### Communication Style
|
||||
|
||||
Ask before assuming. Never assume:
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Accepted
|
||||
**Status**: Superseded
|
||||
|
||||
**Implemented**: 2026-01-11
|
||||
**Superseded By**: This ADR was updated in February 2026 to reflect the migration from swagger-jsdoc to tsoa. The original approach using JSDoc annotations has been replaced with a decorator-based controller pattern.
|
||||
|
||||
**Implemented**: 2026-02-12
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,139 +18,296 @@ Key requirements:
|
||||
2. **Code-Documentation Sync**: Documentation should stay in sync with the actual code to prevent drift.
|
||||
3. **Low Maintenance Overhead**: The documentation approach should be "fast and lite" - minimal additional work for developers.
|
||||
4. **Security**: Documentation should not expose sensitive information in production environments.
|
||||
5. **Type Safety**: Documentation should be derived from TypeScript types to ensure accuracy.
|
||||
|
||||
### Why We Migrated from swagger-jsdoc to tsoa
|
||||
|
||||
The original implementation used `swagger-jsdoc` to generate OpenAPI specs from JSDoc comments. This approach had several limitations:
|
||||
|
||||
| Issue | Impact |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| `swagger-jsdoc` unmaintained since 2022 | Security and compatibility risks |
|
||||
| JSDoc duplication with TypeScript types | Maintenance burden, potential for drift |
|
||||
| No runtime validation from schema | Validation logic separate from documentation |
|
||||
| Manual type definitions in comments | Error-prone, no compiler verification |
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt **OpenAPI 3.0 (Swagger)** for API documentation using the following approach:
|
||||
We adopt **tsoa** for API documentation using a decorator-based controller pattern:
|
||||
|
||||
1. **JSDoc Annotations**: Use `swagger-jsdoc` to generate OpenAPI specs from JSDoc comments in route files.
|
||||
2. **Swagger UI**: Use `swagger-ui-express` to serve interactive documentation at `/docs/api-docs`.
|
||||
3. **Environment Restriction**: Only expose the Swagger UI in development and test environments, not production.
|
||||
4. **Incremental Adoption**: Start with key public routes and progressively add annotations to all endpoints.
|
||||
1. **Controller Classes**: Use tsoa decorators (`@Route`, `@Get`, `@Post`, `@Security`, etc.) on controller classes.
|
||||
2. **TypeScript-First**: OpenAPI specs are generated directly from TypeScript interfaces and types.
|
||||
3. **Swagger UI**: Continue using `swagger-ui-express` to serve interactive documentation at `/docs/api-docs`.
|
||||
4. **Environment Restriction**: Only expose the Swagger UI in development and test environments, not production.
|
||||
5. **BaseController Pattern**: All controllers extend a base class providing response formatting utilities.
|
||||
|
||||
### Tooling Selection
|
||||
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ---------------------------------------------- |
|
||||
| `swagger-jsdoc` | Generates OpenAPI 3.0 spec from JSDoc comments |
|
||||
| `swagger-ui-express` | Serves interactive Swagger UI |
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ----------------------------------------------------- |
|
||||
| `tsoa` (6.6.0) | Generates OpenAPI 3.0 spec from decorators and routes |
|
||||
| `swagger-ui-express` | Serves interactive Swagger UI |
|
||||
|
||||
**Why JSDoc over separate schema files?**
|
||||
**Why tsoa over swagger-jsdoc?**
|
||||
|
||||
- Documentation lives with the code, reducing drift
|
||||
- No separate files to maintain
|
||||
- Developers see documentation when editing routes
|
||||
- Lower learning curve for the team
|
||||
- **Type-safe contracts**: Decorators derive types directly from TypeScript, eliminating duplicate definitions
|
||||
- **Active maintenance**: tsoa has an active community and regular releases
|
||||
- **Route generation**: tsoa generates Express routes automatically, reducing boilerplate
|
||||
- **Validation integration**: Request body types serve as validation contracts
|
||||
- **Reduced duplication**: No more parallel JSDoc + TypeScript type definitions
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### OpenAPI Configuration
|
||||
### tsoa Configuration
|
||||
|
||||
Located in `src/config/swagger.ts`:
|
||||
Located in `tsoa.json`:
|
||||
|
||||
```typescript
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Flyer Crawler API',
|
||||
version: '1.0.0',
|
||||
description: 'API for the Flyer Crawler application',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'API server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
```json
|
||||
{
|
||||
"entryFile": "server.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "src/config",
|
||||
"specVersion": 3,
|
||||
"securityDefinitions": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"basePath": "/api",
|
||||
"specFileBaseName": "tsoa-spec",
|
||||
"name": "Flyer Crawler API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
apis: ['./src/routes/*.ts'],
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
"routes": {
|
||||
"routesDir": "src/routes",
|
||||
"basePath": "/api",
|
||||
"middleware": "express",
|
||||
"routesFileName": "tsoa-generated.ts",
|
||||
"esm": true,
|
||||
"authenticationModule": "src/middleware/tsoaAuthentication.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JSDoc Annotation Pattern
|
||||
### Controller Pattern
|
||||
|
||||
Each route handler should include OpenAPI annotations using the `@openapi` tag:
|
||||
Each controller extends `BaseController` and uses tsoa decorators:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* description: Returns a pong response to verify server is responsive
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: pong
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
return sendSuccess(res, { message: 'pong' });
|
||||
});
|
||||
import { Route, Tags, Get, Post, Body, Security, SuccessResponse, Response } from 'tsoa';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
|
||||
interface CreateUserRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
@Route('users')
|
||||
@Tags('Users')
|
||||
export class UserController extends BaseController {
|
||||
/**
|
||||
* Create a new user account.
|
||||
* @summary Create user
|
||||
* @param requestBody User creation data
|
||||
* @returns Created user profile
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'User created')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(409, 'Email already exists')
|
||||
public async createUser(
|
||||
@Body() requestBody: CreateUserRequest,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
// Implementation
|
||||
const user = await userService.createUser(requestBody);
|
||||
return this.created(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's profile.
|
||||
* @summary Get my profile
|
||||
* @param request Express request with authenticated user
|
||||
* @returns User profile
|
||||
*/
|
||||
@Get('me')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(200, 'Profile retrieved')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async getMyProfile(
|
||||
@Request() request: Express.Request,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
const user = request.user as UserProfile;
|
||||
return this.success(toUserProfileDto(user));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Route Documentation Priority
|
||||
### BaseController Helpers
|
||||
|
||||
Document routes in this order of priority:
|
||||
The `BaseController` class provides standardized response formatting:
|
||||
|
||||
1. **Health Routes** - `/api/health/*` (public, critical for operations)
|
||||
2. **Auth Routes** - `/api/auth/*` (public, essential for integration)
|
||||
3. **Gamification Routes** - `/api/achievements/*` (simple, good example)
|
||||
4. **Flyer Routes** - `/api/flyers/*` (core functionality)
|
||||
5. **User Routes** - `/api/users/*` (common CRUD patterns)
|
||||
6. **Remaining Routes** - Budget, Recipe, Admin, etc.
|
||||
```typescript
|
||||
export abstract class BaseController extends Controller {
|
||||
// Success response with data
|
||||
protected success<T>(data: T): SuccessResponse<T> {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
// Success with 201 Created status
|
||||
protected created<T>(data: T): SuccessResponse<T> {
|
||||
this.setStatus(201);
|
||||
return this.success(data);
|
||||
}
|
||||
|
||||
// Paginated response with metadata
|
||||
protected paginated<T>(data: T[], pagination: PaginationInput): PaginatedResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
meta: { pagination: this.calculatePagination(pagination) },
|
||||
};
|
||||
}
|
||||
|
||||
// Message-only response
|
||||
protected message(message: string): SuccessResponse<{ message: string }> {
|
||||
return this.success({ message });
|
||||
}
|
||||
|
||||
// No content response (204)
|
||||
protected noContent(): void {
|
||||
this.setStatus(204);
|
||||
}
|
||||
|
||||
// Error response (prefer throwing errors instead)
|
||||
protected error(code: string, message: string, details?: unknown): ErrorResponse {
|
||||
return { success: false, error: { code, message, details } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication with @Security
|
||||
|
||||
tsoa integrates with the existing passport-jwt strategy via a custom authentication module:
|
||||
|
||||
```typescript
|
||||
// src/middleware/tsoaAuthentication.ts
|
||||
export async function expressAuthentication(
|
||||
request: Request,
|
||||
securityName: string,
|
||||
_scopes?: string[],
|
||||
): Promise<UserProfile> {
|
||||
if (securityName !== 'bearerAuth') {
|
||||
throw new AuthenticationError(`Unknown security scheme: ${securityName}`);
|
||||
}
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
|
||||
const userProfile = await userRepo.findUserProfileById(decoded.user_id);
|
||||
|
||||
if (!userProfile) {
|
||||
throw new AuthenticationError('User not found');
|
||||
}
|
||||
|
||||
request.user = userProfile;
|
||||
return userProfile;
|
||||
}
|
||||
```
|
||||
|
||||
Usage in controllers:
|
||||
|
||||
```typescript
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(@Request() req: Express.Request): Promise<...> {
|
||||
const user = req.user as UserProfile;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### DTO Organization
|
||||
|
||||
Shared DTOs are defined in `src/dtos/common.dto.ts` to avoid duplicate type definitions across controllers:
|
||||
|
||||
```typescript
|
||||
// src/dtos/common.dto.ts
|
||||
|
||||
/**
|
||||
* Address with flattened coordinates (tsoa-compatible).
|
||||
* GeoJSONPoint uses coordinates: [number, number] which tsoa cannot handle.
|
||||
*/
|
||||
export interface AddressDto {
|
||||
address_id: number;
|
||||
address_line_1: string;
|
||||
city: string;
|
||||
province_state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
latitude?: number | null; // Flattened from GeoJSONPoint
|
||||
longitude?: number | null; // Flattened from GeoJSONPoint
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
user_id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserProfileDto {
|
||||
full_name?: string | null;
|
||||
role: 'admin' | 'user';
|
||||
points: number;
|
||||
user: UserDto;
|
||||
address?: AddressDto | null;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Swagger UI Setup
|
||||
|
||||
In `server.ts`, add the Swagger UI middleware (development/test only):
|
||||
In `server.ts`, the Swagger UI middleware serves the tsoa-generated spec:
|
||||
|
||||
```typescript
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { swaggerSpec } from './src/config/swagger';
|
||||
import tsoaSpec from './src/config/tsoa-spec.json' with { type: 'json' };
|
||||
|
||||
// Only serve Swagger UI in non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('/docs/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.use('/docs/api-docs', swaggerUi.serve, swaggerUi.setup(tsoaSpec));
|
||||
|
||||
// Optionally expose raw JSON spec for tooling
|
||||
// Raw JSON spec for tooling
|
||||
app.get('/docs/api-docs.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
res.send(tsoaSpec);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Build Integration
|
||||
|
||||
tsoa spec and route generation is integrated into the build pipeline:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"tsoa:spec": "tsoa spec",
|
||||
"tsoa:routes": "tsoa routes",
|
||||
"prebuild": "npm run tsoa:spec && npm run tsoa:routes",
|
||||
"build": "tsc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema Standardization
|
||||
|
||||
All API responses follow the standardized format from [ADR-028](./0028-api-response-standardization.md):
|
||||
@@ -160,107 +319,144 @@ All API responses follow the standardized format from [ADR-028](./0028-api-respo
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// Paginated response
|
||||
{
|
||||
"success": true,
|
||||
"data": [...],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 100,
|
||||
"totalPages": 5,
|
||||
"hasNextPage": true,
|
||||
"hasPrevPage": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error response
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable message"
|
||||
"code": "NOT_FOUND",
|
||||
"message": "User not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Define reusable schema components for these patterns:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* components:
|
||||
* schemas:
|
||||
* SuccessResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* ErrorResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: object
|
||||
* properties:
|
||||
* code:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
*/
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Production Disabled**: Swagger UI is not available in production to prevent information disclosure.
|
||||
2. **No Sensitive Data**: Never include actual secrets, tokens, or PII in example values.
|
||||
3. **Authentication Documented**: Clearly document which endpoints require authentication.
|
||||
|
||||
## API Route Tags
|
||||
|
||||
Organize endpoints using consistent tags:
|
||||
|
||||
| Tag | Description | Routes |
|
||||
| Tag | Description | Route Prefix |
|
||||
| ------------ | ---------------------------------- | --------------------- |
|
||||
| Health | Server health and readiness checks | `/api/health/*` |
|
||||
| Auth | Authentication and authorization | `/api/auth/*` |
|
||||
| Users | User profile management | `/api/users/*` |
|
||||
| Flyers | Flyer uploads and retrieval | `/api/flyers/*` |
|
||||
| Achievements | Gamification and leaderboards | `/api/achievements/*` |
|
||||
| Budgets | Budget tracking | `/api/budgets/*` |
|
||||
| Deals | Deal search and management | `/api/deals/*` |
|
||||
| Stores | Store information | `/api/stores/*` |
|
||||
| Recipes | Recipe management | `/api/recipes/*` |
|
||||
| Budgets | Budget tracking | `/api/budgets/*` |
|
||||
| Inventory | User inventory management | `/api/inventory/*` |
|
||||
| Gamification | Achievements and leaderboards | `/api/achievements/*` |
|
||||
| Admin | Administrative operations | `/api/admin/*` |
|
||||
| System | System status and monitoring | `/api/system/*` |
|
||||
|
||||
## Controller Inventory
|
||||
|
||||
The following controllers have been migrated to tsoa:
|
||||
|
||||
| Controller | Endpoints | Description |
|
||||
| ------------------------------- | --------- | ----------------------------------------- |
|
||||
| `health.controller.ts` | 10 | Health checks, probes, service status |
|
||||
| `auth.controller.ts` | 8 | Login, register, password reset, OAuth |
|
||||
| `user.controller.ts` | 30 | User profiles, preferences, notifications |
|
||||
| `admin.controller.ts` | 32 | System administration, user management |
|
||||
| `ai.controller.ts` | 15 | AI-powered extraction and analysis |
|
||||
| `flyer.controller.ts` | 12 | Flyer upload and management |
|
||||
| `store.controller.ts` | 8 | Store information |
|
||||
| `recipe.controller.ts` | 10 | Recipe CRUD and suggestions |
|
||||
| `upc.controller.ts` | 6 | UPC barcode lookups |
|
||||
| `inventory.controller.ts` | 8 | User inventory management |
|
||||
| `receipt.controller.ts` | 6 | Receipt processing |
|
||||
| `budget.controller.ts` | 8 | Budget tracking |
|
||||
| `category.controller.ts` | 4 | Category management |
|
||||
| `deals.controller.ts` | 8 | Deal search and discovery |
|
||||
| `stats.controller.ts` | 6 | Usage statistics |
|
||||
| `price.controller.ts` | 6 | Price history and tracking |
|
||||
| `system.controller.ts` | 4 | System status |
|
||||
| `gamification.controller.ts` | 10 | Achievements, leaderboards |
|
||||
| `personalization.controller.ts` | 6 | User recommendations |
|
||||
| `reactions.controller.ts` | 4 | Item reactions and ratings |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Production Disabled**: Swagger UI is not available in production to prevent information disclosure.
|
||||
2. **No Sensitive Data**: Never include actual secrets, tokens, or PII in example values.
|
||||
3. **Authentication Documented**: Clearly document which endpoints require authentication.
|
||||
4. **Rate Limiting**: Rate limiters are applied via `@Middlewares` decorator.
|
||||
|
||||
## Testing
|
||||
|
||||
Verify API documentation is correct by:
|
||||
|
||||
1. **Manual Review**: Navigate to `/docs/api-docs` and test each endpoint.
|
||||
2. **Spec Validation**: Use OpenAPI validators to check the generated spec.
|
||||
3. **Integration Tests**: Existing integration tests serve as implicit documentation verification.
|
||||
3. **Controller Tests**: Each controller has comprehensive test coverage (369 controller tests total).
|
||||
4. **Integration Tests**: 345 integration tests verify endpoint behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Single Source of Truth**: Documentation lives with the code and stays in sync.
|
||||
- **Interactive Exploration**: Developers can try endpoints directly from the UI.
|
||||
- **SDK Generation**: OpenAPI spec enables automatic client SDK generation.
|
||||
- **Onboarding**: New developers can quickly understand the API surface.
|
||||
- **Low Overhead**: JSDoc annotations are minimal additions to existing code.
|
||||
- **Type-safe API contracts**: tsoa decorators derive types from TypeScript, eliminating duplicate definitions
|
||||
- **Single Source of Truth**: Documentation lives with the code and stays in sync
|
||||
- **Active Maintenance**: tsoa is actively maintained with regular releases
|
||||
- **Interactive Exploration**: Developers can try endpoints directly from Swagger UI
|
||||
- **SDK Generation**: OpenAPI spec enables automatic client SDK generation
|
||||
- **Reduced Boilerplate**: tsoa generates Express routes automatically
|
||||
|
||||
### Negative
|
||||
|
||||
- **Maintenance Required**: Developers must update annotations when routes change.
|
||||
- **Build Dependency**: Adds `swagger-jsdoc` and `swagger-ui-express` packages.
|
||||
- **Initial Investment**: Existing routes need annotations added incrementally.
|
||||
- **Learning Curve**: Decorator-based controller pattern differs from Express handlers
|
||||
- **Generated Code**: `tsoa-generated.ts` must be regenerated when controllers change
|
||||
- **Build Step**: Adds `tsoa spec && tsoa routes` to the build pipeline
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Include documentation checks in code review process.
|
||||
- Start with high-priority routes and expand coverage over time.
|
||||
- Use TypeScript types to reduce documentation duplication where possible.
|
||||
- **Migration Guide**: Created comprehensive TSOA-MIGRATION-GUIDE.md for developers
|
||||
- **BaseController**: Provides familiar response helpers matching existing patterns
|
||||
- **Incremental Adoption**: Existing Express routes continue to work alongside tsoa controllers
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/config/swagger.ts` - OpenAPI configuration
|
||||
- `src/routes/*.ts` - Route files with JSDoc annotations
|
||||
- `server.ts` - Swagger UI middleware setup
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | --------------------------------------- |
|
||||
| `tsoa.json` | tsoa configuration |
|
||||
| `src/controllers/base.controller.ts` | Base controller with response utilities |
|
||||
| `src/controllers/types.ts` | Shared controller type definitions |
|
||||
| `src/controllers/*.controller.ts` | Individual domain controllers |
|
||||
| `src/dtos/common.dto.ts` | Shared DTO definitions |
|
||||
| `src/middleware/tsoaAuthentication.ts` | JWT authentication handler |
|
||||
| `src/routes/tsoa-generated.ts` | tsoa-generated Express routes |
|
||||
| `src/config/tsoa-spec.json` | Generated OpenAPI 3.0 spec |
|
||||
| `server.ts` | Swagger UI middleware setup |
|
||||
|
||||
## Migration History
|
||||
|
||||
| Date | Change |
|
||||
| ---------- | --------------------------------------------------------------- |
|
||||
| 2025-12-12 | Initial ADR created with swagger-jsdoc approach |
|
||||
| 2026-01-11 | Began implementation with swagger-jsdoc |
|
||||
| 2026-02-12 | Completed migration to tsoa, superseding swagger-jsdoc approach |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-059](./0059-dependency-modernization.md) - Dependency Modernization (tsoa migration plan)
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (Zod schemas)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling
|
||||
- [ADR-016](./0016-api-security-hardening.md) - Security Hardening
|
||||
- [ADR-048](./0048-authentication-strategy.md) - Authentication Strategy
|
||||
|
||||
308
docs/adr/0059-dependency-modernization.md
Normal file
308
docs/adr/0059-dependency-modernization.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# ADR-059: Dependency Modernization Plan
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-12
|
||||
**Implemented**: 2026-02-12
|
||||
|
||||
## Context
|
||||
|
||||
NPM audit and security scanning identified deprecated dependencies requiring modernization:
|
||||
|
||||
| Dependency | Current | Issue | Replacement |
|
||||
| --------------- | ------- | ----------------------- | --------------------------------------- |
|
||||
| `swagger-jsdoc` | 6.2.8 | Unmaintained since 2022 | `tsoa` (decorator-based OpenAPI) |
|
||||
| `rimraf` | 6.1.2 | Legacy cleanup utility | Node.js `fs.rm()` (native since v14.14) |
|
||||
|
||||
**Constraints**:
|
||||
|
||||
- Existing `@openapi` JSDoc annotations in 20 route files
|
||||
- ADR-018 compliance (API documentation strategy)
|
||||
- Zero-downtime migration (phased approach)
|
||||
- Must maintain Express 5.x compatibility
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. swagger-jsdoc → tsoa Migration
|
||||
|
||||
**Architecture**: tsoa controller classes + Express integration (no replacement of Express routing layer).
|
||||
|
||||
```text
|
||||
Current: Route Files → JSDoc Annotations → swagger-jsdoc → OpenAPI Spec
|
||||
Future: Controller Classes → @Route/@Get decorators → tsoa → OpenAPI Spec + Route Registration
|
||||
```
|
||||
|
||||
**Controller Pattern**: Base controller providing common utilities:
|
||||
|
||||
```typescript
|
||||
// src/controllers/base.controller.ts
|
||||
export abstract class BaseController {
|
||||
protected sendSuccess<T>(res: Response, data: T, status = 200) {
|
||||
return sendSuccess(res, data, status);
|
||||
}
|
||||
protected sendError(
|
||||
res: Response,
|
||||
code: ErrorCode,
|
||||
msg: string,
|
||||
status: number,
|
||||
details?: unknown,
|
||||
) {
|
||||
return sendError(res, code, msg, status, details);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Express Integration Strategy**: tsoa generates routes.ts; wrap with Express middleware pipeline:
|
||||
|
||||
```typescript
|
||||
// server.ts integration
|
||||
import { RegisterRoutes } from './src/generated/routes';
|
||||
RegisterRoutes(app); // tsoa registers routes with existing Express app
|
||||
```
|
||||
|
||||
### 2. rimraf → fs.rm() Migration
|
||||
|
||||
**Change**: Replace `rimraf coverage .coverage` script with Node.js native API.
|
||||
|
||||
```json
|
||||
// package.json (before)
|
||||
"clean": "rimraf coverage .coverage"
|
||||
|
||||
// package.json (after)
|
||||
"clean": "node -e \"import('fs/promises').then(fs => Promise.all([fs.rm('coverage', {recursive:true,force:true}), fs.rm('.coverage', {recursive:true,force:true})]))\""
|
||||
```
|
||||
|
||||
**Alternative**: Create `scripts/clean.mjs` for maintainability:
|
||||
|
||||
```javascript
|
||||
// scripts/clean.mjs
|
||||
import { rm } from 'fs/promises';
|
||||
await Promise.all([
|
||||
rm('coverage', { recursive: true, force: true }),
|
||||
rm('.coverage', { recursive: true, force: true }),
|
||||
]);
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Infrastructure (Tasks 1-4)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | ---------------------------------------------- | ------------ |
|
||||
| 1 | Install tsoa, configure tsoa.json | None |
|
||||
| 2 | Create BaseController with utility methods | Task 1 |
|
||||
| 3 | Configure Express integration (RegisterRoutes) | Task 2 |
|
||||
| 4 | Set up tsoa spec generation in build pipeline | Task 3 |
|
||||
|
||||
### Phase 2: Controller Migration (Tasks 5-14)
|
||||
|
||||
Priority order matches ADR-018:
|
||||
|
||||
| Task | Route File | Controller Class | Dependencies |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------ |
|
||||
| 5 | health.routes.ts | HealthController | Task 4 |
|
||||
| 6 | auth.routes.ts | AuthController | Task 4 |
|
||||
| 7 | gamification.routes.ts | AchievementsController | Task 4 |
|
||||
| 8 | flyer.routes.ts | FlyersController | Task 4 |
|
||||
| 9 | user.routes.ts | UsersController | Task 4 |
|
||||
| 10 | budget.routes.ts | BudgetController | Task 4 |
|
||||
| 11 | recipe.routes.ts | RecipeController | Task 4 |
|
||||
| 12 | store.routes.ts | StoreController | Task 4 |
|
||||
| 13 | admin.routes.ts | AdminController | Task 4 |
|
||||
| 14 | Remaining routes (deals, price, upc, inventory, ai, receipt, category, stats, personalization, reactions, system) | Various | Task 4 |
|
||||
|
||||
### Phase 3: Cleanup and rimraf (Tasks 15-18)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | -------------------------------- | ------------------- |
|
||||
| 15 | Create scripts/clean.mjs | None |
|
||||
| 16 | Update package.json clean script | Task 15 |
|
||||
| 17 | Remove rimraf dependency | Task 16 |
|
||||
| 18 | Remove swagger-jsdoc + types | Tasks 5-14 complete |
|
||||
|
||||
### Phase 4: Verification (Tasks 19-24)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | --------------------------------- | ------------ |
|
||||
| 19 | Run type-check | Tasks 15-18 |
|
||||
| 20 | Run unit tests | Task 19 |
|
||||
| 21 | Run integration tests | Task 20 |
|
||||
| 22 | Verify OpenAPI spec completeness | Task 21 |
|
||||
| 23 | Update ADR-018 (reference tsoa) | Task 22 |
|
||||
| 24 | Update CLAUDE.md (swagger → tsoa) | Task 23 |
|
||||
|
||||
### Task Dependency Graph
|
||||
|
||||
```text
|
||||
[1: Install tsoa]
|
||||
|
|
||||
[2: BaseController]
|
||||
|
|
||||
[3: Express Integration]
|
||||
|
|
||||
[4: Build Pipeline]
|
||||
|
|
||||
+------------------+------------------+
|
||||
| | | | |
|
||||
[5] [6] [7] [8] [9-14]
|
||||
Health Auth Gamif Flyer Others
|
||||
| | | | |
|
||||
+------------------+------------------+
|
||||
|
|
||||
[18: Remove swagger-jsdoc]
|
||||
|
|
||||
[15: clean.mjs] -----> [16: Update pkg.json]
|
||||
|
|
||||
[17: Remove rimraf]
|
||||
|
|
||||
[19: type-check]
|
||||
|
|
||||
[20: unit tests]
|
||||
|
|
||||
[21: integration tests]
|
||||
|
|
||||
[22: Verify OpenAPI]
|
||||
|
|
||||
[23: Update ADR-018]
|
||||
|
|
||||
[24: Update CLAUDE.md]
|
||||
```
|
||||
|
||||
### Critical Path
|
||||
|
||||
**Minimum time to completion**: Tasks 1 → 2 → 3 → 4 → 5 (or any controller) → 18 → 19 → 20 → 21 → 22 → 23 → 24
|
||||
|
||||
**Parallelization opportunities**:
|
||||
|
||||
- Tasks 5-14 (all controller migrations) can run in parallel after Task 4
|
||||
- Tasks 15-17 (rimraf removal) can run in parallel with controller migrations
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### tsoa Configuration
|
||||
|
||||
```json
|
||||
// tsoa.json
|
||||
{
|
||||
"entryFile": "server.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "src/generated",
|
||||
"specVersion": 3,
|
||||
"basePath": "/api/v1"
|
||||
},
|
||||
"routes": {
|
||||
"routesDir": "src/generated",
|
||||
"middleware": "express"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Decorator Migration Example
|
||||
|
||||
**Before** (swagger-jsdoc):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* tags: [Health]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), handler);
|
||||
```
|
||||
|
||||
**After** (tsoa):
|
||||
|
||||
```typescript
|
||||
@Route('health')
|
||||
@Tags('Health')
|
||||
export class HealthController extends BaseController {
|
||||
@Get('ping')
|
||||
@SuccessResponse(200, 'Server is responsive')
|
||||
public async ping(): Promise<{ message: string }> {
|
||||
return { message: 'pong' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Integration
|
||||
|
||||
tsoa uses its own validation. Options:
|
||||
|
||||
1. **Replace Zod with tsoa validation** - Use `@Body`, `@Query`, `@Path` decorators with TypeScript types
|
||||
2. **Hybrid approach** - Keep Zod schemas, call `validateRequest()` within controller methods
|
||||
3. **Custom template** - Generate tsoa routes that call Zod validation middleware
|
||||
|
||||
**Recommended**: Option 1 for new controllers; gradually migrate existing Zod schemas.
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| --------------------------------------- | ---------- | ------ | ------------------------------------------- |
|
||||
| tsoa/Express 5.x incompatibility | Medium | High | Test in dev container before migration |
|
||||
| Missing OpenAPI coverage post-migration | Low | Medium | Compare generated specs before/after |
|
||||
| Authentication middleware integration | Medium | Medium | Test @Security decorator with passport-jwt |
|
||||
| Test regression from route changes | Low | High | Run full test suite after each controller |
|
||||
| Build time increase (tsoa generation) | Low | Low | Add to npm run build; cache generated files |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Type-safe API contracts**: tsoa decorators derive types from TypeScript
|
||||
- **Reduced duplication**: No more parallel JSDoc + TypeScript type definitions
|
||||
- **Modern tooling**: Active tsoa community (vs. unmaintained swagger-jsdoc)
|
||||
- **Native Node.js**: fs.rm() is built-in, no external dependency
|
||||
- **Smaller dependency tree**: Remove rimraf (5 transitive deps) + swagger-jsdoc (8 transitive deps)
|
||||
|
||||
### Negative
|
||||
|
||||
- **Learning curve**: Decorator-based controller pattern differs from Express handlers
|
||||
- **Migration effort**: 20 route files require conversion
|
||||
- **Generated code**: `src/generated/routes.ts` must be version-controlled or regenerated on build
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Build step change**: Add `tsoa spec && tsoa routes` to build pipeline
|
||||
- **Testing approach**: May need to adjust test structure for controller classes
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Update swagger-jsdoc to fork/successor
|
||||
|
||||
**Rejected**: No active fork; community has moved to tsoa, fastify-swagger, or NestJS.
|
||||
|
||||
### 2. NestJS migration
|
||||
|
||||
**Rejected**: Full framework migration (Express → NestJS) is disproportionate to the problem scope.
|
||||
|
||||
### 3. fastify-swagger
|
||||
|
||||
**Rejected**: Requires Express → Fastify migration; out of scope.
|
||||
|
||||
### 4. Keep rimraf, accept deprecation warning
|
||||
|
||||
**Rejected**: Native fs.rm() is trivial replacement; no reason to maintain deprecated dependency.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------ | ------------------------------------- |
|
||||
| `tsoa.json` | tsoa configuration |
|
||||
| `src/controllers/base.controller.ts` | Base controller with utilities |
|
||||
| `src/controllers/*.controller.ts` | Individual domain controllers |
|
||||
| `src/generated/routes.ts` | tsoa-generated Express routes |
|
||||
| `src/generated/swagger.json` | Generated OpenAPI 3.0 spec |
|
||||
| `scripts/clean.mjs` | Native fs.rm() replacement for rimraf |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-018](./0018-api-documentation-strategy.md) - API Documentation Strategy (will be updated)
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (Zod integration)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization (BaseController pattern)
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling (error utilities in BaseController)
|
||||
@@ -23,7 +23,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
|
||||
**[ADR-003](./0003-standardized-input-validation-using-middleware.md)**: Standardized Input Validation using Middleware (Accepted)
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Accepted - Phase 2 Complete)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Accepted)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Superseded - tsoa migration complete)
|
||||
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Accepted)
|
||||
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Implemented)
|
||||
|
||||
@@ -74,6 +74,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-045](./0045-test-data-factories-and-fixtures.md)**: Test Data Factories and Fixtures (Accepted)
|
||||
**[ADR-047](./0047-project-file-and-folder-organization.md)**: Project File and Folder Organization (Proposed)
|
||||
**[ADR-057](./0057-test-remediation-post-api-versioning.md)**: Test Remediation Post-API Versioning (Accepted)
|
||||
**[ADR-059](./0059-dependency-modernization.md)**: Dependency Modernization - tsoa Migration (Accepted)
|
||||
|
||||
## 9. Architecture Patterns
|
||||
|
||||
|
||||
@@ -4,22 +4,24 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Key Function/Class | Import From |
|
||||
| ------------------ | ------------------------------------------------- | ------------------------------------- |
|
||||
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
||||
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
||||
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
||||
| Authentication | `authenticateJWT` | `src/middleware/auth.ts` |
|
||||
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
||||
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
||||
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
||||
| Pattern | Key Function/Class | Import From |
|
||||
| -------------------- | ------------------------------------------------- | ------------------------------------- |
|
||||
| **tsoa Controllers** | `BaseController`, `@Route`, `@Security` | `src/controllers/base.controller.ts` |
|
||||
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
||||
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
||||
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
||||
| Authentication | `authenticateJWT`, `@Security('bearerAuth')` | `src/middleware/auth.ts` |
|
||||
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
||||
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
||||
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [tsoa Controllers](#tsoa-controllers)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Repository Patterns](#repository-patterns)
|
||||
- [API Response Patterns](#api-response-patterns)
|
||||
@@ -32,6 +34,159 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
|
||||
|
||||
---
|
||||
|
||||
## tsoa Controllers
|
||||
|
||||
**ADR**: [ADR-018](../adr/0018-api-documentation-strategy.md), [ADR-059](../adr/0059-dependency-modernization.md)
|
||||
|
||||
All API endpoints are implemented as tsoa controller classes that extend `BaseController`. This pattern provides type-safe OpenAPI documentation generation and standardized response formatting.
|
||||
|
||||
### Basic Controller Structure
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Route,
|
||||
Tags,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
|
||||
interface CreateItemRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ItemResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@Route('items')
|
||||
@Tags('Items')
|
||||
export class ItemController extends BaseController {
|
||||
/**
|
||||
* Get an item by ID.
|
||||
* @summary Get item
|
||||
* @param id Item ID
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Item retrieved')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async getItem(@Path() id: number): Promise<SuccessResponseType<ItemResponse>> {
|
||||
const item = await itemService.getItemById(id);
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new item. Requires authentication.
|
||||
* @summary Create item
|
||||
*/
|
||||
@Post()
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Item created')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async createItem(
|
||||
@Body() body: CreateItemRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ItemResponse>> {
|
||||
const user = request.user as UserProfile;
|
||||
const item = await itemService.createItem(body, user.user.user_id);
|
||||
return this.created(item);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### BaseController Response Helpers
|
||||
|
||||
```typescript
|
||||
// Success response (200)
|
||||
return this.success(data);
|
||||
|
||||
// Created response (201)
|
||||
return this.created(data);
|
||||
|
||||
// Paginated response
|
||||
const { page, limit } = this.normalizePagination(queryPage, queryLimit);
|
||||
return this.paginated(items, { page, limit, total });
|
||||
|
||||
// Message-only response
|
||||
return this.message('Operation completed');
|
||||
|
||||
// No content (204)
|
||||
return this.noContent();
|
||||
```
|
||||
|
||||
### Authentication with @Security
|
||||
|
||||
```typescript
|
||||
import { Security, Request } from 'tsoa';
|
||||
import { requireAdminRole } from '../middleware/tsoaAuthentication';
|
||||
|
||||
// Require authentication
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(@Request() req: ExpressRequest): Promise<...> {
|
||||
const user = req.user as UserProfile;
|
||||
return this.success(user);
|
||||
}
|
||||
|
||||
// Require admin role
|
||||
@Delete('users/{id}')
|
||||
@Security('bearerAuth')
|
||||
public async deleteUser(@Path() id: string, @Request() req: ExpressRequest): Promise<void> {
|
||||
requireAdminRole(req.user as UserProfile);
|
||||
await userService.deleteUser(id);
|
||||
return this.noContent();
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling in Controllers
|
||||
|
||||
```typescript
|
||||
import { NotFoundError, ValidationError, ForbiddenError } from './base.controller';
|
||||
|
||||
// Throw errors - they're handled by the global error handler
|
||||
throw new NotFoundError('Item', id); // 404
|
||||
throw new ValidationError([], 'Invalid'); // 400
|
||||
throw new ForbiddenError('Admin only'); // 403
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { Middlewares } from 'tsoa';
|
||||
import { loginLimiter } from '../config/rateLimiters';
|
||||
|
||||
@Post('login')
|
||||
@Middlewares(loginLimiter)
|
||||
@Response<ErrorResponse>(429, 'Too many attempts')
|
||||
public async login(@Body() body: LoginRequest): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Regenerating Routes
|
||||
|
||||
After modifying controllers, regenerate the tsoa routes:
|
||||
|
||||
```bash
|
||||
npm run tsoa:spec && npm run tsoa:routes
|
||||
```
|
||||
|
||||
**Full Guide**: See [TSOA-MIGRATION-GUIDE.md](./TSOA-MIGRATION-GUIDE.md) for comprehensive documentation.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
**ADR**: [ADR-001](../adr/0001-standardized-error-handling.md)
|
||||
|
||||
899
docs/development/TSOA-MIGRATION-GUIDE.md
Normal file
899
docs/development/TSOA-MIGRATION-GUIDE.md
Normal file
@@ -0,0 +1,899 @@
|
||||
# tsoa Migration Guide
|
||||
|
||||
This guide documents the migration from `swagger-jsdoc` to `tsoa` for API documentation and route generation in the Flyer Crawler project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Creating a New Controller](#creating-a-new-controller)
|
||||
- [BaseController Pattern](#basecontroller-pattern)
|
||||
- [Authentication](#authentication)
|
||||
- [Request Handling](#request-handling)
|
||||
- [Response Formatting](#response-formatting)
|
||||
- [DTOs and Type Definitions](#dtos-and-type-definitions)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Rate Limiting](#rate-limiting)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Testing Controllers](#testing-controllers)
|
||||
- [Build and Development](#build-and-development)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Migration Lessons Learned](#migration-lessons-learned)
|
||||
|
||||
## Overview
|
||||
|
||||
### What Changed
|
||||
|
||||
| Before (swagger-jsdoc) | After (tsoa) |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
| JSDoc `@openapi` comments in route files | TypeScript decorators on controller classes |
|
||||
| Manual Express route registration | tsoa generates routes automatically |
|
||||
| Separate Zod validation middleware | tsoa validates from TypeScript types |
|
||||
| OpenAPI spec from comments | OpenAPI spec from decorators and types |
|
||||
|
||||
### Why tsoa?
|
||||
|
||||
1. **Type Safety**: OpenAPI spec is generated from TypeScript types, eliminating drift
|
||||
2. **Active Maintenance**: tsoa is actively maintained (vs. unmaintained swagger-jsdoc)
|
||||
3. **Reduced Duplication**: No more parallel JSDoc + TypeScript definitions
|
||||
4. **Route Generation**: tsoa generates Express routes, reducing boilerplate
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | --------------------------------------- |
|
||||
| `tsoa.json` | tsoa configuration |
|
||||
| `src/controllers/base.controller.ts` | Base controller with response utilities |
|
||||
| `src/controllers/types.ts` | Shared controller type definitions |
|
||||
| `src/controllers/*.controller.ts` | Domain controllers |
|
||||
| `src/dtos/common.dto.ts` | Shared DTO definitions |
|
||||
| `src/middleware/tsoaAuthentication.ts` | JWT authentication handler |
|
||||
| `src/routes/tsoa-generated.ts` | Generated Express routes |
|
||||
| `src/config/tsoa-spec.json` | Generated OpenAPI 3.0 spec |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
|
|
||||
v
|
||||
Express Middleware (logging, CORS, body parsing)
|
||||
|
|
||||
v
|
||||
tsoa-generated routes (src/routes/tsoa-generated.ts)
|
||||
|
|
||||
v
|
||||
tsoaAuthentication (if @Security decorator present)
|
||||
|
|
||||
v
|
||||
Controller Method
|
||||
|
|
||||
v
|
||||
Service Layer
|
||||
|
|
||||
v
|
||||
Repository Layer
|
||||
|
|
||||
v
|
||||
Database
|
||||
```
|
||||
|
||||
### Controller Structure
|
||||
|
||||
```
|
||||
src/controllers/
|
||||
base.controller.ts # Base class with response helpers
|
||||
types.ts # Shared type definitions
|
||||
health.controller.ts # Health check endpoints
|
||||
auth.controller.ts # Authentication endpoints
|
||||
user.controller.ts # User management endpoints
|
||||
...
|
||||
```
|
||||
|
||||
## Creating a New Controller
|
||||
|
||||
### Step 1: Create the Controller File
|
||||
|
||||
```typescript
|
||||
// src/controllers/example.controller.ts
|
||||
import {
|
||||
Route,
|
||||
Tags,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
PaginatedResponse,
|
||||
} from './base.controller';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface CreateExampleRequest {
|
||||
/**
|
||||
* Name of the example item.
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
* @example "My Example"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Optional description.
|
||||
* @example "This is an example item"
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ExampleResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Example controller demonstrating tsoa patterns.
|
||||
*/
|
||||
@Route('examples')
|
||||
@Tags('Examples')
|
||||
export class ExampleController extends BaseController {
|
||||
/**
|
||||
* List all examples with pagination.
|
||||
* @summary List examples
|
||||
* @param page Page number (1-indexed)
|
||||
* @param limit Items per page (max 100)
|
||||
* @returns Paginated list of examples
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'Examples retrieved')
|
||||
public async listExamples(
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number,
|
||||
): Promise<PaginatedResponse<ExampleResponse>> {
|
||||
const { page: p, limit: l } = this.normalizePagination(page, limit);
|
||||
|
||||
// Call service layer
|
||||
const { items, total } = await exampleService.listExamples(p, l);
|
||||
|
||||
return this.paginated(items, { page: p, limit: l, total });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single example by ID.
|
||||
* @summary Get example
|
||||
* @param id Example ID
|
||||
* @returns The example
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Example retrieved')
|
||||
@Response<ErrorResponse>(404, 'Example not found')
|
||||
public async getExample(@Path() id: number): Promise<SuccessResponseType<ExampleResponse>> {
|
||||
const example = await exampleService.getExampleById(id);
|
||||
return this.success(example);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new example.
|
||||
* Requires authentication.
|
||||
* @summary Create example
|
||||
* @param requestBody Example data
|
||||
* @param request Express request
|
||||
* @returns Created example
|
||||
*/
|
||||
@Post()
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Example created')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async createExample(
|
||||
@Body() requestBody: CreateExampleRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExampleResponse>> {
|
||||
const user = request.user as UserProfile;
|
||||
const example = await exampleService.createExample(requestBody, user.user.user_id);
|
||||
return this.created(example);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an example.
|
||||
* Requires authentication.
|
||||
* @summary Delete example
|
||||
* @param id Example ID
|
||||
* @param request Express request
|
||||
*/
|
||||
@Delete('{id}')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(204, 'Example deleted')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
@Response<ErrorResponse>(404, 'Example not found')
|
||||
public async deleteExample(
|
||||
@Path() id: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const user = request.user as UserProfile;
|
||||
await exampleService.deleteExample(id, user.user.user_id);
|
||||
return this.noContent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Regenerate Routes
|
||||
|
||||
After creating or modifying a controller:
|
||||
|
||||
```bash
|
||||
# Generate OpenAPI spec and routes
|
||||
npm run tsoa:spec && npm run tsoa:routes
|
||||
|
||||
# Or use the combined command
|
||||
npm run prebuild
|
||||
```
|
||||
|
||||
### Step 3: Add Tests
|
||||
|
||||
Create a test file at `src/controllers/__tests__/example.controller.test.ts`.
|
||||
|
||||
## BaseController Pattern
|
||||
|
||||
All controllers extend `BaseController` which provides:
|
||||
|
||||
### Response Helpers
|
||||
|
||||
```typescript
|
||||
// Success response (200)
|
||||
return this.success(data);
|
||||
|
||||
// Created response (201)
|
||||
return this.created(data);
|
||||
|
||||
// Paginated response (200 with pagination metadata)
|
||||
return this.paginated(items, { page, limit, total });
|
||||
|
||||
// Message-only response
|
||||
return this.message('Operation completed successfully');
|
||||
|
||||
// No content response (204)
|
||||
return this.noContent();
|
||||
|
||||
// Error response (prefer throwing errors)
|
||||
this.setStatus(400);
|
||||
return this.error('BAD_REQUEST', 'Invalid input', details);
|
||||
```
|
||||
|
||||
### Pagination Helpers
|
||||
|
||||
```typescript
|
||||
// Normalize pagination with defaults and bounds
|
||||
const { page, limit } = this.normalizePagination(queryPage, queryLimit);
|
||||
// page defaults to 1, limit defaults to 20, max 100
|
||||
|
||||
// Calculate pagination metadata
|
||||
const meta = this.calculatePagination({ page, limit, total });
|
||||
// Returns: { page, limit, total, totalPages, hasNextPage, hasPrevPage }
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
|
||||
```typescript
|
||||
// Access standard error codes
|
||||
this.ErrorCode.VALIDATION_ERROR; // 'VALIDATION_ERROR'
|
||||
this.ErrorCode.NOT_FOUND; // 'NOT_FOUND'
|
||||
this.ErrorCode.UNAUTHORIZED; // 'UNAUTHORIZED'
|
||||
this.ErrorCode.FORBIDDEN; // 'FORBIDDEN'
|
||||
this.ErrorCode.CONFLICT; // 'CONFLICT'
|
||||
this.ErrorCode.BAD_REQUEST; // 'BAD_REQUEST'
|
||||
this.ErrorCode.INTERNAL_ERROR; // 'INTERNAL_ERROR'
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Using @Security Decorator
|
||||
|
||||
```typescript
|
||||
import { Security, Request } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
// request.user is populated by tsoaAuthentication.ts
|
||||
const user = request.user as UserProfile;
|
||||
return this.success(toUserProfileDto(user));
|
||||
}
|
||||
```
|
||||
|
||||
### Requiring Admin Role
|
||||
|
||||
```typescript
|
||||
import { requireAdminRole } from '../middleware/tsoaAuthentication';
|
||||
|
||||
@Delete('users/{id}')
|
||||
@Security('bearerAuth')
|
||||
public async deleteUser(
|
||||
@Path() id: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const user = request.user as UserProfile;
|
||||
requireAdminRole(user); // Throws 403 if not admin
|
||||
|
||||
await userService.deleteUser(id);
|
||||
return this.noContent();
|
||||
}
|
||||
```
|
||||
|
||||
### How Authentication Works
|
||||
|
||||
1. tsoa sees `@Security('bearerAuth')` decorator
|
||||
2. tsoa calls `expressAuthentication()` from `src/middleware/tsoaAuthentication.ts`
|
||||
3. The function extracts and validates the JWT token
|
||||
4. User profile is fetched from database and attached to `request.user`
|
||||
5. If authentication fails, an `AuthenticationError` is thrown
|
||||
|
||||
## Request Handling
|
||||
|
||||
### Path Parameters
|
||||
|
||||
```typescript
|
||||
@Get('{id}')
|
||||
public async getItem(@Path() id: number): Promise<...> { ... }
|
||||
|
||||
// Multiple path params
|
||||
@Get('{userId}/items/{itemId}')
|
||||
public async getUserItem(
|
||||
@Path() userId: string,
|
||||
@Path() itemId: number,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
public async listItems(
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number,
|
||||
@Query() status?: 'active' | 'inactive',
|
||||
@Query() search?: string,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```typescript
|
||||
interface CreateItemRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@Post()
|
||||
public async createItem(
|
||||
@Body() requestBody: CreateItemRequest,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
public async getWithHeader(
|
||||
@Header('X-Custom-Header') customHeader?: string,
|
||||
): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Accessing Express Request/Response
|
||||
|
||||
```typescript
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
@Post()
|
||||
public async handleRequest(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<...> {
|
||||
const reqLog = request.log; // Pino logger
|
||||
const cookies = request.cookies; // Cookies
|
||||
const ip = request.ip; // Client IP
|
||||
const res = request.res!; // Express response
|
||||
|
||||
// Set cookie
|
||||
res.cookie('name', 'value', { httpOnly: true });
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Response Formatting
|
||||
|
||||
### Standard Success Response
|
||||
|
||||
```typescript
|
||||
// Returns: { "success": true, "data": {...} }
|
||||
return this.success({ id: 1, name: 'Item' });
|
||||
```
|
||||
|
||||
### Created Response (201)
|
||||
|
||||
```typescript
|
||||
// Sets status 201 and returns success response
|
||||
return this.created(newItem);
|
||||
```
|
||||
|
||||
### Paginated Response
|
||||
|
||||
```typescript
|
||||
// Returns: { "success": true, "data": [...], "meta": { "pagination": {...} } }
|
||||
return this.paginated(items, { page: 1, limit: 20, total: 100 });
|
||||
```
|
||||
|
||||
### No Content (204)
|
||||
|
||||
```typescript
|
||||
// Sets status 204 with no body
|
||||
return this.noContent();
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
Prefer throwing errors rather than returning error responses:
|
||||
|
||||
```typescript
|
||||
import { NotFoundError, ValidationError, ForbiddenError } from './base.controller';
|
||||
|
||||
// Throw for not found
|
||||
throw new NotFoundError('Item', id);
|
||||
|
||||
// Throw for validation errors
|
||||
throw new ValidationError([], 'Invalid input');
|
||||
|
||||
// Throw for forbidden
|
||||
throw new ForbiddenError('Admin access required');
|
||||
```
|
||||
|
||||
If you need manual error response:
|
||||
|
||||
```typescript
|
||||
this.setStatus(400);
|
||||
return this.error(this.ErrorCode.BAD_REQUEST, 'Invalid operation', { reason: '...' });
|
||||
```
|
||||
|
||||
## DTOs and Type Definitions
|
||||
|
||||
### Why DTOs?
|
||||
|
||||
tsoa generates OpenAPI specs from TypeScript types. Some types cannot be serialized:
|
||||
|
||||
- Tuples: `[number, number]` (e.g., GeoJSON coordinates)
|
||||
- Complex generics
|
||||
- Circular references
|
||||
|
||||
DTOs flatten these into tsoa-compatible structures.
|
||||
|
||||
### Shared DTOs
|
||||
|
||||
Define shared DTOs in `src/dtos/common.dto.ts`:
|
||||
|
||||
```typescript
|
||||
// src/dtos/common.dto.ts
|
||||
|
||||
/**
|
||||
* Address with flattened coordinates.
|
||||
* GeoJSONPoint uses coordinates: [number, number] which tsoa cannot handle.
|
||||
*/
|
||||
export interface AddressDto {
|
||||
address_id: number;
|
||||
address_line_1: string;
|
||||
city: string;
|
||||
province_state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
// Flattened from GeoJSONPoint.coordinates
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
user_id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Conversion Functions
|
||||
|
||||
Create conversion functions to map domain types to DTOs:
|
||||
|
||||
```typescript
|
||||
// In controller file
|
||||
function toAddressDto(address: Address): AddressDto {
|
||||
return {
|
||||
address_id: address.address_id,
|
||||
address_line_1: address.address_line_1,
|
||||
city: address.city,
|
||||
province_state: address.province_state,
|
||||
postal_code: address.postal_code,
|
||||
country: address.country,
|
||||
latitude: address.location?.coordinates[1] ?? null,
|
||||
longitude: address.location?.coordinates[0] ?? null,
|
||||
created_at: address.created_at,
|
||||
updated_at: address.updated_at,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Important: Avoid Duplicate Type Names
|
||||
|
||||
tsoa requires unique type names across all controllers. If two controllers define an interface with the same name, tsoa will fail.
|
||||
|
||||
**Solution**: Define shared types in `src/dtos/common.dto.ts` and import them.
|
||||
|
||||
## File Uploads
|
||||
|
||||
tsoa supports file uploads via `@UploadedFile` and `@FormField` decorators:
|
||||
|
||||
```typescript
|
||||
import { Post, Route, UploadedFile, FormField, Security } from 'tsoa';
|
||||
import multer from 'multer';
|
||||
|
||||
// Configure multer
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: '/tmp/uploads',
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}-${file.originalname}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
});
|
||||
|
||||
@Route('flyers')
|
||||
@Tags('Flyers')
|
||||
export class FlyerController extends BaseController {
|
||||
/**
|
||||
* Upload a flyer image.
|
||||
* @summary Upload flyer
|
||||
* @param file The flyer image file
|
||||
* @param storeId Associated store ID
|
||||
* @param request Express request
|
||||
*/
|
||||
@Post('upload')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(upload.single('file'))
|
||||
@SuccessResponse(201, 'Flyer uploaded')
|
||||
public async uploadFlyer(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@FormField() storeId?: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerDto>> {
|
||||
const user = request.user as UserProfile;
|
||||
const flyer = await flyerService.processUpload(file, storeId, user.user.user_id);
|
||||
return this.created(flyer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Apply rate limiters using the `@Middlewares` decorator:
|
||||
|
||||
```typescript
|
||||
import { Middlewares } from 'tsoa';
|
||||
import { loginLimiter, registerLimiter } from '../config/rateLimiters';
|
||||
|
||||
@Post('login')
|
||||
@Middlewares(loginLimiter)
|
||||
@SuccessResponse(200, 'Login successful')
|
||||
@Response<ErrorResponse>(429, 'Too many login attempts')
|
||||
public async login(@Body() body: LoginRequest): Promise<...> { ... }
|
||||
|
||||
@Post('register')
|
||||
@Middlewares(registerLimiter)
|
||||
@SuccessResponse(201, 'User registered')
|
||||
@Response<ErrorResponse>(429, 'Too many registration attempts')
|
||||
public async register(@Body() body: RegisterRequest): Promise<...> { ... }
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
Use the error classes from `base.controller.ts`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ForbiddenError,
|
||||
UniqueConstraintError,
|
||||
} from './base.controller';
|
||||
|
||||
// Not found (404)
|
||||
throw new NotFoundError('User', userId);
|
||||
|
||||
// Validation error (400)
|
||||
throw new ValidationError([], 'Invalid email format');
|
||||
|
||||
// Forbidden (403)
|
||||
throw new ForbiddenError('Admin access required');
|
||||
|
||||
// Conflict (409) - e.g., duplicate email
|
||||
throw new UniqueConstraintError('email', 'Email already registered');
|
||||
```
|
||||
|
||||
### Global Error Handler
|
||||
|
||||
Errors are caught by the global error handler in `server.ts` which formats them according to ADR-028:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "User not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
The `tsoaAuthentication.ts` module throws `AuthenticationError` with appropriate HTTP status codes:
|
||||
|
||||
- 401: Missing token, invalid token, expired token
|
||||
- 403: User lacks required role
|
||||
- 500: Server configuration error
|
||||
|
||||
## Testing Controllers
|
||||
|
||||
### Test File Location
|
||||
|
||||
```
|
||||
src/controllers/__tests__/
|
||||
example.controller.test.ts
|
||||
auth.controller.test.ts
|
||||
user.controller.test.ts
|
||||
...
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```typescript
|
||||
// src/controllers/__tests__/example.controller.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExampleController } from '../example.controller';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/exampleService', () => ({
|
||||
exampleService: {
|
||||
listExamples: vi.fn(),
|
||||
getExampleById: vi.fn(),
|
||||
createExample: vi.fn(),
|
||||
deleteExample: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { exampleService } from '../../services/exampleService';
|
||||
|
||||
describe('ExampleController', () => {
|
||||
let controller: ExampleController;
|
||||
|
||||
beforeEach(() => {
|
||||
controller = new ExampleController();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('listExamples', () => {
|
||||
it('should return paginated examples', async () => {
|
||||
const mockItems = [{ id: 1, name: 'Test' }];
|
||||
vi.mocked(exampleService.listExamples).mockResolvedValue({
|
||||
items: mockItems,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listExamples(1, 20);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockItems);
|
||||
expect(result.meta?.pagination).toBeDefined();
|
||||
expect(result.meta?.pagination?.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createExample', () => {
|
||||
it('should create example and return 201', async () => {
|
||||
const mockExample = { id: 1, name: 'New', created_at: '2026-01-01' };
|
||||
vi.mocked(exampleService.createExample).mockResolvedValue(mockExample);
|
||||
|
||||
const mockRequest = {
|
||||
user: { user: { user_id: 'user-123' } },
|
||||
} as any;
|
||||
|
||||
const result = await controller.createExample({ name: 'New' }, mockRequest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockExample);
|
||||
// Note: setStatus is called internally, verify with spy if needed
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Authentication
|
||||
|
||||
```typescript
|
||||
describe('authenticated endpoints', () => {
|
||||
it('should use user from request', async () => {
|
||||
const mockRequest = {
|
||||
user: {
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
role: 'user',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await controller.getProfile(mockRequest);
|
||||
|
||||
expect(result.data.user.user_id).toBe('user-123');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Known Test Limitations
|
||||
|
||||
Some test files have type errors with mock objects that are acceptable:
|
||||
|
||||
```typescript
|
||||
// Type error: 'any' is not assignable to 'Express.Request'
|
||||
// This is acceptable in tests - the mock has the properties we need
|
||||
const mockRequest = { user: mockUser } as any;
|
||||
```
|
||||
|
||||
These type errors do not affect test correctness. The 4603 unit tests and 345 integration tests all pass.
|
||||
|
||||
## Build and Development
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Create or modify controller
|
||||
2. Run `npm run tsoa:spec && npm run tsoa:routes`
|
||||
3. Run `npm run type-check` to verify
|
||||
4. Run tests
|
||||
|
||||
### NPM Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"tsoa:spec": "tsoa spec",
|
||||
"tsoa:routes": "tsoa routes",
|
||||
"prebuild": "npm run tsoa:spec && npm run tsoa:routes",
|
||||
"build": "tsc"
|
||||
}
|
||||
```
|
||||
|
||||
### Watching for Changes
|
||||
|
||||
Currently, tsoa routes must be regenerated manually when controllers change. Consider adding a watch script:
|
||||
|
||||
```bash
|
||||
# In development, regenerate on save
|
||||
npm run tsoa:spec && npm run tsoa:routes
|
||||
```
|
||||
|
||||
### Generated Files
|
||||
|
||||
| File | Regenerate When |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `src/routes/tsoa-generated.ts` | Controller changes |
|
||||
| `src/config/tsoa-spec.json` | Controller changes, DTO changes |
|
||||
|
||||
These files are committed to the repository for faster builds.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Duplicate identifier" Error
|
||||
|
||||
**Problem**: tsoa fails with "Duplicate identifier" for a type.
|
||||
|
||||
**Solution**: Move the type to `src/dtos/common.dto.ts` and import it in all controllers.
|
||||
|
||||
### "Unable to resolve type" Error
|
||||
|
||||
**Problem**: tsoa cannot serialize a complex type (tuples, generics).
|
||||
|
||||
**Solution**: Create a DTO with flattened/simplified structure.
|
||||
|
||||
```typescript
|
||||
// Before: GeoJSONPoint with coordinates: [number, number]
|
||||
// After: AddressDto with latitude, longitude as separate fields
|
||||
```
|
||||
|
||||
### Route Not Found (404)
|
||||
|
||||
**Problem**: New endpoint returns 404.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Ensure controller file matches glob pattern: `src/controllers/**/*.controller.ts`
|
||||
2. Regenerate routes: `npm run tsoa:routes`
|
||||
3. Verify the route is in `src/routes/tsoa-generated.ts`
|
||||
|
||||
### Authentication Not Working
|
||||
|
||||
**Problem**: `request.user` is undefined.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Ensure `@Security('bearerAuth')` decorator is on the method
|
||||
2. Verify `tsoaAuthentication.ts` is correctly configured in `tsoa.json`
|
||||
3. Check the Authorization header format: `Bearer <token>`
|
||||
|
||||
### Type Mismatch in Tests
|
||||
|
||||
**Problem**: TypeScript errors when mocking Express.Request.
|
||||
|
||||
**Solution**: Use `as any` cast for mock objects in tests. This is acceptable and does not affect test correctness.
|
||||
|
||||
```typescript
|
||||
const mockRequest = {
|
||||
user: mockUserProfile,
|
||||
log: mockLogger,
|
||||
} as any;
|
||||
```
|
||||
|
||||
## Migration Lessons Learned
|
||||
|
||||
### What Worked Well
|
||||
|
||||
1. **BaseController Pattern**: Provides consistent response formatting and familiar helpers
|
||||
2. **Incremental Migration**: Controllers can be migrated one at a time
|
||||
3. **Type-First Design**: Defining request/response types first makes implementation clearer
|
||||
4. **Shared DTOs**: Centralizing DTOs in `common.dto.ts` prevents duplicate type errors
|
||||
|
||||
### Challenges Encountered
|
||||
|
||||
1. **Tuple Types**: tsoa cannot serialize TypeScript tuples. Solution: Flatten to separate fields.
|
||||
2. **Passport Integration**: OAuth callbacks use redirect-based flows that don't fit tsoa's JSON model. Solution: Keep OAuth callbacks in Express routes.
|
||||
3. **Test Type Errors**: Mock objects don't perfectly match Express types. Solution: Accept `as any` casts in tests.
|
||||
4. **Build Pipeline**: Must regenerate routes when controllers change. Solution: Add to prebuild script.
|
||||
|
||||
### Recommendations for Future Controllers
|
||||
|
||||
1. Start with the DTO/request/response types
|
||||
2. Use `@SuccessResponse` and `@Response` decorators for all status codes
|
||||
3. Add JSDoc comments for OpenAPI descriptions
|
||||
4. Keep controller methods thin - delegate to service layer
|
||||
5. Test controllers in isolation by mocking services
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR-018: API Documentation Strategy](../adr/0018-api-documentation-strategy.md)
|
||||
- [ADR-059: Dependency Modernization](../adr/0059-dependency-modernization.md)
|
||||
- [ADR-028: API Response Standardization](../adr/0028-api-response-standardization.md)
|
||||
- [CODE-PATTERNS.md](./CODE-PATTERNS.md)
|
||||
- [TESTING.md](./TESTING.md)
|
||||
- [tsoa Documentation](https://tsoa-community.github.io/docs/)
|
||||
6090
package-lock.json
generated
6090
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.14.1",
|
||||
"version": "0.14.2",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
"dev:container": "concurrently \"npm:start:dev\" \"vite --host\"",
|
||||
@@ -24,14 +27,17 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"validate": "(prettier --check . || true) && npm run type-check && (npm run lint || true)",
|
||||
"clean": "rimraf coverage .coverage",
|
||||
"clean": "node scripts/clean.mjs",
|
||||
"start:dev": "NODE_ENV=development tsx watch server.ts",
|
||||
"start:prod": "NODE_ENV=production tsx server.ts",
|
||||
"start:test": "NODE_ENV=test NODE_V8_COVERAGE=.coverage/tmp/integration-server tsx server.ts",
|
||||
"db:reset:dev": "NODE_ENV=development tsx src/db/seed.ts",
|
||||
"db:reset:test": "NODE_ENV=test tsx src/db/seed.ts",
|
||||
"worker:prod": "NODE_ENV=production tsx src/services/queueService.server.ts",
|
||||
"prepare": "node -e \"try { require.resolve('husky') } catch (e) { process.exit(0) }\" && husky || true"
|
||||
"prepare": "node -e \"try { require.resolve('husky') } catch (e) { process.exit(0) }\" && husky || true",
|
||||
"tsoa:spec": "tsoa spec",
|
||||
"tsoa:routes": "tsoa routes",
|
||||
"tsoa:build": "tsoa spec-and-routes"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
@@ -74,8 +80,8 @@
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1",
|
||||
"sharp": "^0.34.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tsoa": "^6.6.0",
|
||||
"tsx": "^4.20.6",
|
||||
"zod": "^4.2.1",
|
||||
"zxcvbn": "^4.4.2",
|
||||
@@ -110,7 +116,6 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
@@ -139,7 +144,6 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.3.2",
|
||||
"rimraf": "^6.1.2",
|
||||
"supertest": "^7.1.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"testcontainers": "^11.8.1",
|
||||
|
||||
70
scripts/clean.mjs
Normal file
70
scripts/clean.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Clean script to remove coverage directories.
|
||||
* Replaces rimraf dependency with native Node.js fs.rm API.
|
||||
*
|
||||
* Usage: node scripts/clean.mjs
|
||||
*
|
||||
* Behavior matches rimraf: errors are logged but script exits successfully.
|
||||
* This allows build pipelines to continue even if directories don't exist.
|
||||
*/
|
||||
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Directories to clean, relative to project root.
|
||||
* Add additional directories here as needed.
|
||||
*/
|
||||
const DIRECTORIES_TO_CLEAN = ['coverage', '.coverage'];
|
||||
|
||||
/**
|
||||
* Removes a directory recursively, handling errors gracefully.
|
||||
*
|
||||
* @param {string} dirPath - Absolute path to the directory to remove
|
||||
* @returns {Promise<boolean>} - True if removed successfully, false if error occurred
|
||||
*/
|
||||
async function removeDirectory(dirPath) {
|
||||
try {
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
console.log(`Removed: ${dirPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Log error but don't fail - matches rimraf behavior
|
||||
// force: true should handle ENOENT, but log other errors
|
||||
console.error(`Warning: Could not remove ${dirPath}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point. Cleans all configured directories.
|
||||
*/
|
||||
async function main() {
|
||||
// Get project root (parent of scripts directory)
|
||||
const projectRoot = resolve(import.meta.dirname, '..');
|
||||
|
||||
console.log('Cleaning coverage directories...');
|
||||
|
||||
const results = await Promise.all(
|
||||
DIRECTORIES_TO_CLEAN.map((dir) => {
|
||||
const absolutePath = resolve(projectRoot, dir);
|
||||
return removeDirectory(absolutePath);
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter(Boolean).length;
|
||||
console.log(
|
||||
`Clean complete: ${successCount}/${DIRECTORIES_TO_CLEAN.length} directories processed.`
|
||||
);
|
||||
|
||||
// Always exit successfully (matches rimraf behavior)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// Catch any unexpected errors in main
|
||||
console.error('Unexpected error during clean:', error.message);
|
||||
// Still exit successfully to not break build pipelines
|
||||
process.exit(0);
|
||||
});
|
||||
34
server.ts
34
server.ts
@@ -25,9 +25,12 @@ import { backgroundJobService, startBackgroundJobs } from './src/services/backgr
|
||||
import { websocketService } from './src/services/websocketService.server';
|
||||
import type { UserProfile } from './src/types';
|
||||
|
||||
// API Documentation (ADR-018)
|
||||
// API Documentation (ADR-018) - tsoa-generated OpenAPI spec
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { swaggerSpec } from './src/config/swagger';
|
||||
import tsoaSpec from './src/config/tsoa-spec.json' with { type: 'json' };
|
||||
|
||||
// tsoa-generated routes
|
||||
import { RegisterRoutes } from './src/routes/tsoa-generated';
|
||||
import {
|
||||
analyticsQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
@@ -197,11 +200,13 @@ if (!process.env.JWT_SECRET) {
|
||||
|
||||
// --- API Documentation (ADR-018) ---
|
||||
// Only serve Swagger UI in non-production environments to prevent information disclosure.
|
||||
// Uses tsoa-generated OpenAPI specification.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Serve tsoa-generated OpenAPI documentation
|
||||
app.use(
|
||||
'/docs/api-docs',
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(swaggerSpec, {
|
||||
swaggerUi.setup(tsoaSpec, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'Flyer Crawler API Documentation',
|
||||
}),
|
||||
@@ -210,7 +215,7 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
// Expose raw OpenAPI JSON spec for tooling (SDK generation, testing, etc.)
|
||||
app.get('/docs/api-docs.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
res.send(tsoaSpec);
|
||||
});
|
||||
|
||||
logger.info('API Documentation available at /docs/api-docs');
|
||||
@@ -230,12 +235,27 @@ app.get('/api/v1/health/queues', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- tsoa-generated Routes ---
|
||||
// Register routes generated by tsoa from controllers.
|
||||
// These routes run in parallel with existing routes during migration.
|
||||
// tsoa routes are mounted directly on the app (basePath in tsoa.json is '/api').
|
||||
// The RegisterRoutes function adds routes at /api/health/*, /api/_tsoa/*, etc.
|
||||
//
|
||||
// IMPORTANT: tsoa routes are registered BEFORE the backwards compatibility redirect
|
||||
// middleware so that tsoa routes (like /api/health/ping, /api/_tsoa/verify) are
|
||||
// matched directly without being redirected to /api/v1/*.
|
||||
// During migration, both tsoa routes and versioned routes coexist:
|
||||
// - /api/health/ping -> handled by tsoa HealthController
|
||||
// - /api/v1/health/ping -> handled by versioned health.routes.ts
|
||||
// As controllers are migrated, the versioned routes will be removed.
|
||||
RegisterRoutes(app);
|
||||
|
||||
// --- Backwards Compatibility Redirect (ADR-008: API Versioning Strategy) ---
|
||||
// Redirect old /api/* paths to /api/v1/* for backwards compatibility.
|
||||
// This allows clients to gradually migrate to the versioned API.
|
||||
// IMPORTANT: This middleware MUST be mounted BEFORE createApiRouter() so that
|
||||
// unversioned paths like /api/users are redirected to /api/v1/users BEFORE
|
||||
// the versioned router's detectApiVersion middleware rejects them as invalid versions.
|
||||
// IMPORTANT: This middleware MUST be mounted:
|
||||
// - AFTER tsoa routes (so tsoa routes are matched directly)
|
||||
// - BEFORE createApiRouter() (so unversioned paths are redirected to /api/v1/*)
|
||||
app.use('/api', (req, res, next) => {
|
||||
// Check if the path starts with a version-like prefix (/v followed by digits).
|
||||
// This includes both supported versions (v1, v2) and unsupported ones (v99).
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// src/config/swagger.test.ts
|
||||
/**
|
||||
* Tests for tsoa-generated OpenAPI specification.
|
||||
*
|
||||
* These tests verify the tsoa specification structure and content
|
||||
* as generated from controllers decorated with tsoa decorators.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { swaggerSpec } from './swagger';
|
||||
import tsoaSpec from './tsoa-spec.json';
|
||||
|
||||
// Type definition for OpenAPI 3.0 spec structure used in tests
|
||||
interface OpenAPISpec {
|
||||
@@ -10,18 +16,11 @@ interface OpenAPISpec {
|
||||
version: string;
|
||||
description?: string;
|
||||
contact?: { name: string };
|
||||
license?: { name: string };
|
||||
license?: { name: string | { name: string } };
|
||||
};
|
||||
servers: Array<{ url: string; description?: string }>;
|
||||
components: {
|
||||
securitySchemes?: {
|
||||
bearerAuth?: {
|
||||
type: string;
|
||||
scheme: string;
|
||||
bearerFormat?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
securitySchemes?: Record<string, unknown>;
|
||||
schemas?: Record<string, unknown>;
|
||||
};
|
||||
tags: Array<{ name: string; description?: string }>;
|
||||
@@ -29,19 +28,13 @@ interface OpenAPISpec {
|
||||
}
|
||||
|
||||
// Cast to typed spec for property access
|
||||
const spec = swaggerSpec as OpenAPISpec;
|
||||
const spec = tsoaSpec as unknown as OpenAPISpec;
|
||||
|
||||
/**
|
||||
* Tests for src/config/swagger.ts - OpenAPI/Swagger configuration.
|
||||
*
|
||||
* These tests verify the swagger specification structure and content
|
||||
* without testing the swagger-jsdoc library itself.
|
||||
*/
|
||||
describe('swagger configuration', () => {
|
||||
describe('swaggerSpec export', () => {
|
||||
describe('tsoa OpenAPI specification', () => {
|
||||
describe('spec export', () => {
|
||||
it('should export a swagger specification object', () => {
|
||||
expect(swaggerSpec).toBeDefined();
|
||||
expect(typeof swaggerSpec).toBe('object');
|
||||
expect(tsoaSpec).toBeDefined();
|
||||
expect(typeof tsoaSpec).toBe('object');
|
||||
});
|
||||
|
||||
it('should have openapi version 3.0.0', () => {
|
||||
@@ -63,12 +56,11 @@ describe('swagger configuration', () => {
|
||||
|
||||
it('should have contact information', () => {
|
||||
expect(spec.info.contact).toBeDefined();
|
||||
expect(spec.info.contact?.name).toBe('API Support');
|
||||
expect(spec.info.contact?.name).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have license information', () => {
|
||||
expect(spec.info.license).toBeDefined();
|
||||
expect(spec.info.license?.name).toBe('Private');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,10 +71,9 @@ describe('swagger configuration', () => {
|
||||
expect(spec.servers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have /api/v1 as the server URL (ADR-008)', () => {
|
||||
const apiServer = spec.servers.find((s) => s.url === '/api/v1');
|
||||
it('should have /api as the server URL (tsoa basePath)', () => {
|
||||
const apiServer = spec.servers.find((s) => s.url === '/api');
|
||||
expect(apiServer).toBeDefined();
|
||||
expect(apiServer?.description).toBe('API server (v1)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,96 +82,42 @@ describe('swagger configuration', () => {
|
||||
expect(spec.components).toBeDefined();
|
||||
});
|
||||
|
||||
describe('securitySchemes', () => {
|
||||
it('should have bearerAuth security scheme', () => {
|
||||
expect(spec.components.securitySchemes).toBeDefined();
|
||||
expect(spec.components.securitySchemes?.bearerAuth).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure bearerAuth as HTTP bearer with JWT format', () => {
|
||||
const bearerAuth = spec.components.securitySchemes?.bearerAuth;
|
||||
expect(bearerAuth?.type).toBe('http');
|
||||
expect(bearerAuth?.scheme).toBe('bearer');
|
||||
expect(bearerAuth?.bearerFormat).toBe('JWT');
|
||||
});
|
||||
|
||||
it('should have description for bearerAuth', () => {
|
||||
const bearerAuth = spec.components.securitySchemes?.bearerAuth;
|
||||
expect(bearerAuth?.description).toContain('JWT token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas', () => {
|
||||
const schemas = () => spec.components.schemas as Record<string, any>;
|
||||
const schemas = () => spec.components.schemas as Record<string, unknown>;
|
||||
|
||||
it('should have schemas object', () => {
|
||||
expect(spec.components.schemas).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have SuccessResponse schema (ADR-028)', () => {
|
||||
const schema = schemas().SuccessResponse;
|
||||
it('should have PaginationMeta schema (ADR-028)', () => {
|
||||
const schema = schemas().PaginationMeta as Record<string, unknown>;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.success).toBeDefined();
|
||||
expect(schema.properties.data).toBeDefined();
|
||||
expect(schema.required).toContain('success');
|
||||
expect(schema.required).toContain('data');
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(properties.page).toBeDefined();
|
||||
expect(properties.limit).toBeDefined();
|
||||
expect(properties.total).toBeDefined();
|
||||
expect(properties.totalPages).toBeDefined();
|
||||
expect(properties.hasNextPage).toBeDefined();
|
||||
expect(properties.hasPrevPage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have ErrorResponse schema (ADR-028)', () => {
|
||||
const schema = schemas().ErrorResponse;
|
||||
it('should have ResponseMeta schema', () => {
|
||||
const schema = schemas().ResponseMeta as Record<string, unknown>;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.success).toBeDefined();
|
||||
expect(schema.properties.error).toBeDefined();
|
||||
expect(schema.required).toContain('success');
|
||||
expect(schema.required).toContain('error');
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(properties.requestId).toBeDefined();
|
||||
expect(properties.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have ErrorResponse error object with code and message', () => {
|
||||
const errorSchema = schemas().ErrorResponse.properties.error;
|
||||
expect(errorSchema.properties.code).toBeDefined();
|
||||
expect(errorSchema.properties.message).toBeDefined();
|
||||
expect(errorSchema.required).toContain('code');
|
||||
expect(errorSchema.required).toContain('message');
|
||||
});
|
||||
|
||||
it('should have ServiceHealth schema', () => {
|
||||
const schema = schemas().ServiceHealth;
|
||||
it('should have ErrorDetails schema for error responses (ADR-028)', () => {
|
||||
const schema = schemas().ErrorDetails as Record<string, unknown>;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.status).toBeDefined();
|
||||
expect(schema.properties.status.enum).toContain('healthy');
|
||||
expect(schema.properties.status.enum).toContain('degraded');
|
||||
expect(schema.properties.status.enum).toContain('unhealthy');
|
||||
});
|
||||
|
||||
it('should have Achievement schema', () => {
|
||||
const schema = schemas().Achievement;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.achievement_id).toBeDefined();
|
||||
expect(schema.properties.name).toBeDefined();
|
||||
expect(schema.properties.description).toBeDefined();
|
||||
expect(schema.properties.icon).toBeDefined();
|
||||
expect(schema.properties.points_value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have UserAchievement schema extending Achievement', () => {
|
||||
const schema = schemas().UserAchievement;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.allOf).toBeDefined();
|
||||
expect(schema.allOf[0].$ref).toBe('#/components/schemas/Achievement');
|
||||
});
|
||||
|
||||
it('should have LeaderboardUser schema', () => {
|
||||
const schema = schemas().LeaderboardUser;
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties.user_id).toBeDefined();
|
||||
expect(schema.properties.full_name).toBeDefined();
|
||||
expect(schema.properties.points).toBeDefined();
|
||||
expect(schema.properties.rank).toBeDefined();
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(properties.code).toBeDefined();
|
||||
expect(properties.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -194,7 +131,7 @@ describe('swagger configuration', () => {
|
||||
it('should have Health tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Health');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('health');
|
||||
expect(tag?.description).toContain('Health');
|
||||
});
|
||||
|
||||
it('should have Auth tag', () => {
|
||||
@@ -206,13 +143,6 @@ describe('swagger configuration', () => {
|
||||
it('should have Users tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Users');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('User');
|
||||
});
|
||||
|
||||
it('should have Achievements tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Achievements');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('Gamification');
|
||||
});
|
||||
|
||||
it('should have Flyers tag', () => {
|
||||
@@ -220,45 +150,37 @@ describe('swagger configuration', () => {
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Recipes tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Recipes');
|
||||
it('should have Deals tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Deals');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Budgets tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Budgets');
|
||||
it('should have Stores tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Stores');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have Admin tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'Admin');
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag?.description).toContain('admin');
|
||||
describe('paths section', () => {
|
||||
it('should have paths object with endpoints', () => {
|
||||
expect(spec.paths).toBeDefined();
|
||||
expect(typeof spec.paths).toBe('object');
|
||||
expect(Object.keys(spec.paths as object).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have System tag', () => {
|
||||
const tag = spec.tags.find((t) => t.name === 'System');
|
||||
expect(tag).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have 9 tags total', () => {
|
||||
expect(spec.tags.length).toBe(9);
|
||||
it('should have health ping endpoint', () => {
|
||||
const paths = spec.paths as Record<string, unknown>;
|
||||
expect(paths['/health/ping']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('specification validity', () => {
|
||||
it('should have paths object (may be empty if no JSDoc annotations parsed)', () => {
|
||||
// swagger-jsdoc creates paths from JSDoc annotations in route files
|
||||
// In test environment, this may be empty if routes aren't scanned
|
||||
expect(swaggerSpec).toHaveProperty('paths');
|
||||
});
|
||||
|
||||
it('should be a valid JSON-serializable object', () => {
|
||||
expect(() => JSON.stringify(swaggerSpec)).not.toThrow();
|
||||
expect(() => JSON.stringify(tsoaSpec)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce valid JSON output', () => {
|
||||
const json = JSON.stringify(swaggerSpec);
|
||||
const json = JSON.stringify(tsoaSpec);
|
||||
expect(() => JSON.parse(json)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// src/config/swagger.ts
|
||||
/**
|
||||
* @file OpenAPI/Swagger configuration for API documentation.
|
||||
* Implements ADR-018: API Documentation Strategy.
|
||||
*
|
||||
* This file configures swagger-jsdoc to generate an OpenAPI 3.0 specification
|
||||
* from JSDoc annotations in route files. The specification is used by
|
||||
* swagger-ui-express to serve interactive API documentation.
|
||||
*/
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Flyer Crawler API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'API for the Flyer Crawler application - a platform for discovering grocery deals, managing recipes, and tracking budgets.',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
},
|
||||
license: {
|
||||
name: 'Private',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api/v1',
|
||||
description: 'API server (v1)',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT token obtained from /auth/login or /auth/register',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
// Standard success response wrapper (ADR-028)
|
||||
SuccessResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Response payload - structure varies by endpoint',
|
||||
},
|
||||
},
|
||||
required: ['success', 'data'],
|
||||
},
|
||||
// Standard error response wrapper (ADR-028)
|
||||
ErrorResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
},
|
||||
error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description: 'Machine-readable error code',
|
||||
example: 'VALIDATION_ERROR',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Human-readable error message',
|
||||
example: 'Invalid request parameters',
|
||||
},
|
||||
},
|
||||
required: ['code', 'message'],
|
||||
},
|
||||
},
|
||||
required: ['success', 'error'],
|
||||
},
|
||||
// Common service health status
|
||||
ServiceHealth: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['healthy', 'degraded', 'unhealthy'],
|
||||
},
|
||||
latency: {
|
||||
type: 'number',
|
||||
description: 'Response time in milliseconds',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Additional status information',
|
||||
},
|
||||
details: {
|
||||
type: 'object',
|
||||
description: 'Service-specific details',
|
||||
},
|
||||
},
|
||||
required: ['status'],
|
||||
},
|
||||
// Achievement schema
|
||||
Achievement: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
achievement_id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
example: 'First-Upload',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
example: 'Upload your first flyer',
|
||||
},
|
||||
icon: {
|
||||
type: 'string',
|
||||
example: 'upload-cloud',
|
||||
},
|
||||
points_value: {
|
||||
type: 'integer',
|
||||
example: 25,
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
},
|
||||
// User achievement (with achieved_at)
|
||||
UserAchievement: {
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/Achievement' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
achieved_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Leaderboard entry
|
||||
LeaderboardUser: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
full_name: {
|
||||
type: 'string',
|
||||
example: 'John Doe',
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
points: {
|
||||
type: 'integer',
|
||||
example: 150,
|
||||
},
|
||||
rank: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Health',
|
||||
description: 'Server health and readiness checks',
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
description: 'Authentication and authorization',
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
description: 'User profile management',
|
||||
},
|
||||
{
|
||||
name: 'Achievements',
|
||||
description: 'Gamification and leaderboards',
|
||||
},
|
||||
{
|
||||
name: 'Flyers',
|
||||
description: 'Flyer uploads and retrieval',
|
||||
},
|
||||
{
|
||||
name: 'Recipes',
|
||||
description: 'Recipe management',
|
||||
},
|
||||
{
|
||||
name: 'Budgets',
|
||||
description: 'Budget tracking and analysis',
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
description: 'Administrative operations (requires admin role)',
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
description: 'System status and monitoring',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Path to the API routes files with JSDoc annotations
|
||||
apis: ['./src/routes/*.ts'],
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
420
src/controllers/README.md
Normal file
420
src/controllers/README.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# tsoa Controller Standards
|
||||
|
||||
This document defines the coding standards and patterns for implementing tsoa controllers in the Flyer Crawler API.
|
||||
|
||||
## Overview
|
||||
|
||||
Controllers are the API layer that handles HTTP requests and responses. They use [tsoa](https://tsoa-community.github.io/docs/) decorators to define routes and generate OpenAPI specifications automatically.
|
||||
|
||||
**Key Principles:**
|
||||
|
||||
- Controllers handle HTTP concerns (parsing requests, formatting responses)
|
||||
- Business logic belongs in the service layer
|
||||
- All controllers extend `BaseController` for consistent response formatting
|
||||
- Response formats follow ADR-028 (API Response Standards)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Route, Get, Post, Tags, Body, Path, Query, Security, Request } from 'tsoa';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse,
|
||||
PaginatedResponse,
|
||||
RequestContext,
|
||||
} from './base.controller';
|
||||
import { userService } from '../services/userService';
|
||||
import type { User, CreateUserRequest } from '../types';
|
||||
|
||||
@Route('users')
|
||||
@Tags('Users')
|
||||
export class UsersController extends BaseController {
|
||||
/**
|
||||
* Get a user by ID.
|
||||
* @param id The user's unique identifier
|
||||
*/
|
||||
@Get('{id}')
|
||||
@Security('jwt')
|
||||
public async getUser(
|
||||
@Path() id: string,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<SuccessResponse<User>> {
|
||||
ctx.logger.info({ userId: id }, 'Fetching user');
|
||||
const user = await userService.getUserById(id, ctx.logger);
|
||||
return this.success(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users with pagination.
|
||||
*/
|
||||
@Get()
|
||||
@Security('jwt', ['admin'])
|
||||
public async listUsers(
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number,
|
||||
@Request() ctx?: RequestContext,
|
||||
): Promise<PaginatedResponse<User>> {
|
||||
const { page: p, limit: l } = this.normalizePagination(page, limit);
|
||||
const { users, total } = await userService.listUsers({ page: p, limit: l }, ctx?.logger);
|
||||
return this.paginated(users, { page: p, limit: l, total });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user.
|
||||
*/
|
||||
@Post()
|
||||
@Security('jwt', ['admin'])
|
||||
public async createUser(
|
||||
@Body() body: CreateUserRequest,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<SuccessResponse<User>> {
|
||||
const user = await userService.createUser(body, ctx.logger);
|
||||
return this.created(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/controllers/
|
||||
├── base.controller.ts # Base class with response helpers
|
||||
├── types.ts # Shared types for controllers
|
||||
├── README.md # This file
|
||||
├── health.controller.ts # Health check endpoints
|
||||
├── auth.controller.ts # Authentication endpoints
|
||||
├── users.controller.ts # User management endpoints
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
All responses follow ADR-028:
|
||||
|
||||
### Success Response
|
||||
|
||||
```typescript
|
||||
interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta?: {
|
||||
requestId?: string;
|
||||
timestamp?: string;
|
||||
pagination?: PaginationMeta;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```typescript
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string; // e.g., 'NOT_FOUND', 'VALIDATION_ERROR'
|
||||
message: string; // Human-readable message
|
||||
details?: unknown; // Additional error details
|
||||
};
|
||||
meta?: {
|
||||
requestId?: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Paginated Response
|
||||
|
||||
```typescript
|
||||
interface PaginatedResponse<T> {
|
||||
success: true;
|
||||
data: T[];
|
||||
meta: {
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## BaseController Methods
|
||||
|
||||
### Response Helpers
|
||||
|
||||
| Method | Description | HTTP Status |
|
||||
| ------------------------------------ | --------------------------- | ----------- |
|
||||
| `success(data, meta?)` | Standard success response | 200 |
|
||||
| `created(data, meta?)` | Resource created | 201 |
|
||||
| `noContent()` | Success with no body | 204 |
|
||||
| `paginated(data, pagination, meta?)` | Paginated list response | 200 |
|
||||
| `message(msg)` | Success with just a message | 200 |
|
||||
|
||||
### Pagination Helpers
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------------ | ----------------------------------------------------- |
|
||||
| `normalizePagination(page?, limit?)` | Apply defaults and bounds (page=1, limit=20, max=100) |
|
||||
| `calculatePagination(input)` | Calculate totalPages, hasNextPage, etc. |
|
||||
|
||||
### Error Handling
|
||||
|
||||
Controllers should throw typed errors rather than constructing error responses manually:
|
||||
|
||||
```typescript
|
||||
import { NotFoundError, ForbiddenError, ValidationError } from './base.controller';
|
||||
|
||||
// Throw when resource not found
|
||||
throw new NotFoundError('User not found');
|
||||
|
||||
// Throw when access denied
|
||||
throw new ForbiddenError('Cannot access this resource');
|
||||
|
||||
// Throw for validation errors (from Zod)
|
||||
throw new ValidationError(zodError.issues);
|
||||
```
|
||||
|
||||
The global error handler converts these to proper HTTP responses.
|
||||
|
||||
## tsoa Decorators
|
||||
|
||||
### Route Decorators
|
||||
|
||||
| Decorator | Description |
|
||||
| ------------------------------------------------------ | ----------------------------------------- |
|
||||
| `@Route('path')` | Base path for all endpoints in controller |
|
||||
| `@Tags('TagName')` | OpenAPI tag for grouping |
|
||||
| `@Get()`, `@Post()`, `@Put()`, `@Patch()`, `@Delete()` | HTTP methods |
|
||||
| `@Security('jwt')` | Require authentication |
|
||||
| `@Security('jwt', ['admin'])` | Require specific roles |
|
||||
|
||||
### Parameter Decorators
|
||||
|
||||
| Decorator | Description | Example |
|
||||
| ------------ | ---------------------- | ------------------------------------- |
|
||||
| `@Path()` | URL path parameter | `@Path() id: string` |
|
||||
| `@Query()` | Query string parameter | `@Query() search?: string` |
|
||||
| `@Body()` | Request body | `@Body() data: CreateUserRequest` |
|
||||
| `@Header()` | Request header | `@Header('X-Custom') custom?: string` |
|
||||
| `@Request()` | Full request context | `@Request() ctx: RequestContext` |
|
||||
|
||||
### Response Decorators
|
||||
|
||||
| Decorator | Description |
|
||||
| -------------------------------------------- | -------------------------------- |
|
||||
| `@Response<ErrorResponse>(404, 'Not Found')` | Document possible error response |
|
||||
| `@SuccessResponse(201, 'Created')` | Document success response |
|
||||
|
||||
## RequestContext
|
||||
|
||||
The `RequestContext` interface provides access to request-scoped resources:
|
||||
|
||||
```typescript
|
||||
interface RequestContext {
|
||||
logger: Logger; // Request-scoped Pino logger (ADR-004)
|
||||
requestId: string; // Unique request ID for correlation
|
||||
user?: AuthenticatedUser; // Authenticated user (from JWT)
|
||||
dbClient?: PoolClient; // Database client for transactions
|
||||
}
|
||||
|
||||
interface AuthenticatedUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Controller Class Names
|
||||
|
||||
- PascalCase with `Controller` suffix
|
||||
- Example: `UsersController`, `HealthController`, `AuthController`
|
||||
|
||||
### Method Names
|
||||
|
||||
- camelCase, describing the action
|
||||
- Use verbs: `getUser`, `listUsers`, `createUser`, `updateUser`, `deleteUser`
|
||||
|
||||
### File Names
|
||||
|
||||
- kebab-case with `.controller.ts` suffix
|
||||
- Example: `users.controller.ts`, `health.controller.ts`
|
||||
|
||||
## Service Layer Integration
|
||||
|
||||
Controllers delegate to the service layer for business logic:
|
||||
|
||||
```typescript
|
||||
@Route('flyers')
|
||||
@Tags('Flyers')
|
||||
export class FlyersController extends BaseController {
|
||||
@Get('{id}')
|
||||
public async getFlyer(
|
||||
@Path() id: number,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<SuccessResponse<Flyer>> {
|
||||
// Service handles business logic and database access
|
||||
const flyer = await flyerService.getFlyerById(id, ctx.logger);
|
||||
// Controller only formats the response
|
||||
return this.success(flyer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Never access repositories directly from controllers
|
||||
2. Always pass the logger to service methods
|
||||
3. Let services throw domain errors (NotFoundError, etc.)
|
||||
4. Controllers catch and transform errors only when needed
|
||||
|
||||
## Validation
|
||||
|
||||
tsoa performs automatic validation based on TypeScript types. For complex validation, define request types:
|
||||
|
||||
```typescript
|
||||
// In types.ts or a dedicated types file
|
||||
interface CreateUserRequest {
|
||||
/**
|
||||
* User's email address
|
||||
* @format email
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* User's password
|
||||
* @minLength 8
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* Full name (optional)
|
||||
*/
|
||||
fullName?: string;
|
||||
}
|
||||
|
||||
// In controller
|
||||
@Post()
|
||||
public async createUser(@Body() body: CreateUserRequest): Promise<SuccessResponse<User>> {
|
||||
// body is already validated by tsoa
|
||||
return this.created(await userService.createUser(body));
|
||||
}
|
||||
```
|
||||
|
||||
For additional runtime validation (e.g., database constraints), use Zod in the service layer.
|
||||
|
||||
## Error Handling Examples
|
||||
|
||||
### Not Found
|
||||
|
||||
```typescript
|
||||
@Get('{id}')
|
||||
public async getUser(@Path() id: string): Promise<SuccessResponse<User>> {
|
||||
const user = await userService.findUserById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundError(`User with ID ${id} not found`);
|
||||
}
|
||||
return this.success(user);
|
||||
}
|
||||
```
|
||||
|
||||
### Authorization
|
||||
|
||||
```typescript
|
||||
@Delete('{id}')
|
||||
@Security('jwt')
|
||||
public async deleteUser(
|
||||
@Path() id: string,
|
||||
@Request() ctx: RequestContext,
|
||||
): Promise<void> {
|
||||
// Only allow users to delete their own account (or admins)
|
||||
if (ctx.user?.userId !== id && !ctx.user?.roles?.includes('admin')) {
|
||||
throw new ForbiddenError('Cannot delete another user account');
|
||||
}
|
||||
await userService.deleteUser(id, ctx.logger);
|
||||
return this.noContent();
|
||||
}
|
||||
```
|
||||
|
||||
### Conflict (Duplicate)
|
||||
|
||||
```typescript
|
||||
@Post()
|
||||
public async createUser(@Body() body: CreateUserRequest): Promise<SuccessResponse<User>> {
|
||||
try {
|
||||
const user = await userService.createUser(body);
|
||||
return this.created(user);
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
this.setStatus(409);
|
||||
throw error; // Let error handler format the response
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Controllers
|
||||
|
||||
Controllers should be tested via integration tests that verify the full HTTP request/response cycle:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../app';
|
||||
|
||||
describe('UsersController', () => {
|
||||
describe('GET /api/v1/users/:id', () => {
|
||||
it('should return user when found', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/users/123')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: expect.objectContaining({
|
||||
user_id: '123',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when user not found', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/users/nonexistent')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Migration from Express Routes
|
||||
|
||||
When migrating existing Express routes to tsoa controllers:
|
||||
|
||||
1. Create the controller class extending `BaseController`
|
||||
2. Add route decorators matching existing paths
|
||||
3. Move request handling logic (keep business logic in services)
|
||||
4. Replace `sendSuccess`/`sendError` with `this.success()`/throwing errors
|
||||
5. Update tests to use the new paths
|
||||
6. Remove the old Express route file
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR-028: API Response Standards](../../docs/adr/ADR-028-api-response-standards.md)
|
||||
- [ADR-004: Request-Scoped Logging](../../docs/adr/0004-request-scoped-logging.md)
|
||||
- [CODE-PATTERNS.md](../../docs/development/CODE-PATTERNS.md)
|
||||
- [tsoa Documentation](https://tsoa-community.github.io/docs/)
|
||||
862
src/controllers/admin.controller.test.ts
Normal file
862
src/controllers/admin.controller.test.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
// src/controllers/admin.controller.test.ts
|
||||
// ============================================================================
|
||||
// ADMIN CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the AdminController class. These tests verify controller
|
||||
// logic in isolation by mocking database repositories, services, and queues.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database repositories
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
adminRepo: {
|
||||
getSuggestedCorrections: vi.fn(),
|
||||
approveCorrection: vi.fn(),
|
||||
rejectCorrection: vi.fn(),
|
||||
updateSuggestedCorrection: vi.fn(),
|
||||
getAllUsers: vi.fn(),
|
||||
updateUserRole: vi.fn(),
|
||||
updateRecipeStatus: vi.fn(),
|
||||
updateRecipeCommentStatus: vi.fn(),
|
||||
getFlyersForReview: vi.fn(),
|
||||
getUnmatchedFlyerItems: vi.fn(),
|
||||
getApplicationStats: vi.fn(),
|
||||
getDailyStatsForLast30Days: vi.fn(),
|
||||
getActivityLog: vi.fn(),
|
||||
},
|
||||
userRepo: {
|
||||
findUserProfileById: vi.fn(),
|
||||
},
|
||||
flyerRepo: {
|
||||
deleteFlyer: vi.fn(),
|
||||
getAllBrands: vi.fn(),
|
||||
},
|
||||
recipeRepo: {
|
||||
deleteRecipe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock services
|
||||
vi.mock('../services/backgroundJobService', () => ({
|
||||
backgroundJobService: {
|
||||
runDailyDealCheck: vi.fn(),
|
||||
triggerAnalyticsReport: vi.fn(),
|
||||
triggerWeeklyAnalyticsReport: vi.fn(),
|
||||
triggerTokenCleanup: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/monitoringService.server', () => ({
|
||||
monitoringService: {
|
||||
getWorkerStatuses: vi.fn(),
|
||||
getQueueStatuses: vi.fn(),
|
||||
retryFailedJob: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/geocodingService.server', () => ({
|
||||
geocodingService: {
|
||||
clearGeocodeCache: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/cacheService.server', () => ({
|
||||
cacheService: {
|
||||
invalidateFlyers: vi.fn(),
|
||||
invalidateBrands: vi.fn(),
|
||||
invalidateStats: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/brandService', () => ({
|
||||
brandService: {
|
||||
updateBrandLogo: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/userService', () => ({
|
||||
userService: {
|
||||
deleteUserAsAdmin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/featureFlags.server', () => ({
|
||||
getFeatureFlags: vi.fn(),
|
||||
FeatureFlagName: {},
|
||||
}));
|
||||
|
||||
// Mock queues
|
||||
vi.mock('../services/queueService.server', () => ({
|
||||
cleanupQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
analyticsQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
adminTriggerLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
adminUploadLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Mock file utils
|
||||
vi.mock('../utils/fileUtils', () => ({
|
||||
cleanupUploadedFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock websocket service (dynamic import)
|
||||
vi.mock('../services/websocketService.server', () => ({
|
||||
websocketService: {
|
||||
getConnectionStats: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { backgroundJobService } from '../services/backgroundJobService';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
import { cacheService } from '../services/cacheService.server';
|
||||
import { brandService } from '../services/brandService';
|
||||
import { userService } from '../services/userService';
|
||||
import { getFeatureFlags } from '../services/featureFlags.server';
|
||||
import { cleanupQueue, analyticsQueue } from '../services/queueService.server';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access using vi.mocked()
|
||||
const mockedAdminRepo = vi.mocked(db.adminRepo);
|
||||
const mockedUserRepo = vi.mocked(db.userRepo);
|
||||
const mockedFlyerRepo = vi.mocked(db.flyerRepo);
|
||||
const mockedRecipeRepo = vi.mocked(db.recipeRepo);
|
||||
const mockedBackgroundJobService = vi.mocked(backgroundJobService);
|
||||
const mockedMonitoringService = vi.mocked(monitoringService);
|
||||
const mockedGeoCodingService = vi.mocked(geocodingService);
|
||||
const mockedCacheService = vi.mocked(cacheService);
|
||||
const _mockedBrandService = vi.mocked(brandService);
|
||||
const mockedUserService = vi.mocked(userService);
|
||||
const mockedGetFeatureFlags = vi.mocked(getFeatureFlags);
|
||||
const mockedCleanupQueue = vi.mocked(cleanupQueue);
|
||||
const mockedAnalyticsQueue = vi.mocked(analyticsQueue);
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with admin user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
file: undefined,
|
||||
user: createMockAdminProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock admin user profile.
|
||||
*/
|
||||
function createMockAdminProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock correction object.
|
||||
*/
|
||||
function createMockCorrection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
suggested_correction_id: 1,
|
||||
flyer_item_id: 100,
|
||||
user_id: 'user-123',
|
||||
correction_type: 'master_item',
|
||||
suggested_value: 'Organic Milk',
|
||||
status: 'pending' as const,
|
||||
flyer_item_name: 'Milk',
|
||||
flyer_item_price_display: '$3.99',
|
||||
user_email: 'user@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('AdminController', () => {
|
||||
let controller: AdminController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new AdminController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// CORRECTIONS MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getCorrections()', () => {
|
||||
it('should return pending corrections', async () => {
|
||||
// Arrange
|
||||
const mockCorrections = [
|
||||
createMockCorrection(),
|
||||
createMockCorrection({ suggested_correction_id: 2 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getSuggestedCorrections.mockResolvedValue(mockCorrections);
|
||||
|
||||
// Act
|
||||
const result = await controller.getCorrections(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveCorrection()', () => {
|
||||
it('should approve a correction', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.approveCorrection.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.approveCorrection(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Correction approved successfully.');
|
||||
}
|
||||
expect(mockedAdminRepo.approveCorrection).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectCorrection()', () => {
|
||||
it('should reject a correction', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.rejectCorrection.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.rejectCorrection(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Correction rejected successfully.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCorrection()', () => {
|
||||
it('should update a correction value', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = createMockCorrection({ suggested_value: 'Updated Value' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateSuggestedCorrection.mockResolvedValue(mockUpdated);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateCorrection(
|
||||
1,
|
||||
{ suggested_value: 'Updated Value' },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.suggested_value).toBe('Updated Value');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// USER MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getUsers()', () => {
|
||||
it('should return paginated user list', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
users: [
|
||||
{ user_id: 'user-1', email: 'user1@example.com', role: 'user' as const },
|
||||
{ user_id: 'user-2', email: 'user2@example.com', role: 'admin' as const },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getAllUsers.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUsers(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.users).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect pagination parameters', async () => {
|
||||
// Arrange
|
||||
const mockResult = { users: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getAllUsers.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getUsers(request, 50, 20);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getAllUsers).toHaveBeenCalledWith(expect.anything(), 50, 20);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const mockResult = { users: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getAllUsers.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getUsers(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getAllUsers).toHaveBeenCalledWith(expect.anything(), 100, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserById()', () => {
|
||||
it('should return user profile', async () => {
|
||||
// Arrange
|
||||
const mockProfile = {
|
||||
full_name: 'Test User',
|
||||
role: 'user',
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUserRepo.findUserProfileById.mockResolvedValue(mockProfile);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUserById('user-123', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.user.user_id).toBe('user-123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserRole()', () => {
|
||||
it('should update user role', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = { role: 'admin', points: 100 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateUserRole.mockResolvedValue(mockUpdated);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateUserRole('user-123', { role: 'admin' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.role).toBe('admin');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser()', () => {
|
||||
it('should delete a user', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUserService.deleteUserAsAdmin.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteUser('user-to-delete', request);
|
||||
|
||||
// Assert
|
||||
expect(mockedUserService.deleteUserAsAdmin).toHaveBeenCalledWith(
|
||||
'admin-user-id',
|
||||
'user-to-delete',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// CONTENT MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
describe('updateRecipeStatus()', () => {
|
||||
it('should update recipe status', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = { recipe_id: 1, status: 'public' };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.updateRecipeStatus.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateRecipeStatus(1, { status: 'public' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('public');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRecipe()', () => {
|
||||
it('should delete a recipe', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedRecipeRepo.deleteRecipe.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteRecipe(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedRecipeRepo.deleteRecipe).toHaveBeenCalledWith(
|
||||
1,
|
||||
'admin-user-id',
|
||||
true, // isAdmin
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlyersForReview()', () => {
|
||||
it('should return flyers needing review', async () => {
|
||||
// Arrange
|
||||
const mockFlyers = [
|
||||
{ flyer_id: 1, status: 'needs_review' },
|
||||
{ flyer_id: 2, status: 'needs_review' },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getFlyersForReview.mockResolvedValue(mockFlyers);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyersForReview(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFlyer()', () => {
|
||||
it('should delete a flyer', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedFlyerRepo.deleteFlyer.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteFlyer(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedFlyerRepo.deleteFlyer).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerFlyerCleanup()', () => {
|
||||
it('should enqueue cleanup job', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCleanupQueue.add.mockResolvedValue({} as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerFlyerCleanup(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('File cleanup job');
|
||||
}
|
||||
expect(mockedCleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// STATISTICS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getStats()', () => {
|
||||
it('should return application statistics', async () => {
|
||||
// Arrange
|
||||
const mockStats = {
|
||||
flyerCount: 100,
|
||||
userCount: 50,
|
||||
flyerItemCount: 500,
|
||||
storeCount: 20,
|
||||
pendingCorrectionCount: 5,
|
||||
recipeCount: 30,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getApplicationStats.mockResolvedValue(mockStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flyerCount).toBe(100);
|
||||
expect(result.data.userCount).toBe(50);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDailyStats()', () => {
|
||||
it('should return daily statistics', async () => {
|
||||
// Arrange
|
||||
const mockDailyStats = [
|
||||
{ date: '2024-01-01', new_users: 5, new_flyers: 10 },
|
||||
{ date: '2024-01-02', new_users: 3, new_flyers: 8 },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getDailyStatsForLast30Days.mockResolvedValue(mockDailyStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDailyStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// QUEUE/WORKER MONITORING
|
||||
// ==========================================================================
|
||||
|
||||
describe('getWorkerStatuses()', () => {
|
||||
it('should return worker statuses', async () => {
|
||||
// Arrange
|
||||
const mockStatuses = [
|
||||
{ name: 'flyer-processor', isRunning: true },
|
||||
{ name: 'email-sender', isRunning: true },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getWorkerStatuses.mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const result = await controller.getWorkerStatuses(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].isRunning).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueueStatuses()', () => {
|
||||
it('should return queue statuses', async () => {
|
||||
// Arrange
|
||||
const mockStatuses = [
|
||||
{
|
||||
name: 'flyer-processing',
|
||||
counts: { waiting: 0, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getQueueStatuses.mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const result = await controller.getQueueStatuses(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].counts.completed).toBe(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryJob()', () => {
|
||||
it('should retry a failed job', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.retryFailedJob.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.retryJob('flyer-processing', 'job-123', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('job-123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BACKGROUND JOB TRIGGERS
|
||||
// ==========================================================================
|
||||
|
||||
describe('triggerDailyDealCheck()', () => {
|
||||
it('should trigger daily deal check', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerDailyDealCheck(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('Daily deal check');
|
||||
}
|
||||
expect(mockedBackgroundJobService.runDailyDealCheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerAnalyticsReport()', () => {
|
||||
it('should trigger analytics report', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBackgroundJobService.triggerAnalyticsReport.mockResolvedValue('job-456');
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerAnalyticsReport(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('job-456');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerWeeklyAnalytics()', () => {
|
||||
it('should trigger weekly analytics', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBackgroundJobService.triggerWeeklyAnalyticsReport.mockResolvedValue('weekly-job-1');
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerWeeklyAnalytics(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('weekly-job-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerTokenCleanup()', () => {
|
||||
it('should trigger token cleanup', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBackgroundJobService.triggerTokenCleanup.mockResolvedValue('cleanup-job-1');
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerTokenCleanup(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('cleanup-job-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerFailingJob()', () => {
|
||||
it('should trigger a failing test job', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAnalyticsQueue.add.mockResolvedValue({ id: 'fail-job-1' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.triggerFailingJob(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('fail-job-1');
|
||||
}
|
||||
expect(mockedAnalyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', {
|
||||
reportDate: 'FAIL',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SYSTEM OPERATIONS
|
||||
// ==========================================================================
|
||||
|
||||
describe('clearGeocodeCache()', () => {
|
||||
it('should clear geocode cache', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGeoCodingService.clearGeocodeCache.mockResolvedValue(50);
|
||||
|
||||
// Act
|
||||
const result = await controller.clearGeocodeCache(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('50 keys');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearApplicationCache()', () => {
|
||||
it('should clear all application caches', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCacheService.invalidateFlyers.mockResolvedValue(10);
|
||||
mockedCacheService.invalidateBrands.mockResolvedValue(5);
|
||||
mockedCacheService.invalidateStats.mockResolvedValue(3);
|
||||
|
||||
// Act
|
||||
const result = await controller.clearApplicationCache(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('18 keys');
|
||||
expect(result.data.details?.flyers).toBe(10);
|
||||
expect(result.data.details?.brands).toBe(5);
|
||||
expect(result.data.details?.stats).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// FEATURE FLAGS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getFeatureFlags()', () => {
|
||||
it('should return feature flags', async () => {
|
||||
// Arrange
|
||||
const mockFlags = {
|
||||
enableNewUI: true,
|
||||
enableBetaFeatures: false,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGetFeatureFlags.mockReturnValue(mockFlags);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFeatureFlags(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flags.enableNewUI).toBe(true);
|
||||
expect(result.data.flags.enableBetaFeatures).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStats = { flyerCount: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getApplicationStats.mockResolvedValue(mockStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
1419
src/controllers/admin.controller.ts
Normal file
1419
src/controllers/admin.controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
632
src/controllers/ai.controller.test.ts
Normal file
632
src/controllers/ai.controller.test.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
// src/controllers/ai.controller.test.ts
|
||||
// ============================================================================
|
||||
// AI CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the AIController class. These tests verify controller
|
||||
// logic in isolation by mocking AI service and monitoring service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
FormField: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock AI service
|
||||
vi.mock('../services/aiService.server', () => ({
|
||||
aiService: {
|
||||
enqueueFlyerProcessing: vi.fn(),
|
||||
processLegacyFlyerUpload: vi.fn(),
|
||||
extractTextFromImageArea: vi.fn(),
|
||||
planTripWithMaps: vi.fn(),
|
||||
},
|
||||
DuplicateFlyerError: class DuplicateFlyerError extends Error {
|
||||
flyerId: number;
|
||||
constructor(message: string, flyerId: number) {
|
||||
super(message);
|
||||
this.flyerId = flyerId;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock monitoring service
|
||||
vi.mock('../services/monitoringService.server', () => ({
|
||||
monitoringService: {
|
||||
getFlyerJobStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock file utils
|
||||
vi.mock('../utils/fileUtils', () => ({
|
||||
cleanupUploadedFile: vi.fn(),
|
||||
cleanupUploadedFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
aiUploadLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
aiGenerationLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { aiService, DuplicateFlyerError } from '../services/aiService.server';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
import { AIController } from './ai.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedAiService = aiService as Mocked<typeof aiService>;
|
||||
const mockedMonitoringService = monitoringService as Mocked<typeof monitoringService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
headers: {},
|
||||
ip: '127.0.0.1',
|
||||
file: undefined,
|
||||
files: undefined,
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock uploaded file.
|
||||
*/
|
||||
function createMockFile(overrides: Partial<Express.Multer.File> = {}): Express.Multer.File {
|
||||
return {
|
||||
fieldname: 'flyerFile',
|
||||
originalname: 'test-flyer.jpg',
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 1024,
|
||||
destination: '/tmp/uploads',
|
||||
filename: 'abc123.jpg',
|
||||
path: '/tmp/uploads/abc123.jpg',
|
||||
buffer: Buffer.from('mock file content'),
|
||||
stream: {} as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('AIController', () => {
|
||||
let controller: AIController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new AIController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// FLYER UPLOAD ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('uploadAndProcess()', () => {
|
||||
it('should accept flyer for processing', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.enqueueFlyerProcessing.mockResolvedValue({ id: 'job-123' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadAndProcess(
|
||||
request,
|
||||
'a'.repeat(64), // valid checksum
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.jobId).toBe('job-123');
|
||||
expect(result.data.message).toContain('Flyer accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid checksum format', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadAndProcess(request, 'invalid')).rejects.toThrow(
|
||||
'Checksum must be a 64-character hexadecimal string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadAndProcess(request, 'a'.repeat(64))).rejects.toThrow(
|
||||
'A flyer file (PDF or image) is required.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle duplicate flyer error', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.enqueueFlyerProcessing.mockRejectedValue(
|
||||
new DuplicateFlyerError('Duplicate flyer', 42),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadAndProcess(request, 'a'.repeat(64));
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('CONFLICT');
|
||||
expect(result.error.details).toEqual({ flyerId: 42 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadLegacy()', () => {
|
||||
it('should process legacy upload', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const mockFlyer = { flyer_id: 1, file_name: 'test.jpg' };
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.processLegacyFlyerUpload.mockResolvedValue(mockFlyer as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadLegacy(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flyer_id).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadLegacy(request)).rejects.toThrow('No flyer file uploaded.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processFlyer()', () => {
|
||||
it('should process flyer data', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const mockFlyer = { flyer_id: 1, file_name: 'test.jpg' };
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.processLegacyFlyerUpload.mockResolvedValue(mockFlyer as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.processFlyer(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('processed');
|
||||
expect(result.data.flyer.flyer_id).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.processFlyer(request)).rejects.toThrow(
|
||||
'Flyer image file is required.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// JOB STATUS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getJobStatus()', () => {
|
||||
it('should return job status', async () => {
|
||||
// Arrange
|
||||
const mockStatus = {
|
||||
id: 'job-123',
|
||||
state: 'completed',
|
||||
progress: 100,
|
||||
returnValue: { flyer_id: 1 },
|
||||
failedReason: null,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getFlyerJobStatus.mockResolvedValue(mockStatus);
|
||||
|
||||
// Act
|
||||
const result = await controller.getJobStatus('job-123', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('job-123');
|
||||
expect(result.data.state).toBe('completed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// IMAGE ANALYSIS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('checkFlyer()', () => {
|
||||
it('should check if image is a flyer', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act
|
||||
const result = await controller.checkFlyer(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.is_flyer).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no file uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ file: undefined });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.checkFlyer(request)).rejects.toThrow('Image file is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAddress()', () => {
|
||||
it('should extract address from image', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act
|
||||
const result = await controller.extractAddress(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.address).toBe('not identified');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLogo()', () => {
|
||||
it('should extract logo from images', async () => {
|
||||
// Arrange
|
||||
const mockFiles = [createMockFile()];
|
||||
const request = createMockRequest({ files: mockFiles });
|
||||
|
||||
// Act
|
||||
const result = await controller.extractLogo(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.store_logo_base_64).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when no files uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ files: [] });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.extractLogo(request)).rejects.toThrow('Image files are required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rescanArea()', () => {
|
||||
it('should rescan a specific area', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
mockedAiService.extractTextFromImageArea.mockResolvedValue({ text: 'Extracted text' });
|
||||
|
||||
// Act
|
||||
const result = await controller.rescanArea(
|
||||
request,
|
||||
JSON.stringify({ x: 10, y: 10, width: 100, height: 100 }),
|
||||
'item_details',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toBe('Extracted text');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid cropArea JSON', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.rescanArea(request, 'invalid json', 'item_details')).rejects.toThrow(
|
||||
'cropArea must be a valid JSON string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject cropArea with missing properties', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.rescanArea(request, JSON.stringify({ x: 10 }), 'item_details'),
|
||||
).rejects.toThrow('cropArea must contain numeric x, y, width, and height properties.');
|
||||
});
|
||||
|
||||
it('should reject cropArea with zero width', async () => {
|
||||
// Arrange
|
||||
const mockFile = createMockFile();
|
||||
const request = createMockRequest({ file: mockFile });
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.rescanArea(
|
||||
request,
|
||||
JSON.stringify({ x: 10, y: 10, width: 0, height: 100 }),
|
||||
'item_details',
|
||||
),
|
||||
).rejects.toThrow('Crop area width must be positive.');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AI INSIGHTS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getQuickInsights()', () => {
|
||||
it('should return quick insights', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getQuickInsights(request, {
|
||||
items: [{ item: 'Milk' }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('quick insight');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeepDive()', () => {
|
||||
it('should return deep dive analysis', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getDeepDive(request, {
|
||||
items: [{ item: 'Chicken' }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('deep dive');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchWeb()', () => {
|
||||
it('should search the web', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.searchWeb(request, {
|
||||
query: 'best grocery deals',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toBeDefined();
|
||||
expect(result.data.sources).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparePrices()', () => {
|
||||
it('should compare prices', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.comparePrices(request, {
|
||||
items: [{ item: 'Milk' }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('price comparison');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planTrip()', () => {
|
||||
it('should plan a shopping trip', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
text: 'Here is your trip plan',
|
||||
sources: [{ uri: 'https://maps.google.com', title: 'Google Maps' }],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.planTripWithMaps.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.planTrip(request, {
|
||||
items: [{ item: 'Milk' }],
|
||||
store: { name: 'SuperMart' },
|
||||
userLocation: { latitude: 43.65, longitude: -79.38 },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.text).toContain('trip plan');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// STUBBED FUTURE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('generateImage()', () => {
|
||||
it('should return 501 not implemented', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.generateImage(request, {
|
||||
prompt: 'A grocery store',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NOT_IMPLEMENTED');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSpeech()', () => {
|
||||
it('should return 501 not implemented', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.generateSpeech(request, {
|
||||
text: 'Hello world',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NOT_IMPLEMENTED');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStatus = {
|
||||
id: 'job-1',
|
||||
state: 'active',
|
||||
progress: 50,
|
||||
returnValue: null,
|
||||
failedReason: null,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedMonitoringService.getFlyerJobStatus.mockResolvedValue(mockStatus);
|
||||
|
||||
// Act
|
||||
const result = await controller.getJobStatus('job-1', request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for error responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.generateImage(request, { prompt: 'test' });
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toHaveProperty('code');
|
||||
expect(result.error).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
937
src/controllers/ai.controller.ts
Normal file
937
src/controllers/ai.controller.ts
Normal file
@@ -0,0 +1,937 @@
|
||||
// src/controllers/ai.controller.ts
|
||||
// ============================================================================
|
||||
// AI CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for AI-powered flyer processing, item extraction, and
|
||||
// various AI-assisted features including OCR, insights generation, and
|
||||
// trip planning.
|
||||
//
|
||||
// Key Features:
|
||||
// - Flyer upload and asynchronous processing via BullMQ
|
||||
// - Image-based text extraction (OCR) and rescan
|
||||
// - AI-generated insights, price comparisons, and trip planning
|
||||
// - Legacy upload endpoints for backward compatibility
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
FormField,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController, ControllerErrorCode } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { aiService, DuplicateFlyerError } from '../services/aiService.server';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
|
||||
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
|
||||
import type { UserProfile, FlyerItem } from '../types';
|
||||
import type { FlyerDto } from '../dtos/common.dto';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
// These Data Transfer Object types define the API contract for AI endpoints.
|
||||
// They are used by tsoa to generate OpenAPI specifications.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Crop area coordinates for targeted image rescanning.
|
||||
*/
|
||||
interface CropArea {
|
||||
/** X coordinate of the top-left corner */
|
||||
x: number;
|
||||
/** Y coordinate of the top-left corner */
|
||||
y: number;
|
||||
/** Width of the crop area in pixels */
|
||||
width: number;
|
||||
/** Height of the crop area in pixels */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of data to extract from a rescan operation.
|
||||
*/
|
||||
type ExtractionType = 'store_name' | 'dates' | 'item_details';
|
||||
|
||||
/**
|
||||
* Flyer item for AI analysis.
|
||||
* At least one of 'item' or 'name' must be provided.
|
||||
*/
|
||||
interface FlyerItemForAnalysis {
|
||||
/** Item name/description (primary identifier) */
|
||||
item?: string;
|
||||
/** Alternative item name field */
|
||||
name?: string;
|
||||
/** Additional properties are allowed */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store information for trip planning.
|
||||
*/
|
||||
interface StoreInfo {
|
||||
/** Store name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User's geographic location for trip planning.
|
||||
*/
|
||||
interface UserLocation {
|
||||
/**
|
||||
* Latitude coordinate.
|
||||
* @minimum -90
|
||||
* @maximum 90
|
||||
*/
|
||||
latitude: number;
|
||||
/**
|
||||
* Longitude coordinate.
|
||||
* @minimum -180
|
||||
* @maximum 180
|
||||
*/
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for quick insights or deep dive analysis.
|
||||
*/
|
||||
interface InsightsRequest {
|
||||
/**
|
||||
* Array of items to analyze.
|
||||
* @minItems 1
|
||||
*/
|
||||
items: FlyerItemForAnalysis[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for price comparison.
|
||||
*/
|
||||
interface ComparePricesRequest {
|
||||
/**
|
||||
* Array of items to compare prices for.
|
||||
* @minItems 1
|
||||
*/
|
||||
items: FlyerItemForAnalysis[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for trip planning.
|
||||
*/
|
||||
interface PlanTripRequest {
|
||||
/** Items to buy on the trip */
|
||||
items: FlyerItemForAnalysis[];
|
||||
/** Target store information */
|
||||
store: StoreInfo;
|
||||
/** User's current location */
|
||||
userLocation: UserLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for image generation (not implemented).
|
||||
*/
|
||||
interface GenerateImageRequest {
|
||||
/**
|
||||
* Prompt for image generation.
|
||||
* @minLength 1
|
||||
*/
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for speech generation (not implemented).
|
||||
*/
|
||||
interface GenerateSpeechRequest {
|
||||
/**
|
||||
* Text to convert to speech.
|
||||
* @minLength 1
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for web search.
|
||||
*/
|
||||
interface SearchWebRequest {
|
||||
/**
|
||||
* Search query.
|
||||
* @minLength 1
|
||||
*/
|
||||
query: string;
|
||||
}
|
||||
|
||||
// RescanAreaRequest is handled via route-level form parsing
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response for successful flyer upload.
|
||||
*/
|
||||
interface UploadProcessResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** Background job ID for tracking processing status */
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for duplicate flyer detection.
|
||||
*/
|
||||
interface DuplicateFlyerResponse {
|
||||
/** Existing flyer ID */
|
||||
flyerId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for job status check.
|
||||
*/
|
||||
interface JobStatusResponse {
|
||||
/** Job ID */
|
||||
id: string;
|
||||
/** Current job state */
|
||||
state: string;
|
||||
/** Processing progress (0-100 or object with details) */
|
||||
progress: number | object | string | boolean;
|
||||
/** Return value when job is completed */
|
||||
returnValue: unknown;
|
||||
/** Error reason if job failed */
|
||||
failedReason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for flyer check.
|
||||
*/
|
||||
interface FlyerCheckResponse {
|
||||
/** Whether the image is identified as a flyer */
|
||||
is_flyer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for address extraction.
|
||||
*/
|
||||
interface ExtractAddressResponse {
|
||||
/** Extracted address or 'not identified' if not found */
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for logo extraction.
|
||||
*/
|
||||
interface ExtractLogoResponse {
|
||||
/** Base64-encoded logo image or null if not found */
|
||||
store_logo_base_64: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for text-based AI features (insights, deep dive, etc.).
|
||||
*/
|
||||
interface TextResponse {
|
||||
/** AI-generated text response */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for web search.
|
||||
*/
|
||||
interface SearchWebResponse {
|
||||
/** AI-generated response */
|
||||
text: string;
|
||||
/** Source references */
|
||||
sources: { uri: string; title: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for trip planning.
|
||||
*/
|
||||
interface PlanTripResponse {
|
||||
/** AI-generated trip plan */
|
||||
text: string;
|
||||
/** Map and store sources */
|
||||
sources: { uri: string; title: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for rescan area.
|
||||
*/
|
||||
interface RescanAreaResponse {
|
||||
/** Extracted text from the cropped area */
|
||||
text: string | undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parses cropArea JSON string into a validated CropArea object.
|
||||
* @param cropAreaStr JSON string containing crop area coordinates
|
||||
* @returns Parsed and validated CropArea object
|
||||
* @throws Error if parsing fails or validation fails
|
||||
*/
|
||||
function parseCropArea(cropAreaStr: string): CropArea {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cropAreaStr);
|
||||
} catch {
|
||||
throw new Error('cropArea must be a valid JSON string.');
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('cropArea must be a valid JSON object.');
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof obj.x !== 'number' ||
|
||||
typeof obj.y !== 'number' ||
|
||||
typeof obj.width !== 'number' ||
|
||||
typeof obj.height !== 'number'
|
||||
) {
|
||||
throw new Error('cropArea must contain numeric x, y, width, and height properties.');
|
||||
}
|
||||
|
||||
if (obj.width <= 0) {
|
||||
throw new Error('Crop area width must be positive.');
|
||||
}
|
||||
|
||||
if (obj.height <= 0) {
|
||||
throw new Error('Crop area height must be positive.');
|
||||
}
|
||||
|
||||
return {
|
||||
x: obj.x,
|
||||
y: obj.y,
|
||||
width: obj.width,
|
||||
height: obj.height,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for AI-powered flyer processing and analysis.
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Uploading and processing flyers with AI extraction
|
||||
* - Checking processing job status
|
||||
* - Targeted image rescanning for specific data extraction
|
||||
* - AI-generated insights and recommendations
|
||||
* - Price comparisons and trip planning
|
||||
*
|
||||
* File upload endpoints expect multipart/form-data and are configured
|
||||
* with Express middleware for multer file handling.
|
||||
*/
|
||||
@Route('ai')
|
||||
@Tags('AI')
|
||||
export class AIController extends BaseController {
|
||||
// ==========================================================================
|
||||
// FLYER UPLOAD ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Upload and process a flyer.
|
||||
*
|
||||
* Accepts a single flyer file (PDF or image), validates the checksum to
|
||||
* prevent duplicates, and enqueues it for background AI processing.
|
||||
* Returns immediately with a job ID for tracking.
|
||||
*
|
||||
* The file upload is handled by Express middleware (multer).
|
||||
* The endpoint expects multipart/form-data with:
|
||||
* - flyerFile: The flyer image/PDF file
|
||||
* - checksum: SHA-256 checksum of the file (64 hex characters)
|
||||
* - baseUrl: Optional base URL override
|
||||
*
|
||||
* @summary Upload and process flyer
|
||||
* @param request Express request with uploaded file
|
||||
* @param checksum SHA-256 checksum of the file (64 hex characters)
|
||||
* @param baseUrl Optional base URL for generated image URLs
|
||||
* @returns Job ID for tracking processing status
|
||||
*/
|
||||
@Post('upload-and-process')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(202, 'Flyer accepted for processing')
|
||||
@Response<ErrorResponse>(400, 'Missing file or invalid checksum')
|
||||
@Response<ErrorResponse & { error: { details: DuplicateFlyerResponse } }>(
|
||||
409,
|
||||
'Duplicate flyer detected',
|
||||
)
|
||||
public async uploadAndProcess(
|
||||
@Request() request: ExpressRequest,
|
||||
@FormField() checksum: string,
|
||||
@FormField() baseUrl?: string,
|
||||
): Promise<SuccessResponseType<UploadProcessResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
// Validate checksum format
|
||||
if (!checksum || checksum.length !== 64 || !/^[a-f0-9]+$/.test(checksum)) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Checksum must be a 64-character hexadecimal string.');
|
||||
}
|
||||
|
||||
// Validate file was uploaded
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('A flyer file (PDF or image) is required.');
|
||||
}
|
||||
|
||||
request.log.debug(
|
||||
{ filename: file.originalname, size: file.size, checksum },
|
||||
'Handling upload-and-process',
|
||||
);
|
||||
|
||||
try {
|
||||
// Handle optional authentication - clear user if no auth header in test/staging
|
||||
let userProfile = request.user as UserProfile | undefined;
|
||||
if (
|
||||
(process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'staging') &&
|
||||
!request.headers['authorization']
|
||||
) {
|
||||
userProfile = undefined;
|
||||
}
|
||||
|
||||
const job = await aiService.enqueueFlyerProcessing(
|
||||
file,
|
||||
checksum,
|
||||
userProfile,
|
||||
request.ip ?? 'unknown',
|
||||
request.log,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
this.setStatus(202);
|
||||
return this.success({
|
||||
message: 'Flyer accepted for processing.',
|
||||
jobId: job.id!,
|
||||
});
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(file);
|
||||
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
request.log.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
|
||||
this.setStatus(409);
|
||||
return this.error(ControllerErrorCode.CONFLICT, error.message, {
|
||||
flyerId: error.flyerId,
|
||||
}) as unknown as SuccessResponseType<UploadProcessResponse>;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy flyer upload (deprecated).
|
||||
*
|
||||
* Process a flyer upload synchronously. This endpoint is deprecated
|
||||
* and will be removed in a future version. Use /upload-and-process instead.
|
||||
*
|
||||
* @summary Legacy flyer upload (deprecated)
|
||||
* @param request Express request with uploaded file
|
||||
* @returns The processed flyer data
|
||||
* @deprecated Use /upload-and-process instead
|
||||
*/
|
||||
@Post('upload-legacy')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Flyer processed successfully')
|
||||
@Response<ErrorResponse>(400, 'No flyer file uploaded')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(409, 'Duplicate flyer detected')
|
||||
public async uploadLegacy(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerDto>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('No flyer file uploaded.');
|
||||
}
|
||||
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
try {
|
||||
const newFlyer = await aiService.processLegacyFlyerUpload(
|
||||
file,
|
||||
request.body,
|
||||
userProfile,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(newFlyer);
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(file);
|
||||
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
request.log.warn('Duplicate legacy flyer upload attempt blocked.');
|
||||
this.setStatus(409);
|
||||
return this.error(ControllerErrorCode.CONFLICT, error.message, {
|
||||
flyerId: error.flyerId,
|
||||
}) as unknown as SuccessResponseType<FlyerDto>;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process flyer data (deprecated).
|
||||
*
|
||||
* Saves processed flyer data to the database. This endpoint is deprecated
|
||||
* and will be removed in a future version. Use /upload-and-process instead.
|
||||
*
|
||||
* @summary Process flyer data (deprecated)
|
||||
* @param request Express request with uploaded file
|
||||
* @returns Success message with flyer data
|
||||
* @deprecated Use /upload-and-process instead
|
||||
*/
|
||||
@Post('flyers/process')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(201, 'Flyer processed and saved successfully')
|
||||
@Response<ErrorResponse>(400, 'Flyer image file is required')
|
||||
@Response<ErrorResponse>(409, 'Duplicate flyer detected')
|
||||
public async processFlyer(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<{ message: string; flyer: FlyerDto }>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Flyer image file is required.');
|
||||
}
|
||||
|
||||
const userProfile = request.user as UserProfile | undefined;
|
||||
|
||||
try {
|
||||
const newFlyer = await aiService.processLegacyFlyerUpload(
|
||||
file,
|
||||
request.body,
|
||||
userProfile,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.created({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
|
||||
} catch (error) {
|
||||
await cleanupUploadedFile(file);
|
||||
|
||||
if (error instanceof DuplicateFlyerError) {
|
||||
request.log.warn('Duplicate flyer upload attempt blocked.');
|
||||
this.setStatus(409);
|
||||
return this.error(ControllerErrorCode.CONFLICT, error.message, {
|
||||
flyerId: error.flyerId,
|
||||
}) as unknown as SuccessResponseType<{ message: string; flyer: FlyerDto }>;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// JOB STATUS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Check job status.
|
||||
*
|
||||
* Checks the status of a background flyer processing job.
|
||||
* Use this endpoint to poll for completion after uploading a flyer.
|
||||
*
|
||||
* @summary Check job status
|
||||
* @param jobId Job ID returned from upload-and-process
|
||||
* @param request Express request for logging
|
||||
* @returns Job status information
|
||||
*/
|
||||
@Get('jobs/{jobId}/status')
|
||||
@SuccessResponse(200, 'Job status retrieved')
|
||||
@Response<ErrorResponse>(404, 'Job not found')
|
||||
public async getJobStatus(
|
||||
@Path() jobId: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<JobStatusResponse>> {
|
||||
const jobStatus = await monitoringService.getFlyerJobStatus(jobId);
|
||||
request.log.debug(`Status check for job ${jobId}: ${jobStatus.state}`);
|
||||
return this.success(jobStatus);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// IMAGE ANALYSIS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Check if image is a flyer.
|
||||
*
|
||||
* Analyzes an uploaded image to determine if it's a grocery store flyer.
|
||||
*
|
||||
* @summary Check if image is a flyer
|
||||
* @param request Express request with uploaded image
|
||||
* @returns Whether the image is identified as a flyer
|
||||
*/
|
||||
@Post('check-flyer')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Flyer check completed')
|
||||
@Response<ErrorResponse>(400, 'Image file is required')
|
||||
public async checkFlyer(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerCheckResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image file is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
request.log.info(`Server-side flyer check for file: ${file.originalname}`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ is_flyer: true });
|
||||
} finally {
|
||||
await cleanupUploadedFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract address from image.
|
||||
*
|
||||
* Extracts store address information from a flyer image using AI.
|
||||
*
|
||||
* @summary Extract address from image
|
||||
* @param request Express request with uploaded image
|
||||
* @returns Extracted address information
|
||||
*/
|
||||
@Post('extract-address')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Address extraction completed')
|
||||
@Response<ErrorResponse>(400, 'Image file is required')
|
||||
public async extractAddress(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExtractAddressResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image file is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
request.log.info(`Server-side address extraction for file: ${file.originalname}`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ address: 'not identified' });
|
||||
} finally {
|
||||
await cleanupUploadedFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract store logo.
|
||||
*
|
||||
* Extracts the store logo from flyer images using AI.
|
||||
*
|
||||
* @summary Extract store logo
|
||||
* @param request Express request with uploaded images
|
||||
* @returns Extracted logo as base64 string
|
||||
*/
|
||||
@Post('extract-logo')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Logo extraction completed')
|
||||
@Response<ErrorResponse>(400, 'Image files are required')
|
||||
public async extractLogo(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExtractLogoResponse>> {
|
||||
const files = request.files as Express.Multer.File[] | undefined;
|
||||
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image files are required.');
|
||||
}
|
||||
|
||||
try {
|
||||
request.log.info(`Server-side logo extraction for ${files.length} image(s).`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ store_logo_base_64: null });
|
||||
} finally {
|
||||
await cleanupUploadedFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescan area of image.
|
||||
*
|
||||
* Performs a targeted AI scan on a specific area of an image.
|
||||
* Useful for re-extracting data from poorly recognized regions.
|
||||
*
|
||||
* @summary Rescan area of image
|
||||
* @param request Express request with uploaded image
|
||||
* @param cropArea JSON string with x, y, width, height coordinates
|
||||
* @param extractionType Type of data to extract (store_name, dates, item_details)
|
||||
* @returns Extracted text from the cropped area
|
||||
*/
|
||||
@Post('rescan-area')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiUploadLimiter)
|
||||
@SuccessResponse(200, 'Rescan completed')
|
||||
@Response<ErrorResponse>(400, 'Image file is required or invalid cropArea')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async rescanArea(
|
||||
@Request() request: ExpressRequest,
|
||||
@FormField() cropArea: string,
|
||||
@FormField() extractionType: ExtractionType,
|
||||
): Promise<SuccessResponseType<RescanAreaResponse>> {
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Image file is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse and validate cropArea
|
||||
const parsedCropArea = parseCropArea(cropArea);
|
||||
|
||||
request.log.debug(
|
||||
{ extractionType, cropArea: parsedCropArea, filename: file.originalname },
|
||||
'Rescan area requested',
|
||||
);
|
||||
|
||||
const result = await aiService.extractTextFromImageArea(
|
||||
file.path,
|
||||
file.mimetype,
|
||||
parsedCropArea,
|
||||
extractionType,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
} finally {
|
||||
await cleanupUploadedFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AI INSIGHTS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get quick insights.
|
||||
*
|
||||
* Get AI-generated quick insights about flyer items.
|
||||
* Provides brief recommendations and highlights.
|
||||
*
|
||||
* @summary Get quick insights
|
||||
* @param request Express request for logging
|
||||
* @param body Items to analyze
|
||||
* @returns AI-generated quick insights
|
||||
*/
|
||||
@Post('quick-insights')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Quick insights generated')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async getQuickInsights(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: InsightsRequest,
|
||||
): Promise<SuccessResponseType<TextResponse>> {
|
||||
request.log.info(`Server-side quick insights requested for ${body.items.length} items.`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ text: 'This is a server-generated quick insight: buy the cheap stuff!' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deep dive analysis.
|
||||
*
|
||||
* Get detailed AI-generated analysis about flyer items.
|
||||
* Provides comprehensive information including nutritional value,
|
||||
* price history, and recommendations.
|
||||
*
|
||||
* @summary Get deep dive analysis
|
||||
* @param request Express request for logging
|
||||
* @param body Items to analyze
|
||||
* @returns Detailed AI analysis
|
||||
*/
|
||||
@Post('deep-dive')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Deep dive analysis generated')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async getDeepDive(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: InsightsRequest,
|
||||
): Promise<SuccessResponseType<TextResponse>> {
|
||||
request.log.info(`Server-side deep dive requested for ${body.items.length} items.`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({
|
||||
text: 'This is a server-generated deep dive analysis. It is very detailed.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search web for information.
|
||||
*
|
||||
* Search the web for product or deal information using AI.
|
||||
*
|
||||
* @summary Search web for information
|
||||
* @param request Express request for logging
|
||||
* @param body Search query
|
||||
* @returns Search results with sources
|
||||
*/
|
||||
@Post('search-web')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Web search completed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async searchWeb(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: SearchWebRequest,
|
||||
): Promise<SuccessResponseType<SearchWebResponse>> {
|
||||
request.log.info(`Server-side web search requested for query: ${body.query}`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({ text: 'The web says this is good.', sources: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare prices across stores.
|
||||
*
|
||||
* Compare prices for items across different stores using AI.
|
||||
*
|
||||
* @summary Compare prices across stores
|
||||
* @param request Express request for logging
|
||||
* @param body Items to compare
|
||||
* @returns Price comparison results
|
||||
*/
|
||||
@Post('compare-prices')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Price comparison completed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async comparePrices(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: ComparePricesRequest,
|
||||
): Promise<SuccessResponseType<SearchWebResponse>> {
|
||||
request.log.info(`Server-side price comparison requested for ${body.items.length} items.`);
|
||||
// Stubbed response - actual AI implementation would go here
|
||||
return this.success({
|
||||
text: 'This is a server-generated price comparison. Milk is cheaper at SuperMart.',
|
||||
sources: [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan shopping trip.
|
||||
*
|
||||
* Plan an optimized shopping trip to a store based on items and user location.
|
||||
* Uses Google Maps integration for directions and nearby store suggestions.
|
||||
*
|
||||
* @summary Plan shopping trip
|
||||
* @param request Express request for logging
|
||||
* @param body Trip planning parameters
|
||||
* @returns Trip plan with directions
|
||||
*/
|
||||
@Post('plan-trip')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(200, 'Trip plan generated')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(501, 'Feature disabled')
|
||||
public async planTrip(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: PlanTripRequest,
|
||||
): Promise<SuccessResponseType<PlanTripResponse>> {
|
||||
request.log.debug(
|
||||
{ itemCount: body.items.length, storeName: body.store.name },
|
||||
'Trip planning requested.',
|
||||
);
|
||||
|
||||
try {
|
||||
// Note: planTripWithMaps is currently disabled and throws immediately.
|
||||
// The cast is safe since FlyerItemForAnalysis has the same shape as FlyerItem.
|
||||
const result = await aiService.planTripWithMaps(
|
||||
body.items as unknown as FlyerItem[],
|
||||
body.store,
|
||||
body.userLocation as GeolocationCoordinates,
|
||||
request.log,
|
||||
);
|
||||
return this.success(result);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Error in plan-trip endpoint');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// STUBBED FUTURE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Generate image (not implemented).
|
||||
*
|
||||
* Generate an image from a prompt. Currently not implemented.
|
||||
*
|
||||
* @summary Generate image (not implemented)
|
||||
* @param request Express request for logging
|
||||
* @param body Image generation prompt
|
||||
* @returns 501 Not Implemented
|
||||
*/
|
||||
@Post('generate-image')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(501, 'Not implemented')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(501, 'Not implemented')
|
||||
public async generateImage(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() _body: GenerateImageRequest,
|
||||
): Promise<ErrorResponse> {
|
||||
request.log.info('Request received for unimplemented endpoint: generate-image');
|
||||
this.setStatus(501);
|
||||
return this.error(
|
||||
ControllerErrorCode.NOT_IMPLEMENTED,
|
||||
'Image generation is not yet implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech (not implemented).
|
||||
*
|
||||
* Generate speech from text. Currently not implemented.
|
||||
*
|
||||
* @summary Generate speech (not implemented)
|
||||
* @param request Express request for logging
|
||||
* @param body Text to convert to speech
|
||||
* @returns 501 Not Implemented
|
||||
*/
|
||||
@Post('generate-speech')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(aiGenerationLimiter)
|
||||
@SuccessResponse(501, 'Not implemented')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(501, 'Not implemented')
|
||||
public async generateSpeech(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() _body: GenerateSpeechRequest,
|
||||
): Promise<ErrorResponse> {
|
||||
request.log.info('Request received for unimplemented endpoint: generate-speech');
|
||||
this.setStatus(501);
|
||||
return this.error(
|
||||
ControllerErrorCode.NOT_IMPLEMENTED,
|
||||
'Speech generation is not yet implemented.',
|
||||
);
|
||||
}
|
||||
}
|
||||
486
src/controllers/auth.controller.test.ts
Normal file
486
src/controllers/auth.controller.test.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
// src/controllers/auth.controller.test.ts
|
||||
// ============================================================================
|
||||
// AUTH CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the AuthController class. These tests verify controller
|
||||
// logic in isolation by mocking external dependencies like auth service,
|
||||
// passport, and response handling.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
// Mock all external dependencies before importing the controller module.
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class (required before controller import)
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(_status: number): void {
|
||||
// Mock setStatus
|
||||
}
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock auth service
|
||||
vi.mock('../services/authService', () => ({
|
||||
authService: {
|
||||
registerAndLoginUser: vi.fn(),
|
||||
handleSuccessfulLogin: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
updatePassword: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock passport
|
||||
vi.mock('../config/passport', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock password strength validation
|
||||
vi.mock('../utils/authUtils', () => ({
|
||||
validatePasswordStrength: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
registerLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
forgotPasswordLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
resetPasswordLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
refreshTokenLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
logoutLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { authService } from '../services/authService';
|
||||
import { validatePasswordStrength } from '../utils/authUtils';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedAuthService = authService as Mocked<typeof authService>;
|
||||
const mockedValidatePasswordStrength = validatePasswordStrength as ReturnType<typeof vi.fn>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
const mockRes = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as ExpressResponse;
|
||||
|
||||
return {
|
||||
body: {},
|
||||
cookies: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
res: mockRes,
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
avatar_url: null,
|
||||
address_id: null,
|
||||
points: 0,
|
||||
role: 'user' as const,
|
||||
preferences: null,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
address: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new AuthController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// REGISTRATION TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('register()', () => {
|
||||
it('should successfully register a new user', async () => {
|
||||
// Arrange
|
||||
const mockUserProfile = createMockUserProfile();
|
||||
const request = createMockRequest({
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
full_name: 'Test User',
|
||||
},
|
||||
});
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({ isValid: true, feedback: '' });
|
||||
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||
newUserProfile: mockUserProfile,
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.register(
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
full_name: 'Test User',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('User registered successfully!');
|
||||
expect(result.data.userprofile.user.email).toBe('test@example.com');
|
||||
expect(result.data.token).toBe('mock-access-token');
|
||||
}
|
||||
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
'SecurePassword123!',
|
||||
'Test User',
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject registration with weak password', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
password: 'weak',
|
||||
},
|
||||
});
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({
|
||||
isValid: false,
|
||||
feedback: 'Password must be at least 8 characters long.',
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.register(
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'weak',
|
||||
},
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow('Password must be at least 8 characters long.');
|
||||
|
||||
expect(mockedAuthService.registerAndLoginUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sanitize email input (trim and lowercase)', async () => {
|
||||
// Arrange
|
||||
const mockUserProfile = createMockUserProfile();
|
||||
const request = createMockRequest({
|
||||
body: {
|
||||
email: ' TEST@EXAMPLE.COM ',
|
||||
password: 'SecurePassword123!',
|
||||
},
|
||||
});
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({ isValid: true, feedback: '' });
|
||||
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||
newUserProfile: mockUserProfile,
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
await controller.register(
|
||||
{
|
||||
email: ' TEST@EXAMPLE.COM ',
|
||||
password: 'SecurePassword123!',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
'SecurePassword123!',
|
||||
undefined,
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PASSWORD RESET TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('forgotPassword()', () => {
|
||||
it('should return generic message regardless of email existence', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.resetPassword.mockResolvedValue('mock-reset-token');
|
||||
|
||||
// Act
|
||||
const result = await controller.forgotPassword({ email: 'test@example.com' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe(
|
||||
'If an account with that email exists, a password reset link has been sent.',
|
||||
);
|
||||
}
|
||||
expect(mockedAuthService.resetPassword).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return same message when email does not exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.resetPassword.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.forgotPassword({ email: 'nonexistent@example.com' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe(
|
||||
'If an account with that email exists, a password reset link has been sent.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include token in test environment', async () => {
|
||||
// Arrange
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.resetPassword.mockResolvedValue('mock-reset-token');
|
||||
|
||||
// Act
|
||||
const result = await controller.forgotPassword({ email: 'test@example.com' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.token).toBe('mock-reset-token');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword()', () => {
|
||||
it('should successfully reset password with valid token', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.updatePassword.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
const result = await controller.resetPassword(
|
||||
{ token: 'valid-reset-token', newPassword: 'NewSecurePassword123!' },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Password has been reset successfully.');
|
||||
}
|
||||
expect(mockedAuthService.updatePassword).toHaveBeenCalledWith(
|
||||
'valid-reset-token',
|
||||
'NewSecurePassword123!',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject reset with invalid token', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAuthService.updatePassword.mockResolvedValue(false);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
controller.resetPassword(
|
||||
{ token: 'invalid-token', newPassword: 'NewSecurePassword123!' },
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow('Invalid or expired password reset token.');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// TOKEN MANAGEMENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('refreshToken()', () => {
|
||||
it('should successfully refresh access token', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({
|
||||
cookies: { refreshToken: 'valid-refresh-token' },
|
||||
});
|
||||
|
||||
mockedAuthService.refreshAccessToken.mockResolvedValue({
|
||||
accessToken: 'new-access-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.refreshToken(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.token).toBe('new-access-token');
|
||||
}
|
||||
expect(mockedAuthService.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'valid-refresh-token',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when refresh token cookie is missing', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ cookies: {} });
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.refreshToken(request)).rejects.toThrow('Refresh token not found.');
|
||||
});
|
||||
|
||||
it('should reject when refresh token is invalid', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({
|
||||
cookies: { refreshToken: 'invalid-token' },
|
||||
});
|
||||
|
||||
mockedAuthService.refreshAccessToken.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.refreshToken(request)).rejects.toThrow(
|
||||
'Invalid or expired refresh token.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout()', () => {
|
||||
it('should successfully logout and clear refresh token cookie', async () => {
|
||||
// Arrange
|
||||
const mockCookie = vi.fn();
|
||||
const request = createMockRequest({
|
||||
cookies: { refreshToken: 'valid-refresh-token' },
|
||||
res: { cookie: mockCookie } as unknown as ExpressResponse,
|
||||
});
|
||||
|
||||
mockedAuthService.logout.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.logout(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Logged out successfully.');
|
||||
}
|
||||
expect(mockCookie).toHaveBeenCalledWith(
|
||||
'refreshToken',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0, httpOnly: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed even without refresh token cookie', async () => {
|
||||
// Arrange
|
||||
const mockCookie = vi.fn();
|
||||
const request = createMockRequest({
|
||||
cookies: {},
|
||||
res: { cookie: mockCookie } as unknown as ExpressResponse,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.logout(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Logged out successfully.');
|
||||
}
|
||||
expect(mockedAuthService.logout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ cookies: {} });
|
||||
const mockCookie = vi.fn();
|
||||
(request.res as ExpressResponse).cookie = mockCookie;
|
||||
|
||||
// Act
|
||||
const result = await controller.logout(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
827
src/controllers/auth.controller.ts
Normal file
827
src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
// src/controllers/auth.controller.ts
|
||||
// ============================================================================
|
||||
// AUTH CONTROLLER
|
||||
// ============================================================================
|
||||
// Handles user authentication and authorization endpoints including:
|
||||
// - User registration and login
|
||||
// - Password reset flow (forgot-password, reset-password)
|
||||
// - JWT token refresh and logout
|
||||
// - OAuth initiation (Google, GitHub) - Note: callbacks handled via Express middleware
|
||||
//
|
||||
// This controller implements the authentication API following ADR-028 (API Response Standards)
|
||||
// and integrates with the existing passport-based authentication system.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Route,
|
||||
Tags,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import passport from '../config/passport';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
import { authService } from '../services/authService';
|
||||
import { UniqueConstraintError, ValidationError } from '../services/db/errors.db';
|
||||
import type { UserProfile } from '../types';
|
||||
import { validatePasswordStrength } from '../utils/authUtils';
|
||||
import {
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
forgotPasswordLimiter,
|
||||
resetPasswordLimiter,
|
||||
refreshTokenLimiter,
|
||||
logoutLimiter,
|
||||
} from '../config/rateLimiters';
|
||||
import type { AddressDto, UserProfileDto } from '../dtos/common.dto';
|
||||
|
||||
/**
|
||||
* User registration request body.
|
||||
*/
|
||||
interface RegisterRequest {
|
||||
/**
|
||||
* User's email address.
|
||||
* @format email
|
||||
* @example "user@example.com"
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* User's password. Must be at least 8 characters with good entropy.
|
||||
* @minLength 8
|
||||
* @example "SecurePassword123!"
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* User's full name (optional).
|
||||
* @example "John Doe"
|
||||
*/
|
||||
full_name?: string;
|
||||
|
||||
/**
|
||||
* URL to user's avatar image (optional).
|
||||
* @format uri
|
||||
*/
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful registration response data.
|
||||
*/
|
||||
interface RegisterResponseData {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** The created user's profile */
|
||||
userprofile: UserProfileDto;
|
||||
/** JWT access token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User login request body.
|
||||
*/
|
||||
interface LoginRequest {
|
||||
/**
|
||||
* User's email address.
|
||||
* @format email
|
||||
* @example "user@example.com"
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* User's password.
|
||||
* @example "SecurePassword123!"
|
||||
*/
|
||||
password: string;
|
||||
|
||||
/**
|
||||
* If true, refresh token lasts 30 days instead of session-only.
|
||||
*/
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful login response data.
|
||||
*/
|
||||
interface LoginResponseData {
|
||||
/** The authenticated user's profile */
|
||||
userprofile: UserProfileDto;
|
||||
/** JWT access token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password request body.
|
||||
*/
|
||||
interface ForgotPasswordRequest {
|
||||
/**
|
||||
* Email address of the account to reset.
|
||||
* @format email
|
||||
* @example "user@example.com"
|
||||
*/
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password response data.
|
||||
*/
|
||||
interface ForgotPasswordResponseData {
|
||||
/** Generic success message (same for existing and non-existing emails for security) */
|
||||
message: string;
|
||||
/** Reset token (only included in test environment for testability) */
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password request body.
|
||||
*/
|
||||
interface ResetPasswordRequest {
|
||||
/**
|
||||
* Password reset token from email.
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* New password. Must be at least 8 characters with good entropy.
|
||||
* @minLength 8
|
||||
*/
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout response data.
|
||||
*/
|
||||
interface LogoutResponseData {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token refresh response data.
|
||||
*/
|
||||
interface RefreshTokenResponseData {
|
||||
/** New JWT access token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message response data.
|
||||
*/
|
||||
interface MessageResponseData {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM ERROR CLASSES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authentication error for login failures and related issues.
|
||||
*/
|
||||
class AuthenticationError extends Error {
|
||||
public status: number;
|
||||
|
||||
constructor(message: string, status: number = 401) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DTO CONVERSION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Converts a UserProfile to a UserProfileDto.
|
||||
*
|
||||
* This conversion is necessary because UserProfile contains Address which
|
||||
* contains GeoJSONPoint with tuple type coordinates: [number, number].
|
||||
* tsoa cannot serialize tuples, so we flatten to separate lat/lng fields.
|
||||
*
|
||||
* @param userProfile The UserProfile from the service layer
|
||||
* @returns A UserProfileDto safe for tsoa serialization
|
||||
*/
|
||||
function toUserProfileDto(userProfile: UserProfile): UserProfileDto {
|
||||
const addressDto: AddressDto | null = userProfile.address
|
||||
? {
|
||||
address_id: userProfile.address.address_id,
|
||||
address_line_1: userProfile.address.address_line_1,
|
||||
address_line_2: userProfile.address.address_line_2,
|
||||
city: userProfile.address.city,
|
||||
province_state: userProfile.address.province_state,
|
||||
postal_code: userProfile.address.postal_code,
|
||||
country: userProfile.address.country,
|
||||
latitude:
|
||||
userProfile.address.latitude ?? userProfile.address.location?.coordinates[1] ?? null,
|
||||
longitude:
|
||||
userProfile.address.longitude ?? userProfile.address.location?.coordinates[0] ?? null,
|
||||
created_at: userProfile.address.created_at,
|
||||
updated_at: userProfile.address.updated_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
full_name: userProfile.full_name,
|
||||
avatar_url: userProfile.avatar_url,
|
||||
address_id: userProfile.address_id,
|
||||
points: userProfile.points,
|
||||
role: userProfile.role,
|
||||
preferences: userProfile.preferences,
|
||||
created_by: userProfile.created_by,
|
||||
updated_by: userProfile.updated_by,
|
||||
created_at: userProfile.created_at,
|
||||
updated_at: userProfile.updated_at,
|
||||
user: {
|
||||
user_id: userProfile.user.user_id,
|
||||
email: userProfile.user.email,
|
||||
created_at: userProfile.user.created_at,
|
||||
updated_at: userProfile.user.updated_at,
|
||||
},
|
||||
address: addressDto,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTH CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authentication controller handling user registration, login, password reset,
|
||||
* and token management.
|
||||
*
|
||||
* OAuth endpoints (Google, GitHub) use passport middleware for redirect-based
|
||||
* authentication flows and are handled differently than standard JSON endpoints.
|
||||
*/
|
||||
@Route('auth')
|
||||
@Tags('Auth')
|
||||
export class AuthController extends BaseController {
|
||||
// ==========================================================================
|
||||
// REGISTRATION
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Register a new user account.
|
||||
*
|
||||
* Creates a new user with the provided credentials and returns authentication tokens.
|
||||
* The password must be at least 8 characters with good entropy (mix of characters).
|
||||
*
|
||||
* @summary Register a new user
|
||||
* @param requestBody User registration data
|
||||
* @param request Express request object (for logging and cookies)
|
||||
* @returns User profile and JWT token on successful registration
|
||||
*/
|
||||
@Post('register')
|
||||
@Middlewares(registerLimiter)
|
||||
@SuccessResponse(201, 'User registered successfully')
|
||||
@Response<ErrorResponse>(400, 'Validation error (weak password)')
|
||||
@Response<ErrorResponse>(409, 'Email already registered')
|
||||
@Response<ErrorResponse>(429, 'Too many registration attempts')
|
||||
public async register(
|
||||
@Body() requestBody: RegisterRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RegisterResponseData>> {
|
||||
const { email, password, full_name, avatar_url } = this.sanitizeRegisterInput(requestBody);
|
||||
const reqLog = request.log;
|
||||
|
||||
// Validate password strength
|
||||
const strength = validatePasswordStrength(password);
|
||||
if (!strength.isValid) {
|
||||
throw new ValidationError([], strength.feedback);
|
||||
}
|
||||
|
||||
try {
|
||||
const { newUserProfile, accessToken, refreshToken } = await authService.registerAndLoginUser(
|
||||
email,
|
||||
password,
|
||||
full_name,
|
||||
avatar_url,
|
||||
reqLog,
|
||||
);
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
this.setRefreshTokenCookie(request.res!, refreshToken, false);
|
||||
|
||||
this.setStatus(201);
|
||||
return this.success({
|
||||
message: 'User registered successfully!',
|
||||
userprofile: toUserProfileDto(newUserProfile),
|
||||
token: accessToken,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
this.setStatus(409);
|
||||
throw error;
|
||||
}
|
||||
reqLog.error({ error }, `User registration route failed for email: ${email}.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// LOGIN
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Login with email and password.
|
||||
*
|
||||
* Authenticates user credentials via the local passport strategy and returns JWT tokens.
|
||||
* Failed login attempts are tracked for account lockout protection.
|
||||
*
|
||||
* @summary Login with email and password
|
||||
* @param requestBody Login credentials
|
||||
* @param request Express request object
|
||||
* @returns User profile and JWT token on successful authentication
|
||||
*/
|
||||
@Post('login')
|
||||
@Middlewares(loginLimiter)
|
||||
@SuccessResponse(200, 'Login successful')
|
||||
@Response<ErrorResponse>(401, 'Invalid credentials or account locked')
|
||||
@Response<ErrorResponse>(429, 'Too many login attempts')
|
||||
public async login(
|
||||
@Body() requestBody: LoginRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<LoginResponseData>> {
|
||||
const { email, password, rememberMe } = this.sanitizeLoginInput(requestBody);
|
||||
const reqLog = request.log;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Attach sanitized email to request body for passport
|
||||
request.body.email = email;
|
||||
request.body.password = password;
|
||||
|
||||
passport.authenticate(
|
||||
'local',
|
||||
{ session: false },
|
||||
async (err: Error | null, user: Express.User | false, info: { message: string }) => {
|
||||
reqLog.debug(`[API /login] Received login request for email: ${email}`);
|
||||
|
||||
if (err) {
|
||||
reqLog.error({ err }, '[API /login] Passport reported an error.');
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
reqLog.warn({ info }, '[API /login] Passport reported NO USER found.');
|
||||
const authError = new AuthenticationError(info?.message || 'Login failed');
|
||||
return reject(authError);
|
||||
}
|
||||
|
||||
reqLog.info(
|
||||
{ userId: (user as UserProfile).user?.user_id },
|
||||
'[API /login] User authenticated.',
|
||||
);
|
||||
|
||||
try {
|
||||
const userProfile = user as UserProfile;
|
||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
|
||||
userProfile,
|
||||
reqLog,
|
||||
);
|
||||
reqLog.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||
|
||||
// Set refresh token cookie
|
||||
this.setRefreshTokenCookie(request.res!, refreshToken, rememberMe || false);
|
||||
|
||||
resolve(
|
||||
this.success({ userprofile: toUserProfileDto(userProfile), token: accessToken }),
|
||||
);
|
||||
} catch (tokenErr) {
|
||||
const email = (user as UserProfile)?.user?.email || request.body.email;
|
||||
reqLog.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
|
||||
reject(tokenErr);
|
||||
}
|
||||
},
|
||||
)(request, request.res!, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PASSWORD RESET FLOW
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Request a password reset.
|
||||
*
|
||||
* Sends a password reset email if the account exists. For security, always returns
|
||||
* the same response whether the email exists or not to prevent email enumeration.
|
||||
*
|
||||
* @summary Request password reset email
|
||||
* @param requestBody Email address for password reset
|
||||
* @param request Express request object
|
||||
* @returns Generic success message (same for existing and non-existing emails)
|
||||
*/
|
||||
@Post('forgot-password')
|
||||
@Middlewares(forgotPasswordLimiter)
|
||||
@SuccessResponse(200, 'Request processed')
|
||||
@Response<ErrorResponse>(429, 'Too many password reset requests')
|
||||
public async forgotPassword(
|
||||
@Body() requestBody: ForgotPasswordRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ForgotPasswordResponseData>> {
|
||||
const email = this.sanitizeEmail(requestBody.email);
|
||||
const reqLog = request.log;
|
||||
|
||||
try {
|
||||
const token = await authService.resetPassword(email, reqLog);
|
||||
|
||||
// Response payload - token only included in test environment for testability
|
||||
const responsePayload: ForgotPasswordResponseData = {
|
||||
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;
|
||||
}
|
||||
|
||||
return this.success(responsePayload);
|
||||
} catch (error) {
|
||||
reqLog.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token.
|
||||
*
|
||||
* Resets the user's password using a valid reset token from the forgot-password email.
|
||||
* The token is single-use and expires after 1 hour.
|
||||
*
|
||||
* @summary Reset password with token
|
||||
* @param requestBody Reset token and new password
|
||||
* @param request Express request object
|
||||
* @returns Success message on password reset
|
||||
*/
|
||||
@Post('reset-password')
|
||||
@Middlewares(resetPasswordLimiter)
|
||||
@SuccessResponse(200, 'Password reset successful')
|
||||
@Response<ErrorResponse>(400, 'Invalid or expired token, or weak password')
|
||||
@Response<ErrorResponse>(429, 'Too many reset attempts')
|
||||
public async resetPassword(
|
||||
@Body() requestBody: ResetPasswordRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<MessageResponseData>> {
|
||||
const { token, newPassword } = requestBody;
|
||||
const reqLog = request.log;
|
||||
|
||||
try {
|
||||
const resetSuccessful = await authService.updatePassword(token, newPassword, reqLog);
|
||||
|
||||
if (!resetSuccessful) {
|
||||
this.setStatus(400);
|
||||
throw new ValidationError([], 'Invalid or expired password reset token.');
|
||||
}
|
||||
|
||||
return this.success({ message: 'Password has been reset successfully.' });
|
||||
} catch (error) {
|
||||
reqLog.error({ error }, 'An error occurred during password reset.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// TOKEN MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Refresh access token.
|
||||
*
|
||||
* Uses the refresh token cookie to issue a new access token.
|
||||
* The refresh token itself is not rotated to allow multiple active sessions.
|
||||
*
|
||||
* @summary Refresh access token
|
||||
* @param request Express request object (contains refresh token cookie)
|
||||
* @returns New JWT access token
|
||||
*/
|
||||
@Post('refresh-token')
|
||||
@Middlewares(refreshTokenLimiter)
|
||||
@SuccessResponse(200, 'New access token issued')
|
||||
@Response<ErrorResponse>(401, 'Refresh token not found')
|
||||
@Response<ErrorResponse>(403, 'Invalid or expired refresh token')
|
||||
@Response<ErrorResponse>(429, 'Too many refresh attempts')
|
||||
public async refreshToken(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RefreshTokenResponseData>> {
|
||||
const refreshToken = request.cookies?.refreshToken;
|
||||
const reqLog = request.log;
|
||||
|
||||
if (!refreshToken) {
|
||||
this.setStatus(401);
|
||||
throw new AuthenticationError('Refresh token not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.refreshAccessToken(refreshToken, reqLog);
|
||||
|
||||
if (!result) {
|
||||
this.setStatus(403);
|
||||
throw new AuthenticationError('Invalid or expired refresh token.', 403);
|
||||
}
|
||||
|
||||
return this.success({ token: result.accessToken });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
reqLog.error({ error }, 'An error occurred during /refresh-token.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user.
|
||||
*
|
||||
* Invalidates the refresh token and clears the cookie.
|
||||
* The access token will remain valid until it expires (15 minutes).
|
||||
*
|
||||
* @summary Logout user
|
||||
* @param request Express request object
|
||||
* @returns Success message
|
||||
*/
|
||||
@Post('logout')
|
||||
@Middlewares(logoutLimiter)
|
||||
@SuccessResponse(200, 'Logged out successfully')
|
||||
@Response<ErrorResponse>(429, 'Too many logout attempts')
|
||||
public async logout(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<LogoutResponseData>> {
|
||||
const refreshToken = request.cookies?.refreshToken;
|
||||
const reqLog = request.log;
|
||||
|
||||
if (refreshToken) {
|
||||
// Invalidate the token in the database (fire and forget)
|
||||
authService.logout(refreshToken, reqLog).catch((err: Error) => {
|
||||
reqLog.error({ error: err }, 'Logout token invalidation failed in background.');
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the refresh token cookie
|
||||
this.clearRefreshTokenCookie(request.res!);
|
||||
|
||||
return this.success({ message: 'Logged out successfully.' });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// OAUTH INITIATION ENDPOINTS
|
||||
// ==========================================================================
|
||||
// Note: OAuth callback endpoints are handled by Express middleware due to
|
||||
// their redirect-based nature. These initiation endpoints redirect to the
|
||||
// OAuth provider and are documented here for completeness.
|
||||
//
|
||||
// The actual callbacks (/auth/google/callback, /auth/github/callback) remain
|
||||
// in the Express routes file (auth.routes.ts) because tsoa controllers are
|
||||
// designed for JSON APIs, not redirect-based OAuth flows.
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Initiate Google OAuth login.
|
||||
*
|
||||
* Redirects to Google for authentication. After successful authentication,
|
||||
* Google redirects back to /auth/google/callback with an authorization code.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary Initiate Google OAuth
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('google')
|
||||
@Response(302, 'Redirects to Google OAuth consent screen')
|
||||
public async googleAuth(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate('google', { session: false })(request, request.res!, (err: unknown) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth callback.
|
||||
*
|
||||
* Handles the callback from Google after user authentication.
|
||||
* On success, redirects to the frontend with an access token in the query string.
|
||||
* On failure, redirects to the frontend with an error parameter.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary Google OAuth callback
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('google/callback')
|
||||
@Response(302, 'Redirects to frontend with token or error')
|
||||
public async googleAuthCallback(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate(
|
||||
'google',
|
||||
{ session: false, failureRedirect: '/?error=google_auth_failed' },
|
||||
async (err: Error | null, user: Express.User | false) => {
|
||||
if (err) {
|
||||
request.log.error({ error: err }, 'Google OAuth authentication error');
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
await this.handleOAuthCallback(
|
||||
'google',
|
||||
user as UserProfile | false,
|
||||
request,
|
||||
request.res!,
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
)(request, request.res!, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate GitHub OAuth login.
|
||||
*
|
||||
* Redirects to GitHub for authentication. After successful authentication,
|
||||
* GitHub redirects back to /auth/github/callback with an authorization code.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary Initiate GitHub OAuth
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('github')
|
||||
@Response(302, 'Redirects to GitHub OAuth consent screen')
|
||||
public async githubAuth(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate('github', { session: false })(request, request.res!, (err: unknown) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth callback.
|
||||
*
|
||||
* Handles the callback from GitHub after user authentication.
|
||||
* On success, redirects to the frontend with an access token in the query string.
|
||||
* On failure, redirects to the frontend with an error parameter.
|
||||
*
|
||||
* Note: This endpoint performs a redirect, not a JSON response.
|
||||
*
|
||||
* @summary GitHub OAuth callback
|
||||
* @param request Express request object
|
||||
*/
|
||||
@Get('github/callback')
|
||||
@Response(302, 'Redirects to frontend with token or error')
|
||||
public async githubAuthCallback(@Request() request: ExpressRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
passport.authenticate(
|
||||
'github',
|
||||
{ session: false, failureRedirect: '/?error=github_auth_failed' },
|
||||
async (err: Error | null, user: Express.User | false) => {
|
||||
if (err) {
|
||||
request.log.error({ error: err }, 'GitHub OAuth authentication error');
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
await this.handleOAuthCallback(
|
||||
'github',
|
||||
user as UserProfile | false,
|
||||
request,
|
||||
request.res!,
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
)(request, request.res!, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PRIVATE HELPER METHODS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Sanitizes and normalizes email input.
|
||||
* Trims whitespace and converts to lowercase.
|
||||
*/
|
||||
private sanitizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes registration input by trimming and normalizing values.
|
||||
*/
|
||||
private sanitizeRegisterInput(input: RegisterRequest): RegisterRequest {
|
||||
return {
|
||||
email: this.sanitizeEmail(input.email),
|
||||
password: input.password.trim(),
|
||||
full_name: input.full_name?.trim() || undefined,
|
||||
avatar_url: input.avatar_url?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes login input by trimming and normalizing values.
|
||||
*/
|
||||
private sanitizeLoginInput(input: LoginRequest): LoginRequest {
|
||||
return {
|
||||
email: this.sanitizeEmail(input.email),
|
||||
password: input.password,
|
||||
rememberMe: input.rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the refresh token as an httpOnly cookie.
|
||||
*
|
||||
* @param res Express response object
|
||||
* @param refreshToken The refresh token to set
|
||||
* @param rememberMe If true, cookie persists for 30 days; otherwise 7 days
|
||||
*/
|
||||
private setRefreshTokenCookie(
|
||||
res: ExpressResponse,
|
||||
refreshToken: string,
|
||||
rememberMe: boolean,
|
||||
): void {
|
||||
const maxAge = rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the refresh token cookie by setting it to expire immediately.
|
||||
*
|
||||
* @param res Express response object
|
||||
*/
|
||||
private clearRefreshTokenCookie(res: ExpressResponse): void {
|
||||
res.cookie('refreshToken', '', {
|
||||
httpOnly: true,
|
||||
maxAge: 0,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles OAuth callback by generating tokens and redirecting to frontend.
|
||||
*
|
||||
* @param provider OAuth provider name ('google' or 'github')
|
||||
* @param user Authenticated user profile or false if authentication failed
|
||||
* @param request Express request object
|
||||
* @param res Express response object
|
||||
*/
|
||||
private async handleOAuthCallback(
|
||||
provider: 'google' | 'github',
|
||||
user: UserProfile | false,
|
||||
request: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<void> {
|
||||
const reqLog = request.log;
|
||||
|
||||
if (!user || !user.user) {
|
||||
reqLog.error('OAuth callback received but no user profile found');
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(user, reqLog);
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
});
|
||||
|
||||
// Redirect to frontend with provider-specific token parameter
|
||||
const tokenParam = provider === 'google' ? 'googleAuthToken' : 'githubAuthToken';
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?${tokenParam}=${accessToken}`);
|
||||
} catch (err) {
|
||||
reqLog.error({ error: err }, `Failed to complete ${provider} OAuth login`);
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
344
src/controllers/base.controller.ts
Normal file
344
src/controllers/base.controller.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// src/controllers/base.controller.ts
|
||||
// ============================================================================
|
||||
// BASE CONTROLLER FOR TSOA
|
||||
// ============================================================================
|
||||
// Provides a standardized base class for all tsoa controllers, ensuring
|
||||
// consistent response formatting, error handling, and access to common
|
||||
// utilities across the API.
|
||||
//
|
||||
// All controller methods should use the helper methods provided here to
|
||||
// construct responses, ensuring compliance with ADR-028 (API Response Format).
|
||||
// ============================================================================
|
||||
|
||||
import { Controller } from 'tsoa';
|
||||
import type {
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
PaginatedResponse,
|
||||
PaginationInput,
|
||||
PaginationMeta,
|
||||
ResponseMeta,
|
||||
ControllerErrorCodeType,
|
||||
} from './types';
|
||||
import { ControllerErrorCode } from './types';
|
||||
|
||||
/**
|
||||
* Base controller class providing standardized response helpers and error handling.
|
||||
*
|
||||
* All tsoa controllers should extend this class to ensure consistent API
|
||||
* response formatting per ADR-028.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Route, Get, Tags } from 'tsoa';
|
||||
* import { BaseController } from './base.controller';
|
||||
* import type { SuccessResponse } from './types';
|
||||
*
|
||||
* @Route('users')
|
||||
* @Tags('Users')
|
||||
* export class UsersController extends BaseController {
|
||||
* @Get('{id}')
|
||||
* public async getUser(id: string): Promise<SuccessResponse<User>> {
|
||||
* const user = await userService.getUserById(id);
|
||||
* return this.success(user);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class BaseController extends Controller {
|
||||
// ==========================================================================
|
||||
// SUCCESS RESPONSE HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Creates a standard success response envelope.
|
||||
*
|
||||
* @param data - The response payload
|
||||
* @param statusCode - HTTP status code (default: 200)
|
||||
* @param meta - Optional metadata (requestId, timestamp)
|
||||
* @returns A SuccessResponse object matching ADR-028 format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple success response
|
||||
* return this.success({ id: 1, name: 'Item' });
|
||||
*
|
||||
* // Success with 201 Created
|
||||
* this.setStatus(201);
|
||||
* return this.success(newUser);
|
||||
*
|
||||
* // Success with metadata
|
||||
* return this.success(data, { requestId: 'abc-123' });
|
||||
* ```
|
||||
*/
|
||||
protected success<T>(data: T, meta?: Omit<ResponseMeta, 'pagination'>): SuccessResponse<T> {
|
||||
const response: SuccessResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
|
||||
if (meta) {
|
||||
response.meta = meta;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a paginated success response with pagination metadata.
|
||||
*
|
||||
* @param data - Array of items for the current page
|
||||
* @param pagination - Pagination input (page, limit, total)
|
||||
* @param meta - Optional additional metadata
|
||||
* @returns A PaginatedResponse object with calculated pagination info
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { users, total } = await userService.listUsers({ page, limit });
|
||||
* return this.paginated(users, { page, limit, total });
|
||||
* ```
|
||||
*/
|
||||
protected paginated<T>(
|
||||
data: T[],
|
||||
pagination: PaginationInput,
|
||||
meta?: Omit<ResponseMeta, 'pagination'>,
|
||||
): PaginatedResponse<T> {
|
||||
const paginationMeta = this.calculatePagination(pagination);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
meta: {
|
||||
...meta,
|
||||
pagination: paginationMeta,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a success response with just a message.
|
||||
* Useful for operations that complete successfully but don't return data.
|
||||
*
|
||||
* @param message - Success message
|
||||
* @returns A SuccessResponse with a message object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // After deleting a resource
|
||||
* return this.message('User deleted successfully');
|
||||
*
|
||||
* // After an action that doesn't return data
|
||||
* return this.message('Password updated successfully');
|
||||
* ```
|
||||
*/
|
||||
protected message(message: string): SuccessResponse<{ message: string }> {
|
||||
return this.success({ message });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ERROR RESPONSE HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Creates a standard error response envelope.
|
||||
*
|
||||
* Note: For most error cases, you should throw an appropriate error class
|
||||
* (NotFoundError, ValidationError, etc.) and let the global error handler
|
||||
* format the response. Use this method only when you need fine-grained
|
||||
* control over the error response format.
|
||||
*
|
||||
* @param code - Machine-readable error code
|
||||
* @param message - Human-readable error message
|
||||
* @param details - Optional error details (validation errors, etc.)
|
||||
* @param meta - Optional metadata (requestId for error tracking)
|
||||
* @returns An ErrorResponse object matching ADR-028 format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Manual error response (prefer throwing errors instead)
|
||||
* this.setStatus(400);
|
||||
* return this.error(
|
||||
* ControllerErrorCode.BAD_REQUEST,
|
||||
* 'Invalid operation',
|
||||
* { reason: 'Cannot delete last admin user' }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
protected error(
|
||||
code: ControllerErrorCodeType | string,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
meta?: Pick<ResponseMeta, 'requestId' | 'timestamp'>,
|
||||
): ErrorResponse {
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
if (details !== undefined) {
|
||||
response.error.details = details;
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
response.meta = meta;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PAGINATION HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Calculates pagination metadata from input parameters.
|
||||
*
|
||||
* @param input - Pagination input (page, limit, total)
|
||||
* @returns Calculated pagination metadata
|
||||
*/
|
||||
protected calculatePagination(input: PaginationInput): PaginationMeta {
|
||||
const { page, limit, total } = input;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes pagination parameters with defaults and bounds.
|
||||
*
|
||||
* @param page - Requested page number (defaults to 1)
|
||||
* @param limit - Requested page size (defaults to 20, max 100)
|
||||
* @returns Normalized page and limit values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get()
|
||||
* public async listUsers(
|
||||
* @Query() page?: number,
|
||||
* @Query() limit?: number,
|
||||
* ): Promise<PaginatedResponse<User>> {
|
||||
* const { page: p, limit: l } = this.normalizePagination(page, limit);
|
||||
* // p and l are now safe to use with guaranteed bounds
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
protected normalizePagination(page?: number, limit?: number): { page: number; limit: number } {
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
return {
|
||||
page: Math.max(DEFAULT_PAGE, Math.floor(page ?? DEFAULT_PAGE)),
|
||||
limit: Math.min(MAX_LIMIT, Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT))),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// HTTP STATUS CODE HELPERS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Sets HTTP 201 Created status and returns success response.
|
||||
* Use for POST endpoints that create new resources.
|
||||
*
|
||||
* @param data - The created resource
|
||||
* @param meta - Optional metadata
|
||||
* @returns SuccessResponse with 201 status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Post()
|
||||
* public async createUser(body: CreateUserRequest): Promise<SuccessResponse<User>> {
|
||||
* const user = await userService.createUser(body);
|
||||
* return this.created(user);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
protected created<T>(data: T, meta?: Omit<ResponseMeta, 'pagination'>): SuccessResponse<T> {
|
||||
this.setStatus(201);
|
||||
return this.success(data, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets HTTP 204 No Content status.
|
||||
* Use for DELETE endpoints or operations that succeed without returning data.
|
||||
*
|
||||
* Note: tsoa requires a return type, so this returns undefined.
|
||||
* The actual HTTP response will have no body.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Delete('{id}')
|
||||
* public async deleteUser(id: string): Promise<void> {
|
||||
* await userService.deleteUser(id);
|
||||
* return this.noContent();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
protected noContent(): void {
|
||||
this.setStatus(204);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ERROR CODE CONSTANTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Standard error codes for use in error responses.
|
||||
* Exposed as a protected property for use in derived controllers.
|
||||
*/
|
||||
protected readonly ErrorCode = ControllerErrorCode;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTROLLER ERROR CLASSES
|
||||
// ============================================================================
|
||||
// Error classes that can be thrown from controllers and will be handled
|
||||
// by the global error handler to produce appropriate HTTP responses.
|
||||
// These re-export the repository errors for convenience.
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ForbiddenError,
|
||||
UniqueConstraintError,
|
||||
RepositoryError,
|
||||
} from '../services/db/errors.db';
|
||||
|
||||
// ============================================================================
|
||||
// RE-EXPORTS
|
||||
// ============================================================================
|
||||
// Re-export types for convenient imports in controller files.
|
||||
// ============================================================================
|
||||
|
||||
export { ControllerErrorCode } from './types';
|
||||
export type {
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
PaginatedResponse,
|
||||
PaginationInput,
|
||||
PaginationMeta,
|
||||
PaginationParams,
|
||||
ResponseMeta,
|
||||
RequestContext,
|
||||
AuthenticatedUser,
|
||||
MessageResponse,
|
||||
HealthResponse,
|
||||
DetailedHealthResponse,
|
||||
ServiceHealth,
|
||||
ValidationIssue,
|
||||
ValidationErrorResponse,
|
||||
ControllerErrorCodeType,
|
||||
ApiResponse,
|
||||
} from './types';
|
||||
467
src/controllers/budget.controller.test.ts
Normal file
467
src/controllers/budget.controller.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
// src/controllers/budget.controller.test.ts
|
||||
// ============================================================================
|
||||
// BUDGET CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the BudgetController class. These tests verify controller
|
||||
// logic in isolation by mocking the budget repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock budget repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
budgetRepo: {
|
||||
getBudgetsForUser: vi.fn(),
|
||||
createBudget: vi.fn(),
|
||||
updateBudget: vi.fn(),
|
||||
deleteBudget: vi.fn(),
|
||||
getSpendingByCategory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { budgetRepo } from '../services/db/index.db';
|
||||
import { BudgetController } from './budget.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedBudgetRepo = budgetRepo as Mocked<typeof budgetRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock budget record.
|
||||
*/
|
||||
function createMockBudget(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
budget_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
name: 'Monthly Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly' as const,
|
||||
start_date: '2024-01-01',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock spending by category record.
|
||||
*/
|
||||
function createMockSpendingByCategory(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
category_id: 1,
|
||||
category_name: 'Dairy & Eggs',
|
||||
total_cents: 2500,
|
||||
item_count: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('BudgetController', () => {
|
||||
let controller: BudgetController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new BudgetController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LIST BUDGETS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getBudgets()', () => {
|
||||
it('should return all budgets for the user', async () => {
|
||||
// Arrange
|
||||
const mockBudgets = [
|
||||
createMockBudget(),
|
||||
createMockBudget({ budget_id: 2, name: 'Weekly Snacks', period: 'weekly' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue(mockBudgets);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBudgets(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].name).toBe('Monthly Groceries');
|
||||
}
|
||||
expect(mockedBudgetRepo.getBudgetsForUser).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no budgets', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBudgets(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// CREATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
describe('createBudget()', () => {
|
||||
it('should create a new budget', async () => {
|
||||
// Arrange
|
||||
const mockBudget = createMockBudget();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.createBudget(request, {
|
||||
name: 'Monthly Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly',
|
||||
start_date: '2024-01-01',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Monthly Groceries');
|
||||
expect(result.data.amount_cents).toBe(50000);
|
||||
}
|
||||
expect(mockedBudgetRepo.createBudget).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.objectContaining({
|
||||
name: 'Monthly Groceries',
|
||||
amount_cents: 50000,
|
||||
period: 'monthly',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a weekly budget', async () => {
|
||||
// Arrange
|
||||
const mockBudget = createMockBudget({ period: 'weekly', amount_cents: 10000 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.createBudget(request, {
|
||||
name: 'Weekly Snacks',
|
||||
amount_cents: 10000,
|
||||
period: 'weekly',
|
||||
start_date: '2024-01-01',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.period).toBe('weekly');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// UPDATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
describe('updateBudget()', () => {
|
||||
it('should update an existing budget', async () => {
|
||||
// Arrange
|
||||
const mockUpdatedBudget = createMockBudget({ amount_cents: 60000 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateBudget(1, request, {
|
||||
amount_cents: 60000,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.amount_cents).toBe(60000);
|
||||
}
|
||||
expect(mockedBudgetRepo.updateBudget).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.objectContaining({ amount_cents: 60000 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update budget name', async () => {
|
||||
// Arrange
|
||||
const mockUpdatedBudget = createMockBudget({ name: 'Updated Budget Name' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateBudget(1, request, {
|
||||
name: 'Updated Budget Name',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Updated Budget Name');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject update with no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateBudget(1, request, {})).rejects.toThrow(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update multiple fields at once', async () => {
|
||||
// Arrange
|
||||
const mockUpdatedBudget = createMockBudget({
|
||||
name: 'New Name',
|
||||
amount_cents: 75000,
|
||||
period: 'weekly',
|
||||
});
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateBudget(1, request, {
|
||||
name: 'New Name',
|
||||
amount_cents: 75000,
|
||||
period: 'weekly',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedBudgetRepo.updateBudget).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.objectContaining({
|
||||
name: 'New Name',
|
||||
amount_cents: 75000,
|
||||
period: 'weekly',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DELETE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
describe('deleteBudget()', () => {
|
||||
it('should delete a budget', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.deleteBudget.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteBudget(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedBudgetRepo.deleteBudget).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SPENDING ANALYSIS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getSpendingAnalysis()', () => {
|
||||
it('should return spending breakdown by category', async () => {
|
||||
// Arrange
|
||||
const mockSpendingData = [
|
||||
createMockSpendingByCategory(),
|
||||
createMockSpendingByCategory({
|
||||
category_id: 2,
|
||||
category_name: 'Produce',
|
||||
total_cents: 3500,
|
||||
}),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getSpendingByCategory.mockResolvedValue(mockSpendingData);
|
||||
|
||||
// Act
|
||||
const result = await controller.getSpendingAnalysis('2024-01-01', '2024-01-31', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].category_name).toBe('Dairy & Eggs');
|
||||
}
|
||||
expect(mockedBudgetRepo.getSpendingByCategory).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'2024-01-01',
|
||||
'2024-01-31',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no spending data exists', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getSpendingByCategory.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getSpendingAnalysis('2024-01-01', '2024-01-31', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBudgets(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockBudget = createMockBudget();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
|
||||
|
||||
// Act
|
||||
const result = await controller.createBudget(request, {
|
||||
name: 'Test',
|
||||
amount_cents: 1000,
|
||||
period: 'weekly',
|
||||
start_date: '2024-01-01',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedBudgetRepo.deleteBudget.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteBudget(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
src/controllers/budget.controller.ts
Normal file
233
src/controllers/budget.controller.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// src/controllers/budget.controller.ts
|
||||
// ============================================================================
|
||||
// BUDGET CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing user budgets, including CRUD operations
|
||||
// and spending analysis. All endpoints require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { budgetRepo } from '../services/db/index.db';
|
||||
import type { Budget, SpendingByCategory, UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for creating a new budget.
|
||||
*/
|
||||
interface CreateBudgetRequest {
|
||||
/** Budget name */
|
||||
name: string;
|
||||
/** Budget amount in cents (must be positive) */
|
||||
amount_cents: number;
|
||||
/** Budget period - weekly or monthly */
|
||||
period: 'weekly' | 'monthly';
|
||||
/** Budget start date in YYYY-MM-DD format */
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for updating a budget.
|
||||
* All fields are optional, but at least one must be provided.
|
||||
*/
|
||||
interface UpdateBudgetRequest {
|
||||
/** Budget name */
|
||||
name?: string;
|
||||
/** Budget amount in cents (must be positive) */
|
||||
amount_cents?: number;
|
||||
/** Budget period - weekly or monthly */
|
||||
period?: 'weekly' | 'monthly';
|
||||
/** Budget start date in YYYY-MM-DD format */
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUDGET CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for managing user budgets.
|
||||
*
|
||||
* All endpoints require JWT authentication. Users can only access
|
||||
* their own budgets - the user ID is extracted from the JWT token.
|
||||
*/
|
||||
@Route('budgets')
|
||||
@Tags('Budgets')
|
||||
@Security('bearerAuth')
|
||||
export class BudgetController extends BaseController {
|
||||
// ==========================================================================
|
||||
// LIST BUDGETS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get all budgets for the authenticated user.
|
||||
*
|
||||
* Returns a list of all budgets owned by the authenticated user,
|
||||
* ordered by start date descending (newest first).
|
||||
*
|
||||
* @summary Get all budgets
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of user budgets
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'List of user budgets')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getBudgets(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Budget[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const budgets = await budgetRepo.getBudgetsForUser(userProfile.user.user_id, request.log);
|
||||
return this.success(budgets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CREATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a new budget for the authenticated user.
|
||||
*
|
||||
* Creates a budget with the specified name, amount, period, and start date.
|
||||
* The budget is automatically associated with the authenticated user.
|
||||
*
|
||||
* @summary Create budget
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Budget creation data
|
||||
* @returns The newly created budget
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'Budget created')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async createBudget(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: CreateBudgetRequest,
|
||||
): Promise<SuccessResponseType<Budget>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const newBudget = await budgetRepo.createBudget(userProfile.user.user_id, body, request.log);
|
||||
return this.created(newBudget);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// UPDATE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Update an existing budget.
|
||||
*
|
||||
* Updates the specified budget with the provided fields. At least one
|
||||
* field must be provided. The user must own the budget to update it.
|
||||
*
|
||||
* @summary Update budget
|
||||
* @param id Budget ID
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Fields to update
|
||||
* @returns The updated budget
|
||||
*/
|
||||
@Put('{id}')
|
||||
@SuccessResponse(200, 'Budget updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error - at least one field required')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Budget not found')
|
||||
public async updateBudget(
|
||||
@Path() id: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateBudgetRequest,
|
||||
): Promise<SuccessResponseType<Budget>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Validate at least one field is provided
|
||||
if (Object.keys(body).length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('At least one field to update must be provided.');
|
||||
}
|
||||
|
||||
const updatedBudget = await budgetRepo.updateBudget(
|
||||
id,
|
||||
userProfile.user.user_id,
|
||||
body,
|
||||
request.log,
|
||||
);
|
||||
return this.success(updatedBudget);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DELETE BUDGET
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Delete a budget.
|
||||
*
|
||||
* Permanently deletes the specified budget. The user must own
|
||||
* the budget to delete it.
|
||||
*
|
||||
* @summary Delete budget
|
||||
* @param id Budget ID
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Delete('{id}')
|
||||
@SuccessResponse(204, 'Budget deleted')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Budget not found')
|
||||
public async deleteBudget(@Path() id: number, @Request() request: ExpressRequest): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await budgetRepo.deleteBudget(id, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SPENDING ANALYSIS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get spending analysis by category.
|
||||
*
|
||||
* Returns a breakdown of spending by category for the specified date range.
|
||||
* This helps users understand their spending patterns relative to their budgets.
|
||||
*
|
||||
* @summary Get spending analysis
|
||||
* @param startDate Start date in YYYY-MM-DD format
|
||||
* @param endDate End date in YYYY-MM-DD format
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Spending breakdown by category
|
||||
*/
|
||||
@Get('spending-analysis')
|
||||
@SuccessResponse(200, 'Spending breakdown by category')
|
||||
@Response<ErrorResponse>(400, 'Invalid date format')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getSpendingAnalysis(
|
||||
@Query() startDate: string,
|
||||
@Query() endDate: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<SpendingByCategory[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const spendingData = await budgetRepo.getSpendingByCategory(
|
||||
userProfile.user.user_id,
|
||||
startDate,
|
||||
endDate,
|
||||
request.log,
|
||||
);
|
||||
return this.success(spendingData);
|
||||
}
|
||||
}
|
||||
333
src/controllers/category.controller.test.ts
Normal file
333
src/controllers/category.controller.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
// src/controllers/category.controller.test.ts
|
||||
// ============================================================================
|
||||
// CATEGORY CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the CategoryController class. These tests verify controller
|
||||
// logic in isolation by mocking the category database service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock category database service
|
||||
vi.mock('../services/db/category.db', () => ({
|
||||
CategoryDbService: {
|
||||
getAllCategories: vi.fn(),
|
||||
getCategoryByName: vi.fn(),
|
||||
getCategoryById: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { CategoryDbService } from '../services/db/category.db';
|
||||
import { CategoryController } from './category.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedCategoryDbService = CategoryDbService as Mocked<typeof CategoryDbService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock category record.
|
||||
*/
|
||||
function createMockCategory(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
category_id: 1,
|
||||
name: 'Dairy & Eggs',
|
||||
description: 'Milk, cheese, eggs, and dairy products',
|
||||
icon: 'dairy',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('CategoryController', () => {
|
||||
let controller: CategoryController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new CategoryController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LIST CATEGORIES
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAllCategories()', () => {
|
||||
it('should return all categories', async () => {
|
||||
// Arrange
|
||||
const mockCategories = [
|
||||
createMockCategory(),
|
||||
createMockCategory({ category_id: 2, name: 'Produce' }),
|
||||
createMockCategory({ category_id: 3, name: 'Meat & Seafood' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].name).toBe('Dairy & Eggs');
|
||||
}
|
||||
expect(mockedCategoryDbService.getAllCategories).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no categories exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LOOKUP BY NAME
|
||||
// ==========================================================================
|
||||
|
||||
describe('getCategoryByName()', () => {
|
||||
it('should return category when found by name', async () => {
|
||||
// Arrange
|
||||
const mockCategory = createMockCategory();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryByName.mockResolvedValue(mockCategory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName('Dairy & Eggs', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Dairy & Eggs');
|
||||
expect(result.data.category_id).toBe(1);
|
||||
}
|
||||
expect(mockedCategoryDbService.getCategoryByName).toHaveBeenCalledWith(
|
||||
'Dairy & Eggs',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when category not found', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryByName.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.getCategoryByName('Nonexistent Category', request)).rejects.toThrow(
|
||||
"Category 'Nonexistent Category' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when name is empty', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName('', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when name is whitespace only', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName(' ', request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GET BY ID
|
||||
// ==========================================================================
|
||||
|
||||
describe('getCategoryById()', () => {
|
||||
it('should return category when found by ID', async () => {
|
||||
// Arrange
|
||||
const mockCategory = createMockCategory();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryById.mockResolvedValue(mockCategory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.category_id).toBe(1);
|
||||
expect(result.data.name).toBe('Dairy & Eggs');
|
||||
}
|
||||
expect(mockedCategoryDbService.getCategoryById).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when category not found', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getCategoryById.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.getCategoryById(999, request)).rejects.toThrow(
|
||||
'Category with ID 999 not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for invalid ID (zero)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(0, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error for invalid ID (negative)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(-1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error for invalid ID (NaN)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryById(NaN, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ACCESS (NO AUTH REQUIRED)
|
||||
// ==========================================================================
|
||||
|
||||
describe('Public access', () => {
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockCategories = [createMockCategory()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockCategories = [createMockCategory()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedCategoryDbService.getAllCategories.mockResolvedValue(mockCategories);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllCategories(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for validation errors', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getCategoryByName('', request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/controllers/category.controller.ts
Normal file
137
src/controllers/category.controller.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// src/controllers/category.controller.ts
|
||||
// ============================================================================
|
||||
// CATEGORY CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for retrieving grocery categories. Categories are
|
||||
// predefined (e.g., "Dairy & Eggs", "Fruits & Vegetables") and are used
|
||||
// to organize items throughout the application.
|
||||
//
|
||||
// All endpoints are public (no authentication required).
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Path, Query, Request, SuccessResponse, Response } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController, NotFoundError } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { CategoryDbService, type Category } from '../services/db/category.db';
|
||||
|
||||
// ============================================================================
|
||||
// CATEGORY CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for retrieving grocery categories.
|
||||
*
|
||||
* Categories are system-defined and cannot be modified by users.
|
||||
* All endpoints are public and do not require authentication.
|
||||
*/
|
||||
@Route('categories')
|
||||
@Tags('Categories')
|
||||
export class CategoryController extends BaseController {
|
||||
// ==========================================================================
|
||||
// LIST CATEGORIES
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* List all available grocery categories.
|
||||
*
|
||||
* Returns all predefined grocery categories ordered alphabetically by name.
|
||||
* Use this endpoint to populate category dropdowns in the UI.
|
||||
*
|
||||
* @summary List all available grocery categories
|
||||
* @param request Express request for logging
|
||||
* @returns List of categories ordered alphabetically by name
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'List of categories ordered alphabetically by name')
|
||||
@Response<ErrorResponse>(500, 'Server error')
|
||||
public async getAllCategories(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Category[]>> {
|
||||
const categories = await CategoryDbService.getAllCategories(request.log);
|
||||
return this.success(categories);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// LOOKUP BY NAME
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Lookup category by name.
|
||||
*
|
||||
* Find a category by its name (case-insensitive). This endpoint is provided
|
||||
* for migration support to help clients transition from using category names
|
||||
* to category IDs.
|
||||
*
|
||||
* @summary Lookup category by name
|
||||
* @param name The category name to search for (case-insensitive)
|
||||
* @param request Express request for logging
|
||||
* @returns Category found
|
||||
*/
|
||||
@Get('lookup')
|
||||
@SuccessResponse(200, 'Category found')
|
||||
@Response<ErrorResponse>(400, 'Missing or invalid query parameter')
|
||||
@Response<ErrorResponse>(404, 'Category not found')
|
||||
public async getCategoryByName(
|
||||
@Query() name: string,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Category>> {
|
||||
// Validate name parameter
|
||||
if (!name || name.trim() === '') {
|
||||
this.setStatus(400);
|
||||
return this.error(
|
||||
this.ErrorCode.BAD_REQUEST,
|
||||
'Query parameter "name" is required and must be a non-empty string',
|
||||
) as unknown as SuccessResponseType<Category>;
|
||||
}
|
||||
|
||||
const category = await CategoryDbService.getCategoryByName(name, request.log);
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundError(`Category '${name}' not found`);
|
||||
}
|
||||
|
||||
return this.success(category);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GET BY ID
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get a specific category by ID.
|
||||
*
|
||||
* Retrieve detailed information about a single category.
|
||||
*
|
||||
* @summary Get a specific category by ID
|
||||
* @param id The category ID
|
||||
* @param request Express request for logging
|
||||
* @returns Category details
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Category details')
|
||||
@Response<ErrorResponse>(400, 'Invalid category ID')
|
||||
@Response<ErrorResponse>(404, 'Category not found')
|
||||
public async getCategoryById(
|
||||
@Path() id: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Category>> {
|
||||
// Validate ID
|
||||
if (isNaN(id) || id <= 0) {
|
||||
this.setStatus(400);
|
||||
return this.error(
|
||||
this.ErrorCode.BAD_REQUEST,
|
||||
'Invalid category ID. Must be a positive integer.',
|
||||
) as unknown as SuccessResponseType<Category>;
|
||||
}
|
||||
|
||||
const category = await CategoryDbService.getCategoryById(id, request.log);
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundError(`Category with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return this.success(category);
|
||||
}
|
||||
}
|
||||
254
src/controllers/deals.controller.test.ts
Normal file
254
src/controllers/deals.controller.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
// src/controllers/deals.controller.test.ts
|
||||
// ============================================================================
|
||||
// DEALS CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the DealsController class. These tests verify controller
|
||||
// logic in isolation by mocking the deals repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock deals repository
|
||||
vi.mock('../services/db/deals.db', () => ({
|
||||
dealsRepo: {
|
||||
findBestPricesForWatchedItems: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { dealsRepo } from '../services/db/deals.db';
|
||||
import { DealsController } from './deals.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDealsRepo = dealsRepo as Mocked<typeof dealsRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock watched item deal.
|
||||
*/
|
||||
function createMockWatchedItemDeal(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
watched_item_id: 1,
|
||||
master_item_id: 100,
|
||||
item_name: 'Milk 2%',
|
||||
current_price_cents: 350,
|
||||
regular_price_cents: 450,
|
||||
discount_percent: 22.2,
|
||||
store_name: 'Superstore',
|
||||
store_location_id: 5,
|
||||
flyer_id: 10,
|
||||
flyer_valid_from: '2024-01-15',
|
||||
flyer_valid_to: '2024-01-21',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('DealsController', () => {
|
||||
let controller: DealsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new DealsController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BEST WATCHED PRICES
|
||||
// ==========================================================================
|
||||
|
||||
describe('getBestWatchedPrices()', () => {
|
||||
it('should return best prices for watched items', async () => {
|
||||
// Arrange
|
||||
const mockDeals = [
|
||||
createMockWatchedItemDeal(),
|
||||
createMockWatchedItemDeal({
|
||||
watched_item_id: 2,
|
||||
item_name: 'Bread',
|
||||
current_price_cents: 250,
|
||||
}),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue(mockDeals);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].item_name).toBe('Milk 2%');
|
||||
expect(result.data[0].current_price_cents).toBe(350);
|
||||
}
|
||||
expect(mockedDealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no watched items', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array when no active deals exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should log successful fetch', async () => {
|
||||
// Arrange
|
||||
const mockDeals = [createMockWatchedItemDeal()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue(mockDeals);
|
||||
|
||||
// Act
|
||||
await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ dealCount: 1 },
|
||||
'Successfully fetched best watched item deals.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use user ID from authenticated profile', async () => {
|
||||
// Arrange
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(mockedDealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
|
||||
'custom-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDealsRepo.findBestPricesForWatchedItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getBestWatchedPrices(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/controllers/deals.controller.ts
Normal file
62
src/controllers/deals.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// src/controllers/deals.controller.ts
|
||||
// ============================================================================
|
||||
// DEALS CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for retrieving deal information, specifically the
|
||||
// best prices for items that the user is watching.
|
||||
//
|
||||
// All endpoints require authentication.
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Security, Request, SuccessResponse, Response } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { dealsRepo } from '../services/db/deals.db';
|
||||
import type { WatchedItemDeal, UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// DEALS CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for retrieving deal information.
|
||||
*
|
||||
* All endpoints require JWT authentication. The user ID is extracted
|
||||
* from the JWT token to retrieve user-specific deal information.
|
||||
*/
|
||||
@Route('deals')
|
||||
@Tags('Deals')
|
||||
@Security('bearerAuth')
|
||||
export class DealsController extends BaseController {
|
||||
// ==========================================================================
|
||||
// BEST WATCHED PRICES
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get best prices for watched items.
|
||||
*
|
||||
* Fetches the best current sale price for each of the authenticated user's
|
||||
* watched items. Only considers currently active flyers (valid_to >= today).
|
||||
* In case of price ties, the deal that is valid for the longest time is preferred.
|
||||
*
|
||||
* @summary Get best prices for watched items
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of best prices for watched items
|
||||
*/
|
||||
@Get('best-watched-prices')
|
||||
@SuccessResponse(200, 'List of best prices for watched items')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getBestWatchedPrices(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<WatchedItemDeal[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const deals = await dealsRepo.findBestPricesForWatchedItems(
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
request.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
|
||||
return this.success(deals);
|
||||
}
|
||||
}
|
||||
590
src/controllers/flyer.controller.test.ts
Normal file
590
src/controllers/flyer.controller.test.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
// src/controllers/flyer.controller.test.ts
|
||||
// ============================================================================
|
||||
// FLYER CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the FlyerController class. These tests verify controller
|
||||
// logic in isolation by mocking external dependencies like database repositories.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database repositories
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
flyerRepo: {
|
||||
getFlyers: vi.fn(),
|
||||
getFlyerById: vi.fn(),
|
||||
getFlyerItems: vi.fn(),
|
||||
getFlyerItemsForFlyers: vi.fn(),
|
||||
countFlyerItemsForFlyers: vi.fn(),
|
||||
trackFlyerItemInteraction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { FlyerController } from './flyer.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock flyer object.
|
||||
*/
|
||||
function createMockFlyer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
flyer_id: 1,
|
||||
file_name: 'test-flyer.jpg',
|
||||
image_url: '/uploads/flyers/test-flyer.jpg',
|
||||
icon_url: '/uploads/flyers/icons/test-flyer.jpg',
|
||||
checksum: 'abc123',
|
||||
store_id: 1,
|
||||
valid_from: '2024-01-01',
|
||||
valid_to: '2024-01-07',
|
||||
status: 'processed' as const,
|
||||
item_count: 10,
|
||||
uploaded_by: 'user-123',
|
||||
store: {
|
||||
store_id: 1,
|
||||
name: 'Test Store',
|
||||
logo_url: '/uploads/logos/store.jpg',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
locations: [],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock flyer item object.
|
||||
*/
|
||||
function createMockFlyerItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
flyer_item_id: 1,
|
||||
flyer_id: 1,
|
||||
item: 'Test Product',
|
||||
price_display: '$2.99',
|
||||
price_in_cents: 299,
|
||||
quantity: '1',
|
||||
quantity_num: 1,
|
||||
master_item_id: 100,
|
||||
master_item_name: 'Test Master Item',
|
||||
category_id: 5,
|
||||
category_name: 'Dairy',
|
||||
unit_price: { value: 299, unit: 'each' },
|
||||
product_id: null,
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('FlyerController', () => {
|
||||
let controller: FlyerController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new FlyerController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LIST ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getFlyers()', () => {
|
||||
it('should return flyers with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockFlyers = [createMockFlyer(), createMockFlyer({ flyer_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue(mockFlyers);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyers(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
20, // default limit
|
||||
0, // default offset
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect custom pagination parameters', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 50, 10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 50, 10);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 100, 0);
|
||||
});
|
||||
|
||||
it('should floor limit to minimum of 1', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, -5);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 1, 0);
|
||||
});
|
||||
|
||||
it('should normalize offset to 0 if negative', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 20, -10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 20, 0);
|
||||
});
|
||||
|
||||
it('should floor decimal pagination values', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getFlyers(request, 15.9, 5.7);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.getFlyers).toHaveBeenCalledWith(expect.anything(), 15, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getFlyerById()', () => {
|
||||
it('should return flyer by ID successfully', async () => {
|
||||
// Arrange
|
||||
const mockFlyer = createMockFlyer();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.flyer_id).toBe(1);
|
||||
expect(result.data.file_name).toBe('test-flyer.jpg');
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyerById).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should log successful retrieval', async () => {
|
||||
// Arrange
|
||||
const mockFlyer = createMockFlyer();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
await controller.getFlyerById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith({ flyerId: 1 }, 'Retrieved flyer by ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlyerItems()', () => {
|
||||
it('should return flyer items successfully', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
createMockFlyerItem(),
|
||||
createMockFlyerItem({ flyer_item_id: 2, item: 'Another Product' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].item).toBe('Test Product');
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyerItems).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should log item count', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockFlyerItem()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getFlyerItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ flyerId: 1, itemCount: 1 },
|
||||
'Retrieved flyer items',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array for flyer with no items', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerItems(999, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BATCH ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('batchFetchItems()', () => {
|
||||
it('should return items for multiple flyers', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
createMockFlyerItem({ flyer_id: 1 }),
|
||||
createMockFlyerItem({ flyer_id: 2, flyer_item_id: 2 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchFetchItems({ flyerIds: [1, 2, 3] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.getFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
[1, 2, 3],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log batch fetch details', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockFlyerItem()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.batchFetchItems({ flyerIds: [1, 2] }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ flyerCount: 2, itemCount: 1 },
|
||||
'Batch fetched flyer items',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no items found', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerItemsForFlyers.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchFetchItems({ flyerIds: [999, 1000] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchCountItems()', () => {
|
||||
it('should return total item count for multiple flyers', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(25);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchCountItems({ flyerIds: [1, 2, 3] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.count).toBe(25);
|
||||
}
|
||||
expect(mockedDb.flyerRepo.countFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
[1, 2, 3],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log count details', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(10);
|
||||
|
||||
// Act
|
||||
await controller.batchCountItems({ flyerIds: [1] }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ flyerCount: 1, totalItems: 10 },
|
||||
'Batch counted items',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 for empty flyer list', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.countFlyerItemsForFlyers.mockResolvedValue(0);
|
||||
|
||||
// Act
|
||||
const result = await controller.batchCountItems({ flyerIds: [] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.count).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// TRACKING ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('trackItemInteraction()', () => {
|
||||
it('should accept view tracking (fire-and-forget)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'view' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Tracking accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept click tracking', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'click' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Tracking accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should log error but not fail on tracking failure', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
// Make tracking fail
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Act
|
||||
const result = await controller.trackItemInteraction(1, { type: 'view' }, request);
|
||||
|
||||
// Assert - should still return success (fire-and-forget)
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Tracking accepted');
|
||||
}
|
||||
|
||||
// Wait for async error handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Error should be logged
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.any(Error),
|
||||
itemId: 1,
|
||||
interactionType: 'view',
|
||||
}),
|
||||
'Flyer item interaction tracking failed (fire-and-forget)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call tracking with correct parameters', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.trackFlyerItemInteraction.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.trackItemInteraction(42, { type: 'click' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(
|
||||
42,
|
||||
'click',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockFlyer = createMockFlyer();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.flyerRepo.getFlyerById.mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
const result = await controller.getFlyerById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
282
src/controllers/flyer.controller.ts
Normal file
282
src/controllers/flyer.controller.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// src/controllers/flyer.controller.ts
|
||||
// ============================================================================
|
||||
// FLYER CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing flyers and flyer items.
|
||||
// Implements endpoints for:
|
||||
// - Listing flyers with pagination
|
||||
// - Getting individual flyer details
|
||||
// - Getting items for a flyer
|
||||
// - Batch fetching items for multiple flyers
|
||||
// - Batch counting items for multiple flyers
|
||||
// - Tracking item interactions (fire-and-forget)
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Query,
|
||||
Body,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as db from '../services/db/index.db';
|
||||
import type { FlyerDto, FlyerItemDto } from '../dtos/common.dto';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
// Types for request bodies and custom response shapes that will appear in
|
||||
// the OpenAPI specification.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for batch fetching flyer items.
|
||||
*/
|
||||
interface BatchFetchRequest {
|
||||
/**
|
||||
* Array of flyer IDs to fetch items for.
|
||||
* @minItems 1
|
||||
* @example [1, 2, 3]
|
||||
*/
|
||||
flyerIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for batch counting flyer items.
|
||||
*/
|
||||
interface BatchCountRequest {
|
||||
/**
|
||||
* Array of flyer IDs to count items for.
|
||||
* @example [1, 2, 3]
|
||||
*/
|
||||
flyerIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for tracking item interactions.
|
||||
*/
|
||||
interface TrackInteractionRequest {
|
||||
/**
|
||||
* Type of interaction to track.
|
||||
* @example "view"
|
||||
*/
|
||||
type: 'view' | 'click';
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for batch item count.
|
||||
*/
|
||||
interface BatchCountResponse {
|
||||
/**
|
||||
* Total number of items across all requested flyers.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for tracking confirmation.
|
||||
*/
|
||||
interface TrackingResponse {
|
||||
/**
|
||||
* Confirmation message.
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FLYER CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for flyer management endpoints.
|
||||
*
|
||||
* Provides read-only access to flyers and flyer items for all users,
|
||||
* with analytics tracking capabilities.
|
||||
*/
|
||||
@Route('flyers')
|
||||
@Tags('Flyers')
|
||||
export class FlyerController extends BaseController {
|
||||
// ==========================================================================
|
||||
// LIST ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get all flyers.
|
||||
*
|
||||
* Returns a paginated list of all flyers, ordered by creation date (newest first).
|
||||
* Includes store information and location data for each flyer.
|
||||
*
|
||||
* @summary List all flyers
|
||||
* @param limit Maximum number of flyers to return (default: 20)
|
||||
* @param offset Number of flyers to skip for pagination (default: 0)
|
||||
* @returns Array of flyer objects with store information
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'List of flyers retrieved successfully')
|
||||
public async getFlyers(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
): Promise<SuccessResponseType<FlyerDto[]>> {
|
||||
// Apply defaults and bounds for pagination
|
||||
// Note: Using offset-based pagination to match existing API behavior
|
||||
const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 20)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const flyers = await db.flyerRepo.getFlyers(req.log, normalizedLimit, normalizedOffset);
|
||||
// The Flyer type from the repository is structurally compatible with FlyerDto
|
||||
// (FlyerDto just omits the GeoJSONPoint type that tsoa can't handle)
|
||||
return this.success(flyers as unknown as FlyerDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get flyer by ID.
|
||||
*
|
||||
* Returns a single flyer with its full details, including store information
|
||||
* and all associated store locations.
|
||||
*
|
||||
* @summary Get a single flyer
|
||||
* @param id The unique identifier of the flyer
|
||||
* @returns The flyer object with full details
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Flyer retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Flyer not found')
|
||||
public async getFlyerById(
|
||||
@Path() id: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerDto>> {
|
||||
// getFlyerById throws NotFoundError if flyer doesn't exist
|
||||
// The global error handler converts this to a 404 response
|
||||
const flyer = await db.flyerRepo.getFlyerById(id);
|
||||
req.log.debug({ flyerId: id }, 'Retrieved flyer by ID');
|
||||
return this.success(flyer as unknown as FlyerDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flyer items.
|
||||
*
|
||||
* Returns all items (deals) associated with a specific flyer.
|
||||
* Items are ordered by their position in the flyer.
|
||||
*
|
||||
* @summary Get items for a flyer
|
||||
* @param id The unique identifier of the flyer
|
||||
* @returns Array of flyer items with pricing and category information
|
||||
*/
|
||||
@Get('{id}/items')
|
||||
@SuccessResponse(200, 'Flyer items retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Flyer not found')
|
||||
public async getFlyerItems(
|
||||
@Path() id: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerItemDto[]>> {
|
||||
const items = await db.flyerRepo.getFlyerItems(id, req.log);
|
||||
req.log.debug({ flyerId: id, itemCount: items.length }, 'Retrieved flyer items');
|
||||
return this.success(items as unknown as FlyerItemDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// BATCH ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Batch fetch flyer items.
|
||||
*
|
||||
* Returns all items for multiple flyers in a single request.
|
||||
* This is more efficient than making separate requests for each flyer.
|
||||
* Items are ordered by flyer ID, then by item position within each flyer.
|
||||
*
|
||||
* @summary Batch fetch items for multiple flyers
|
||||
* @param body Request body containing array of flyer IDs
|
||||
* @returns Array of all flyer items for the requested flyers
|
||||
*/
|
||||
@Post('items/batch-fetch')
|
||||
@SuccessResponse(200, 'Batch items retrieved successfully')
|
||||
public async batchFetchItems(
|
||||
@Body() body: BatchFetchRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<FlyerItemDto[]>> {
|
||||
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
req.log.debug(
|
||||
{ flyerCount: body.flyerIds.length, itemCount: items.length },
|
||||
'Batch fetched flyer items',
|
||||
);
|
||||
return this.success(items as unknown as FlyerItemDto[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch count flyer items.
|
||||
*
|
||||
* Returns the total item count for multiple flyers.
|
||||
* Useful for displaying item counts without fetching all item data.
|
||||
*
|
||||
* @summary Batch count items for multiple flyers
|
||||
* @param body Request body containing array of flyer IDs
|
||||
* @returns Object with total count of items across all requested flyers
|
||||
*/
|
||||
@Post('items/batch-count')
|
||||
@SuccessResponse(200, 'Batch count retrieved successfully')
|
||||
public async batchCountItems(
|
||||
@Body() body: BatchCountRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<BatchCountResponse>> {
|
||||
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
req.log.debug({ flyerCount: body.flyerIds.length, totalItems: count }, 'Batch counted items');
|
||||
return this.success({ count });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// TRACKING ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Track item interaction.
|
||||
*
|
||||
* Records a view or click interaction with a flyer item for analytics purposes.
|
||||
* This endpoint uses a fire-and-forget pattern: it returns immediately with a
|
||||
* 202 Accepted response while the tracking is processed asynchronously.
|
||||
*
|
||||
* This design ensures that tracking does not slow down the user experience,
|
||||
* and any tracking failures are logged but do not affect the client.
|
||||
*
|
||||
* @summary Track a flyer item interaction
|
||||
* @param itemId The unique identifier of the flyer item
|
||||
* @param body The interaction type (view or click)
|
||||
* @returns Confirmation that tracking was accepted
|
||||
*/
|
||||
@Post('items/{itemId}/track')
|
||||
@SuccessResponse(202, 'Tracking accepted')
|
||||
public async trackItemInteraction(
|
||||
@Path() itemId: number,
|
||||
@Body() body: TrackInteractionRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<TrackingResponse>> {
|
||||
// Fire-and-forget: start the tracking operation but don't await it.
|
||||
// We explicitly handle errors in the .catch() to prevent unhandled rejections
|
||||
// and to log any failures without affecting the client response.
|
||||
db.flyerRepo.trackFlyerItemInteraction(itemId, body.type, req.log).catch((error) => {
|
||||
// Log the error but don't propagate it - this is intentional
|
||||
// as tracking failures should not impact user experience
|
||||
req.log.error(
|
||||
{ error, itemId, interactionType: body.type },
|
||||
'Flyer item interaction tracking failed (fire-and-forget)',
|
||||
);
|
||||
});
|
||||
|
||||
// Return immediately with 202 Accepted
|
||||
this.setStatus(202);
|
||||
return this.success({ message: 'Tracking accepted' });
|
||||
}
|
||||
}
|
||||
457
src/controllers/gamification.controller.test.ts
Normal file
457
src/controllers/gamification.controller.test.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
// src/controllers/gamification.controller.test.ts
|
||||
// ============================================================================
|
||||
// GAMIFICATION CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the GamificationController class. These tests verify controller
|
||||
// logic in isolation by mocking the gamification service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock gamification service
|
||||
vi.mock('../services/gamificationService', () => ({
|
||||
gamificationService: {
|
||||
getAllAchievements: vi.fn(),
|
||||
getLeaderboard: vi.fn(),
|
||||
getUserAchievements: vi.fn(),
|
||||
awardAchievement: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
publicReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
userReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
adminTriggerLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { gamificationService } from '../services/gamificationService';
|
||||
import { GamificationController } from './gamification.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedGamificationService = gamificationService as Mocked<typeof gamificationService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock admin user profile for testing.
|
||||
*/
|
||||
function createMockAdminProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock achievement.
|
||||
*/
|
||||
function createMockAchievement(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
achievement_id: 1,
|
||||
name: 'First-Upload',
|
||||
description: 'Upload your first flyer',
|
||||
points: 10,
|
||||
icon: 'upload',
|
||||
category: 'contribution',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user achievement.
|
||||
*/
|
||||
function createMockUserAchievement(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
user_achievement_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
achievement_id: 1,
|
||||
achievement_name: 'First-Upload',
|
||||
achievement_description: 'Upload your first flyer',
|
||||
points: 10,
|
||||
earned_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock leaderboard user.
|
||||
*/
|
||||
function createMockLeaderboardUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
user_id: 'user-1',
|
||||
display_name: 'Top User',
|
||||
total_points: 150,
|
||||
achievement_count: 8,
|
||||
rank: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('GamificationController', () => {
|
||||
let controller: GamificationController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new GamificationController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAllAchievements()', () => {
|
||||
it('should return all achievements', async () => {
|
||||
// Arrange
|
||||
const mockAchievements = [
|
||||
createMockAchievement(),
|
||||
createMockAchievement({ achievement_id: 2, name: 'Deal-Hunter', points: 25 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue(mockAchievements);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].name).toBe('First-Upload');
|
||||
}
|
||||
expect(mockedGamificationService.getAllAchievements).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no achievements exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockAchievements = [createMockAchievement()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue(mockAchievements);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeaderboard()', () => {
|
||||
it('should return leaderboard with default limit', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [
|
||||
createMockLeaderboardUser(),
|
||||
createMockLeaderboardUser({ user_id: 'user-2', rank: 2, total_points: 120 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
const result = await controller.getLeaderboard(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].rank).toBe(1);
|
||||
}
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(
|
||||
10, // default limit
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom limit', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
await controller.getLeaderboard(request, 25);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(25, expect.anything());
|
||||
});
|
||||
|
||||
it('should cap limit at 50', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
await controller.getLeaderboard(request, 100);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(50, expect.anything());
|
||||
});
|
||||
|
||||
it('should floor limit at 1', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
await controller.getLeaderboard(request, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getLeaderboard).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockLeaderboard = [createMockLeaderboardUser()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedGamificationService.getLeaderboard.mockResolvedValue(mockLeaderboard);
|
||||
|
||||
// Act
|
||||
const result = await controller.getLeaderboard(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED USER ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getMyAchievements()', () => {
|
||||
it('should return user achievements', async () => {
|
||||
// Arrange
|
||||
const mockUserAchievements = [
|
||||
createMockUserAchievement(),
|
||||
createMockUserAchievement({ user_achievement_id: 2, achievement_name: 'Deal-Hunter' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getUserAchievements.mockResolvedValue(mockUserAchievements);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMyAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].achievement_name).toBe('First-Upload');
|
||||
}
|
||||
expect(mockedGamificationService.getUserAchievements).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no achievements', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getUserAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMyAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use user ID from authenticated profile', async () => {
|
||||
// Arrange
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
mockedGamificationService.getUserAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getMyAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(mockedGamificationService.getUserAchievements).toHaveBeenCalledWith(
|
||||
'custom-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('awardAchievement()', () => {
|
||||
it('should award achievement to user (admin)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: createMockAdminProfile() });
|
||||
|
||||
mockedGamificationService.awardAchievement.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.awardAchievement(request, {
|
||||
userId: 'target-user-id',
|
||||
achievementName: 'First-Upload',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe(
|
||||
"Successfully awarded 'First-Upload' to user target-user-id.",
|
||||
);
|
||||
}
|
||||
expect(mockedGamificationService.awardAchievement).toHaveBeenCalledWith(
|
||||
'target-user-id',
|
||||
'First-Upload',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include achievement name in success message', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: createMockAdminProfile() });
|
||||
|
||||
mockedGamificationService.awardAchievement.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.awardAchievement(request, {
|
||||
userId: 'user-123',
|
||||
achievementName: 'Deal-Hunter',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('Deal-Hunter');
|
||||
expect(result.data.message).toContain('user-123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGamificationService.getAllAchievements.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAllAchievements(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
190
src/controllers/gamification.controller.ts
Normal file
190
src/controllers/gamification.controller.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// src/controllers/gamification.controller.ts
|
||||
// ============================================================================
|
||||
// GAMIFICATION CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for the achievement and leaderboard system.
|
||||
// Includes public endpoints for viewing achievements and leaderboard,
|
||||
// authenticated endpoint for user's achievements, and admin endpoint
|
||||
// for manually awarding achievements.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { gamificationService } from '../services/gamificationService';
|
||||
import type { UserProfile, Achievement, UserAchievement, LeaderboardUser } from '../types';
|
||||
import { publicReadLimiter, userReadLimiter, adminTriggerLimiter } from '../config/rateLimiters';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for awarding an achievement (admin only).
|
||||
*/
|
||||
interface AwardAchievementRequest {
|
||||
/**
|
||||
* User ID to award the achievement to.
|
||||
* @format uuid
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* Name of the achievement to award.
|
||||
* @example "First-Upload"
|
||||
*/
|
||||
achievementName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for successful achievement award.
|
||||
*/
|
||||
interface AwardAchievementResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GAMIFICATION CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for achievement and leaderboard system.
|
||||
*
|
||||
* Public endpoints:
|
||||
* - GET /achievements - List all available achievements
|
||||
* - GET /achievements/leaderboard - View top users by points
|
||||
*
|
||||
* Authenticated endpoints:
|
||||
* - GET /achievements/me - View user's earned achievements
|
||||
*
|
||||
* Admin endpoints:
|
||||
* - POST /achievements/award - Manually award an achievement
|
||||
*/
|
||||
@Route('achievements')
|
||||
@Tags('Achievements')
|
||||
export class GamificationController extends BaseController {
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get all achievements.
|
||||
*
|
||||
* Returns the master list of all available achievements in the system.
|
||||
* This is a public endpoint.
|
||||
*
|
||||
* @summary Get all achievements
|
||||
* @param request Express request for logging
|
||||
* @returns List of all available achievements
|
||||
*/
|
||||
@Get()
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of all achievements')
|
||||
public async getAllAchievements(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Achievement[]>> {
|
||||
const achievements = await gamificationService.getAllAchievements(request.log);
|
||||
return this.success(achievements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leaderboard.
|
||||
*
|
||||
* Returns the top users ranked by total points earned from achievements.
|
||||
* This is a public endpoint.
|
||||
*
|
||||
* @summary Get leaderboard
|
||||
* @param request Express request for logging
|
||||
* @param limit Maximum number of users to return (1-50, default: 10)
|
||||
* @returns Leaderboard entries with user points
|
||||
*/
|
||||
@Get('leaderboard')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'Leaderboard entries')
|
||||
public async getLeaderboard(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
): Promise<SuccessResponseType<LeaderboardUser[]>> {
|
||||
// Normalize limit: default 10, min 1, max 50
|
||||
const normalizedLimit = Math.min(50, Math.max(1, Math.floor(limit ?? 10)));
|
||||
|
||||
const leaderboard = await gamificationService.getLeaderboard(normalizedLimit, request.log);
|
||||
return this.success(leaderboard);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED USER ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get my achievements.
|
||||
*
|
||||
* Returns all achievements earned by the authenticated user.
|
||||
*
|
||||
* @summary Get my achievements
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of user's earned achievements
|
||||
*/
|
||||
@Get('me')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(userReadLimiter)
|
||||
@SuccessResponse(200, "List of user's earned achievements")
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - JWT token missing or invalid')
|
||||
public async getMyAchievements(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<UserAchievement[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const userAchievements = await gamificationService.getUserAchievements(
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
return this.success(userAchievements);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Award achievement to user (Admin only).
|
||||
*
|
||||
* Manually award an achievement to a specific user. Requires admin role.
|
||||
*
|
||||
* @summary Award achievement to user (Admin only)
|
||||
* @param request Express request with authenticated admin user
|
||||
* @param body User ID and achievement name
|
||||
* @returns Success message
|
||||
*/
|
||||
@Post('award')
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@Middlewares(adminTriggerLimiter)
|
||||
@SuccessResponse(200, 'Achievement awarded successfully')
|
||||
@Response<ErrorResponse>(400, 'Invalid achievement name')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - JWT token missing or invalid')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - User is not an admin')
|
||||
@Response<ErrorResponse>(404, 'User or achievement not found')
|
||||
public async awardAchievement(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: AwardAchievementRequest,
|
||||
): Promise<SuccessResponseType<AwardAchievementResponse>> {
|
||||
await gamificationService.awardAchievement(body.userId, body.achievementName, request.log);
|
||||
return this.success({
|
||||
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
769
src/controllers/health.controller.test.ts
Normal file
769
src/controllers/health.controller.test.ts
Normal file
@@ -0,0 +1,769 @@
|
||||
// src/controllers/health.controller.test.ts
|
||||
// ============================================================================
|
||||
// HEALTH CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the HealthController class. These tests verify controller
|
||||
// logic in isolation by mocking external dependencies like database, Redis,
|
||||
// and file system access.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
// Mock all external dependencies before importing the controller module.
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class (required before controller import)
|
||||
// tsoa is used at compile-time for code generation but needs to be mocked for Vitest
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(_status: number): void {
|
||||
// Mock setStatus
|
||||
}
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database connection module
|
||||
vi.mock('../services/db/connection.db', () => ({
|
||||
checkTablesExist: vi.fn(),
|
||||
getPoolStatus: vi.fn(),
|
||||
getPool: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock file system module
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
access: vi.fn(),
|
||||
constants: { W_OK: 1 },
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Redis connection from queue service
|
||||
vi.mock('../services/queueService.server', () => ({
|
||||
connection: {
|
||||
ping: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Use vi.hoisted to create mock queue objects available during vi.mock hoisting
|
||||
const { mockQueuesModule } = vi.hoisted(() => {
|
||||
const createMockQueue = () => ({
|
||||
getJobCounts: vi.fn().mockResolvedValue({
|
||||
waiting: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
mockQueuesModule: {
|
||||
flyerQueue: createMockQueue(),
|
||||
emailQueue: createMockQueue(),
|
||||
analyticsQueue: createMockQueue(),
|
||||
weeklyAnalyticsQueue: createMockQueue(),
|
||||
cleanupQueue: createMockQueue(),
|
||||
tokenCleanupQueue: createMockQueue(),
|
||||
receiptQueue: createMockQueue(),
|
||||
expiryAlertQueue: createMockQueue(),
|
||||
barcodeQueue: createMockQueue(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the queues.server module
|
||||
vi.mock('../services/queues.server', () => mockQueuesModule);
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as dbConnection from '../services/db/connection.db';
|
||||
import { connection as redisConnection } from '../services/queueService.server';
|
||||
import fs from 'node:fs/promises';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDbConnection = dbConnection as Mocked<typeof dbConnection>;
|
||||
const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection> & {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const mockedFs = fs as Mocked<typeof fs>;
|
||||
|
||||
// Cast queues module for test assertions
|
||||
const mockedQueues = mockQueuesModule as {
|
||||
flyerQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
emailQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
analyticsQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
weeklyAnalyticsQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
cleanupQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
tokenCleanupQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
receiptQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
expiryAlertQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
barcodeQueue: { getJobCounts: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('HealthController', () => {
|
||||
let controller: HealthController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new HealthController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASIC HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
describe('ping()', () => {
|
||||
it('should return a pong response', async () => {
|
||||
const result = await controller.ping();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ message: 'pong' });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// KUBERNETES PROBES (ADR-020)
|
||||
// ==========================================================================
|
||||
|
||||
describe('live()', () => {
|
||||
it('should return ok status with timestamp', async () => {
|
||||
const result = await controller.live();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.status).toBe('ok');
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
expect(() => new Date(result.data.timestamp)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ready()', () => {
|
||||
it('should return healthy status when all services are healthy', async () => {
|
||||
// Arrange: Mock all services as healthy
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.services.database.status).toBe('healthy');
|
||||
expect(result.data.services.redis.status).toBe('healthy');
|
||||
expect(result.data.services.storage.status).toBe('healthy');
|
||||
expect(result.data.uptime).toBeDefined();
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return degraded status when database pool has high waiting count', async () => {
|
||||
// Arrange: Mock database as degraded (waitingCount > 3)
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 2,
|
||||
waitingCount: 5,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('degraded');
|
||||
expect(result.data.services.database.status).toBe('degraded');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when database is unavailable', async () => {
|
||||
// Arrange: Mock database as unhealthy
|
||||
const mockPool = { query: vi.fn().mockRejectedValue(new Error('Connection failed')) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('Service unhealthy');
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
services: { database: { status: string; message: string } };
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.services.database.status).toBe('unhealthy');
|
||||
expect(details.services.database.message).toBe('Connection failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when Redis is unavailable', async () => {
|
||||
// Arrange: Mock Redis as unhealthy
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockRejectedValue(new Error('Redis connection refused'));
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
services: { redis: { status: string; message: string } };
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.services.redis.status).toBe('unhealthy');
|
||||
expect(details.services.redis.message).toBe('Redis connection refused');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy when Redis returns unexpected ping response', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('UNEXPECTED');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
services: { redis: { status: string; message: string } };
|
||||
};
|
||||
expect(details.services.redis.status).toBe('unhealthy');
|
||||
expect(details.services.redis.message).toContain('Unexpected ping response');
|
||||
}
|
||||
});
|
||||
|
||||
it('should still return healthy when storage is unhealthy but critical services are healthy', async () => {
|
||||
// Arrange: Storage unhealthy, but db and redis healthy
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert: Storage is not critical, so should still be healthy/200
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.services.storage.status).toBe('unhealthy');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle database error with non-Error object', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockRejectedValue('String error') };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.ready();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as { services: { database: { message: string } } };
|
||||
expect(details.services.database.message).toBe('Database connection failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startup()', () => {
|
||||
it('should return started status when database is healthy', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('started');
|
||||
expect(result.data.database.status).toBe('healthy');
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when database is unhealthy during startup', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockRejectedValue(new Error('Database not ready')) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('Waiting for database connection');
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
database: { status: string; message: string };
|
||||
};
|
||||
expect(details.status).toBe('starting');
|
||||
expect(details.database.status).toBe('unhealthy');
|
||||
expect(details.database.message).toBe('Database not ready');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return started with degraded database when pool has high waiting count', async () => {
|
||||
// Arrange
|
||||
const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 2,
|
||||
waitingCount: 5,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert: Degraded is not unhealthy, so startup should succeed
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('started');
|
||||
expect(result.data.database.status).toBe('degraded');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// INDIVIDUAL SERVICE HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
describe('dbSchema()', () => {
|
||||
it('should return success when all tables exist', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.dbSchema();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('All required database tables exist.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when tables are missing', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table_1', 'missing_table_2']);
|
||||
|
||||
// Act
|
||||
const result = await controller.dbSchema();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Missing tables: missing_table_1, missing_table_2');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage()', () => {
|
||||
it('should return success when storage is accessible', async () => {
|
||||
// Arrange
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.storage();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('is accessible and writable');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when storage is not accessible', async () => {
|
||||
// Arrange
|
||||
mockedFs.access.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
// Act
|
||||
const result = await controller.storage();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Storage check failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dbPool()', () => {
|
||||
it('should return success for a healthy pool status', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 10,
|
||||
idleCount: 8,
|
||||
waitingCount: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.dbPool();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting');
|
||||
expect(result.data.totalCount).toBe(10);
|
||||
expect(result.data.idleCount).toBe(8);
|
||||
expect(result.data.waitingCount).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error for an unhealthy pool status', async () => {
|
||||
// Arrange
|
||||
mockedDbConnection.getPoolStatus.mockReturnValue({
|
||||
totalCount: 20,
|
||||
idleCount: 5,
|
||||
waitingCount: 15,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.dbPool();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Pool may be under stress');
|
||||
expect(result.error.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('time()', () => {
|
||||
it('should return current server time, year, and week', async () => {
|
||||
// Arrange
|
||||
const fakeDate = new Date('2024-03-15T10:30:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeDate);
|
||||
|
||||
// Act
|
||||
const result = await controller.time();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.currentTime).toBe('2024-03-15T10:30:00.000Z');
|
||||
expect(result.data.year).toBe(2024);
|
||||
expect(result.data.week).toBe(11);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('redis()', () => {
|
||||
it('should return success when Redis ping is successful', async () => {
|
||||
// Arrange
|
||||
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
||||
|
||||
// Act
|
||||
const result = await controller.redis();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Redis connection is healthy.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when Redis ping fails', async () => {
|
||||
// Arrange
|
||||
mockedRedisConnection.ping.mockRejectedValue(new Error('Connection timed out'));
|
||||
|
||||
// Act
|
||||
const result = await controller.redis();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('Connection timed out');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when Redis returns unexpected response', async () => {
|
||||
// Arrange
|
||||
mockedRedisConnection.ping.mockResolvedValue('OK');
|
||||
|
||||
// Act
|
||||
const result = await controller.redis();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toContain('Unexpected Redis ping response: OK');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// QUEUE HEALTH MONITORING (ADR-053)
|
||||
// ==========================================================================
|
||||
|
||||
describe('queues()', () => {
|
||||
// Helper function to set all queue mocks
|
||||
const setAllQueueMocks = (jobCounts: {
|
||||
waiting: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}) => {
|
||||
mockedQueues.flyerQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.emailQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(jobCounts);
|
||||
};
|
||||
|
||||
it('should return healthy status when all queues and workers are healthy', async () => {
|
||||
// Arrange
|
||||
setAllQueueMocks({ waiting: 5, active: 2, failed: 1, delayed: 0 });
|
||||
|
||||
// Mock Redis heartbeat responses (all healthy)
|
||||
const recentTimestamp = new Date(Date.now() - 10000).toISOString();
|
||||
const heartbeatValue = JSON.stringify({
|
||||
timestamp: recentTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
mockedRedisConnection.get.mockResolvedValue(heartbeatValue);
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.queues['flyer-processing']).toEqual({
|
||||
waiting: 5,
|
||||
active: 2,
|
||||
failed: 1,
|
||||
delayed: 0,
|
||||
});
|
||||
expect(result.data.workers['flyer-processing']).toEqual({
|
||||
alive: true,
|
||||
lastSeen: recentTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when a queue is unavailable', async () => {
|
||||
// Arrange: flyerQueue fails, others succeed
|
||||
mockedQueues.flyerQueue.getJobCounts.mockRejectedValue(new Error('Redis connection lost'));
|
||||
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
mockedQueues.emailQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(healthyJobCounts);
|
||||
|
||||
mockedRedisConnection.get.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.message).toBe('One or more queues or workers unavailable');
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
queues: Record<string, { error?: string }>;
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.queues['flyer-processing']).toEqual({ error: 'Redis connection lost' });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when a worker heartbeat is stale', async () => {
|
||||
// Arrange
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
setAllQueueMocks(healthyJobCounts);
|
||||
|
||||
// Stale heartbeat (> 60s ago)
|
||||
const staleTimestamp = new Date(Date.now() - 120000).toISOString();
|
||||
const staleHeartbeat = JSON.stringify({
|
||||
timestamp: staleTimestamp,
|
||||
pid: 1234,
|
||||
host: 'test-host',
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
mockedRedisConnection.get.mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(callCount === 1 ? staleHeartbeat : null);
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
workers: Record<string, { alive: boolean }>;
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.workers['flyer-processing']).toEqual({ alive: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unhealthy status when worker heartbeat is missing', async () => {
|
||||
// Arrange
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
setAllQueueMocks(healthyJobCounts);
|
||||
mockedRedisConnection.get.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const details = result.error.details as {
|
||||
status: string;
|
||||
workers: Record<string, { alive: boolean }>;
|
||||
};
|
||||
expect(details.status).toBe('unhealthy');
|
||||
expect(details.workers['flyer-processing']).toEqual({ alive: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle Redis connection errors gracefully for heartbeat checks', async () => {
|
||||
// Arrange
|
||||
const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 };
|
||||
setAllQueueMocks(healthyJobCounts);
|
||||
mockedRedisConnection.get.mockRejectedValue(new Error('Redis connection lost'));
|
||||
|
||||
// Act
|
||||
const result = await controller.queues();
|
||||
|
||||
// Assert: Heartbeat fetch errors are treated as non-critical
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.workers['flyer-processing']).toEqual({
|
||||
alive: false,
|
||||
error: 'Redis connection lost',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
const result = await controller.ping();
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for consistent error format', async () => {
|
||||
// Arrange: Make database check fail
|
||||
mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table']);
|
||||
|
||||
// Act
|
||||
const result = await controller.dbSchema();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', false);
|
||||
expect(result).toHaveProperty('error');
|
||||
if (!result.success) {
|
||||
expect(result.error).toHaveProperty('code');
|
||||
expect(result.error).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
|
||||
it('should set HTTP status codes via setStatus', async () => {
|
||||
// Arrange: Make startup probe fail
|
||||
const mockPool = { query: vi.fn().mockRejectedValue(new Error('No database')) };
|
||||
mockedDbConnection.getPool.mockReturnValue(mockPool as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.startup();
|
||||
|
||||
// Assert: The controller called setStatus(503) internally
|
||||
// We can verify this by checking the result structure is an error
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
673
src/controllers/health.controller.ts
Normal file
673
src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
// src/controllers/health.controller.ts
|
||||
// ============================================================================
|
||||
// HEALTH CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides health check endpoints for monitoring the application state,
|
||||
// implementing ADR-020: Health Checks and Liveness/Readiness Probes.
|
||||
//
|
||||
// This controller exposes endpoints for:
|
||||
// - Liveness probe (/live) - Is the server process running?
|
||||
// - Readiness probe (/ready) - Is the server ready to accept traffic?
|
||||
// - Startup probe (/startup) - Has the server completed initialization?
|
||||
// - Individual service health checks (db, redis, storage, queues)
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, SuccessResponse, Response } from 'tsoa';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse, ServiceHealth } from './types';
|
||||
import { getPoolStatus, getPool, checkTablesExist } from '../services/db/connection.db';
|
||||
import { connection as redisConnection } from '../services/queueService.server';
|
||||
import {
|
||||
flyerQueue,
|
||||
emailQueue,
|
||||
analyticsQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
cleanupQueue,
|
||||
tokenCleanupQueue,
|
||||
receiptQueue,
|
||||
expiryAlertQueue,
|
||||
barcodeQueue,
|
||||
} from '../services/queues.server';
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE TYPES
|
||||
// ============================================================================
|
||||
// Types for health check responses that will appear in the OpenAPI spec.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple ping response.
|
||||
*/
|
||||
interface PingResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liveness probe response.
|
||||
*/
|
||||
interface LivenessResponse {
|
||||
status: 'ok';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe response with service status.
|
||||
*/
|
||||
interface ReadinessResponse {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
services: {
|
||||
database: ServiceHealth;
|
||||
redis: ServiceHealth;
|
||||
storage: ServiceHealth;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup probe response.
|
||||
*/
|
||||
interface StartupResponse {
|
||||
status: 'started' | 'starting';
|
||||
timestamp: string;
|
||||
database: ServiceHealth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database schema check response.
|
||||
*/
|
||||
interface DbSchemaResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage check response.
|
||||
*/
|
||||
interface StorageResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database pool status response.
|
||||
*/
|
||||
interface DbPoolResponse {
|
||||
message: string;
|
||||
totalCount: number;
|
||||
idleCount: number;
|
||||
waitingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server time response.
|
||||
*/
|
||||
interface TimeResponse {
|
||||
currentTime: string;
|
||||
year: number;
|
||||
week: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis health check response.
|
||||
*/
|
||||
interface RedisHealthResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue job counts.
|
||||
*/
|
||||
interface QueueJobCounts {
|
||||
waiting: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker heartbeat status.
|
||||
*/
|
||||
interface WorkerHeartbeat {
|
||||
alive: boolean;
|
||||
lastSeen?: string;
|
||||
pid?: number;
|
||||
host?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue health response with metrics and worker heartbeats.
|
||||
*/
|
||||
interface QueuesHealthResponse {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
timestamp: string;
|
||||
queues: Record<string, QueueJobCounts | { error: string }>;
|
||||
workers: Record<string, WorkerHeartbeat>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
// Reusable functions for checking service health.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Checks database connectivity with timing.
|
||||
*
|
||||
* @returns ServiceHealth object with database status and latency
|
||||
*/
|
||||
async function checkDatabase(): Promise<ServiceHealth> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.query('SELECT 1');
|
||||
const latency = Date.now() - start;
|
||||
const poolStatus = getPoolStatus();
|
||||
|
||||
// Consider degraded if waiting connections > 3
|
||||
const status = poolStatus.waitingCount > 3 ? 'degraded' : 'healthy';
|
||||
|
||||
return {
|
||||
status,
|
||||
latency,
|
||||
details: {
|
||||
totalConnections: poolStatus.totalCount,
|
||||
idleConnections: poolStatus.idleCount,
|
||||
waitingConnections: poolStatus.waitingCount,
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - start,
|
||||
message: error instanceof Error ? error.message : 'Database connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Redis connectivity with timing.
|
||||
*
|
||||
* @returns ServiceHealth object with Redis status and latency
|
||||
*/
|
||||
async function checkRedis(): Promise<ServiceHealth> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const reply = await redisConnection.ping();
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (reply === 'PONG') {
|
||||
return { status: 'healthy', latency };
|
||||
}
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency,
|
||||
message: `Unexpected ping response: ${reply}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - start,
|
||||
message: error instanceof Error ? error.message : 'Redis connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks storage accessibility with timing.
|
||||
*
|
||||
* @returns ServiceHealth object with storage status and latency
|
||||
*/
|
||||
async function checkStorage(): Promise<ServiceHealth> {
|
||||
const storagePath =
|
||||
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
const start = Date.now();
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK);
|
||||
return {
|
||||
status: 'healthy',
|
||||
latency: Date.now() - start,
|
||||
details: { path: storagePath },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - start,
|
||||
message: `Storage not accessible: ${storagePath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HEALTH CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Health check controller for monitoring application state.
|
||||
*
|
||||
* Provides endpoints for Kubernetes liveness/readiness/startup probes
|
||||
* and individual service health checks per ADR-020.
|
||||
*/
|
||||
@Route('health')
|
||||
@Tags('Health')
|
||||
export class HealthController extends BaseController {
|
||||
// ==========================================================================
|
||||
// BASIC HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Simple ping endpoint.
|
||||
*
|
||||
* Returns a pong response to verify server is responsive.
|
||||
* Use this for basic connectivity checks.
|
||||
*
|
||||
* @summary Simple ping endpoint
|
||||
* @returns A pong response confirming the server is alive
|
||||
*/
|
||||
@Get('ping')
|
||||
@SuccessResponse(200, 'Server is responsive')
|
||||
public async ping(): Promise<SuccessResponseType<PingResponse>> {
|
||||
return this.success({ message: 'pong' });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// KUBERNETES PROBES (ADR-020)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Liveness probe.
|
||||
*
|
||||
* Returns 200 OK if the server process is running.
|
||||
* If this fails, the orchestrator should restart the container.
|
||||
* This endpoint is intentionally simple and has no external dependencies.
|
||||
*
|
||||
* @summary Liveness probe
|
||||
* @returns Status indicating the server process is alive
|
||||
*/
|
||||
@Get('live')
|
||||
@SuccessResponse(200, 'Server process is alive')
|
||||
public async live(): Promise<SuccessResponseType<LivenessResponse>> {
|
||||
return this.success({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe.
|
||||
*
|
||||
* Returns 200 OK if the server is ready to accept traffic.
|
||||
* Checks all critical dependencies (database, Redis, storage).
|
||||
* If this fails, the orchestrator should remove the container from the load balancer.
|
||||
*
|
||||
* @summary Readiness probe
|
||||
* @returns Service health status for all critical dependencies
|
||||
*/
|
||||
@Get('ready')
|
||||
@SuccessResponse(200, 'Server is ready to accept traffic')
|
||||
@Response<ErrorResponse>(503, 'Service is unhealthy and should not receive traffic')
|
||||
public async ready(): Promise<SuccessResponseType<ReadinessResponse> | ErrorResponse> {
|
||||
// Check all services in parallel for speed
|
||||
const [database, redis, storage] = await Promise.all([
|
||||
checkDatabase(),
|
||||
checkRedis(),
|
||||
checkStorage(),
|
||||
]);
|
||||
|
||||
// Determine overall status
|
||||
// - 'healthy' if all critical services (db, redis) are healthy
|
||||
// - 'degraded' if any service is degraded but none unhealthy
|
||||
// - 'unhealthy' if any critical service is unhealthy
|
||||
const criticalServices = [database, redis];
|
||||
const allServices = [database, redis, storage];
|
||||
|
||||
let overallStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||
|
||||
if (criticalServices.some((s) => s.status === 'unhealthy')) {
|
||||
overallStatus = 'unhealthy';
|
||||
} else if (allServices.some((s) => s.status === 'degraded')) {
|
||||
overallStatus = 'degraded';
|
||||
}
|
||||
|
||||
const response: ReadinessResponse = {
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
services: {
|
||||
database,
|
||||
redis,
|
||||
storage,
|
||||
},
|
||||
};
|
||||
|
||||
// Return appropriate HTTP status code
|
||||
// 200 = healthy or degraded (can still handle traffic)
|
||||
// 503 = unhealthy (should not receive traffic)
|
||||
if (overallStatus === 'unhealthy') {
|
||||
this.setStatus(503);
|
||||
return this.error(this.ErrorCode.SERVICE_UNAVAILABLE, 'Service unhealthy', response);
|
||||
}
|
||||
return this.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup probe.
|
||||
*
|
||||
* Similar to readiness but used during container startup.
|
||||
* The orchestrator will not send liveness/readiness probes until this succeeds.
|
||||
* This allows for longer initialization times without triggering restarts.
|
||||
*
|
||||
* @summary Startup probe for container orchestration
|
||||
* @returns Startup status with database health
|
||||
*/
|
||||
@Get('startup')
|
||||
@SuccessResponse(200, 'Server has started successfully')
|
||||
@Response<ErrorResponse>(503, 'Server is still starting')
|
||||
public async startup(): Promise<SuccessResponseType<StartupResponse> | ErrorResponse> {
|
||||
// For startup, we only check database connectivity
|
||||
// Redis and storage can be checked later in readiness
|
||||
const database = await checkDatabase();
|
||||
|
||||
if (database.status === 'unhealthy') {
|
||||
this.setStatus(503);
|
||||
return this.error(this.ErrorCode.SERVICE_UNAVAILABLE, 'Waiting for database connection', {
|
||||
status: 'starting',
|
||||
database,
|
||||
});
|
||||
}
|
||||
|
||||
return this.success({
|
||||
status: 'started',
|
||||
timestamp: new Date().toISOString(),
|
||||
database,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// INDIVIDUAL SERVICE HEALTH CHECKS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Database schema check.
|
||||
*
|
||||
* Checks if all essential database tables exist.
|
||||
* This is a critical check to ensure the database schema is correctly set up.
|
||||
*
|
||||
* @summary Check database schema
|
||||
* @returns Message confirming all required tables exist
|
||||
*/
|
||||
@Get('db-schema')
|
||||
@SuccessResponse(200, 'All required database tables exist')
|
||||
@Response<ErrorResponse>(500, 'Database schema check failed')
|
||||
public async dbSchema(): Promise<SuccessResponseType<DbSchemaResponse> | ErrorResponse> {
|
||||
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
|
||||
const missingTables = await checkTablesExist(requiredTables);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
this.setStatus(500);
|
||||
return this.error(
|
||||
this.ErrorCode.INTERNAL_ERROR,
|
||||
`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.success({ message: 'All required database tables exist.' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage health check.
|
||||
*
|
||||
* Verifies that the application's file storage path is accessible and writable.
|
||||
* This is important for features like file uploads.
|
||||
*
|
||||
* @summary Check storage accessibility
|
||||
* @returns Message confirming storage is accessible
|
||||
*/
|
||||
@Get('storage')
|
||||
@SuccessResponse(200, 'Storage is accessible and writable')
|
||||
@Response<ErrorResponse>(500, 'Storage check failed')
|
||||
public async storage(): Promise<SuccessResponseType<StorageResponse> | ErrorResponse> {
|
||||
const storagePath =
|
||||
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK);
|
||||
return this.success({
|
||||
message: `Storage directory '${storagePath}' is accessible and writable.`,
|
||||
});
|
||||
} catch {
|
||||
this.setStatus(500);
|
||||
return this.error(
|
||||
this.ErrorCode.INTERNAL_ERROR,
|
||||
`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database pool status check.
|
||||
*
|
||||
* Checks the status of the database connection pool.
|
||||
* This helps diagnose issues related to database connection saturation.
|
||||
*
|
||||
* @summary Check database connection pool status
|
||||
* @returns Pool status with connection counts
|
||||
*/
|
||||
@Get('db-pool')
|
||||
@SuccessResponse(200, 'Database pool is healthy')
|
||||
@Response<ErrorResponse>(500, 'Database pool may be under stress')
|
||||
public async dbPool(): Promise<SuccessResponseType<DbPoolResponse> | ErrorResponse> {
|
||||
const status = getPoolStatus();
|
||||
const isHealthy = status.waitingCount < 5;
|
||||
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
|
||||
|
||||
if (isHealthy) {
|
||||
return this.success({
|
||||
message,
|
||||
totalCount: status.totalCount,
|
||||
idleCount: status.idleCount,
|
||||
waitingCount: status.waitingCount,
|
||||
});
|
||||
}
|
||||
|
||||
this.setStatus(500);
|
||||
return this.error(
|
||||
this.ErrorCode.INTERNAL_ERROR,
|
||||
`Pool may be under stress. ${message}`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server time check.
|
||||
*
|
||||
* Returns the server's current time, year, and week number.
|
||||
* Useful for verifying time synchronization and for features dependent on week numbers.
|
||||
*
|
||||
* @summary Get server time and week number
|
||||
* @returns Current server time with year and week number
|
||||
*/
|
||||
@Get('time')
|
||||
@SuccessResponse(200, 'Server time retrieved')
|
||||
public async time(): Promise<SuccessResponseType<TimeResponse>> {
|
||||
const now = new Date();
|
||||
const { year, week } = getSimpleWeekAndYear(now);
|
||||
return this.success({
|
||||
currentTime: now.toISOString(),
|
||||
year,
|
||||
week,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis health check.
|
||||
*
|
||||
* Checks the health of the Redis connection.
|
||||
*
|
||||
* @summary Check Redis connectivity
|
||||
* @returns Message confirming Redis is healthy
|
||||
*/
|
||||
@Get('redis')
|
||||
@SuccessResponse(200, 'Redis connection is healthy')
|
||||
@Response<ErrorResponse>(500, 'Redis health check failed')
|
||||
public async redis(): Promise<SuccessResponseType<RedisHealthResponse> | ErrorResponse> {
|
||||
try {
|
||||
const reply = await redisConnection.ping();
|
||||
if (reply === 'PONG') {
|
||||
return this.success({ message: 'Redis connection is healthy.' });
|
||||
}
|
||||
throw new Error(`Unexpected Redis ping response: ${reply}`);
|
||||
} catch (error) {
|
||||
this.setStatus(500);
|
||||
const message = error instanceof Error ? error.message : 'Redis health check failed';
|
||||
return this.error(this.ErrorCode.INTERNAL_ERROR, message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// QUEUE HEALTH MONITORING (ADR-053)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Queue health and metrics with worker heartbeats.
|
||||
*
|
||||
* Returns job counts for all BullMQ queues and worker heartbeat status.
|
||||
* Use this endpoint to monitor queue depths and detect stuck/frozen workers.
|
||||
* Implements ADR-053: Worker Health Checks and Stalled Job Monitoring.
|
||||
*
|
||||
* @summary Queue health and metrics
|
||||
* @returns Queue metrics and worker heartbeat status
|
||||
*/
|
||||
@Get('queues')
|
||||
@SuccessResponse(200, 'Queue metrics retrieved successfully')
|
||||
@Response<ErrorResponse>(503, 'One or more queues or workers unavailable')
|
||||
public async queues(): Promise<SuccessResponseType<QueuesHealthResponse> | ErrorResponse> {
|
||||
// Define all queues to monitor
|
||||
const queues = [
|
||||
{ name: 'flyer-processing', queue: flyerQueue },
|
||||
{ name: 'email-sending', queue: emailQueue },
|
||||
{ name: 'analytics-reporting', queue: analyticsQueue },
|
||||
{ name: 'weekly-analytics-reporting', queue: weeklyAnalyticsQueue },
|
||||
{ name: 'file-cleanup', queue: cleanupQueue },
|
||||
{ name: 'token-cleanup', queue: tokenCleanupQueue },
|
||||
{ name: 'receipt-processing', queue: receiptQueue },
|
||||
{ name: 'expiry-alerts', queue: expiryAlertQueue },
|
||||
{ name: 'barcode-detection', queue: barcodeQueue },
|
||||
];
|
||||
|
||||
// Fetch job counts for all queues in parallel
|
||||
const queueMetrics = await Promise.all(
|
||||
queues.map(async ({ name, queue }) => {
|
||||
try {
|
||||
const counts = await queue.getJobCounts();
|
||||
return {
|
||||
name,
|
||||
counts: {
|
||||
waiting: counts.waiting || 0,
|
||||
active: counts.active || 0,
|
||||
failed: counts.failed || 0,
|
||||
delayed: counts.delayed || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If individual queue fails, return error state
|
||||
return {
|
||||
name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch worker heartbeats in parallel
|
||||
const workerNames = queues.map((q) => q.name);
|
||||
const workerHeartbeats = await Promise.all(
|
||||
workerNames.map(async (name) => {
|
||||
try {
|
||||
const key = `worker:heartbeat:${name}`;
|
||||
const value = await redisConnection.get(key);
|
||||
|
||||
if (!value) {
|
||||
return { name, alive: false };
|
||||
}
|
||||
|
||||
const heartbeat = JSON.parse(value) as {
|
||||
timestamp: string;
|
||||
pid: number;
|
||||
host: string;
|
||||
};
|
||||
const lastSeenMs = new Date(heartbeat.timestamp).getTime();
|
||||
const nowMs = Date.now();
|
||||
const ageSeconds = (nowMs - lastSeenMs) / 1000;
|
||||
|
||||
// Consider alive if last heartbeat < 60 seconds ago
|
||||
const alive = ageSeconds < 60;
|
||||
|
||||
return {
|
||||
name,
|
||||
alive,
|
||||
lastSeen: heartbeat.timestamp,
|
||||
pid: heartbeat.pid,
|
||||
host: heartbeat.host,
|
||||
};
|
||||
} catch (error) {
|
||||
// If heartbeat check fails, mark as unknown
|
||||
return {
|
||||
name,
|
||||
alive: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Build response objects
|
||||
const queuesData: Record<string, QueueJobCounts | { error: string }> = {};
|
||||
const workersData: Record<string, WorkerHeartbeat> = {};
|
||||
let hasErrors = false;
|
||||
|
||||
for (const metric of queueMetrics) {
|
||||
if ('error' in metric && metric.error) {
|
||||
queuesData[metric.name] = { error: metric.error };
|
||||
hasErrors = true;
|
||||
} else if ('counts' in metric && metric.counts) {
|
||||
queuesData[metric.name] = metric.counts;
|
||||
}
|
||||
}
|
||||
|
||||
for (const heartbeat of workerHeartbeats) {
|
||||
if ('error' in heartbeat && heartbeat.error) {
|
||||
workersData[heartbeat.name] = { alive: false, error: heartbeat.error };
|
||||
} else if (!heartbeat.alive) {
|
||||
workersData[heartbeat.name] = { alive: false };
|
||||
hasErrors = true;
|
||||
} else {
|
||||
workersData[heartbeat.name] = {
|
||||
alive: heartbeat.alive,
|
||||
lastSeen: heartbeat.lastSeen,
|
||||
pid: heartbeat.pid,
|
||||
host: heartbeat.host,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const response: QueuesHealthResponse = {
|
||||
status: hasErrors ? 'unhealthy' : 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
queues: queuesData,
|
||||
workers: workersData,
|
||||
};
|
||||
|
||||
if (hasErrors) {
|
||||
this.setStatus(503);
|
||||
return this.error(
|
||||
this.ErrorCode.SERVICE_UNAVAILABLE,
|
||||
'One or more queues or workers unavailable',
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
return this.success(response);
|
||||
}
|
||||
}
|
||||
616
src/controllers/inventory.controller.test.ts
Normal file
616
src/controllers/inventory.controller.test.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
// src/controllers/inventory.controller.test.ts
|
||||
// ============================================================================
|
||||
// INVENTORY CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the InventoryController class. These tests verify controller
|
||||
// logic in isolation by mocking the expiry service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock expiry service
|
||||
vi.mock('../services/expiryService.server', () => ({
|
||||
getInventory: vi.fn(),
|
||||
addInventoryItem: vi.fn(),
|
||||
getExpiringItemsGrouped: vi.fn(),
|
||||
getExpiringItems: vi.fn(),
|
||||
getExpiredItems: vi.fn(),
|
||||
getAlertSettings: vi.fn(),
|
||||
updateAlertSettings: vi.fn(),
|
||||
getRecipeSuggestionsForExpiringItems: vi.fn(),
|
||||
getInventoryItemById: vi.fn(),
|
||||
updateInventoryItem: vi.fn(),
|
||||
deleteInventoryItem: vi.fn(),
|
||||
markItemConsumed: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedExpiryService = expiryService as Mocked<typeof expiryService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock inventory item.
|
||||
*/
|
||||
function createMockInventoryItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
inventory_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
item_name: 'Milk',
|
||||
quantity: 1,
|
||||
unit: 'L',
|
||||
purchase_date: '2024-01-01',
|
||||
expiry_date: '2024-01-15',
|
||||
source: 'manual_entry' as const,
|
||||
location: 'refrigerator' as const,
|
||||
is_consumed: false,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('InventoryController', () => {
|
||||
let controller: InventoryController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new InventoryController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getInventory()', () => {
|
||||
it('should return inventory items with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
items: [createMockInventoryItem()],
|
||||
total: 1,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getInventory(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: 'test-user-id',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getInventory(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support filtering by location', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getInventory(request, 50, 0, 'refrigerator');
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ location: 'refrigerator' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support search parameter', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getInventory(
|
||||
request,
|
||||
50,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'milk',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getInventory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: 'milk' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addInventoryItem()', () => {
|
||||
it('should add an inventory item', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.addInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addInventoryItem(request, {
|
||||
item_name: 'Milk',
|
||||
source: 'manual_entry',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item_name).toBe('Milk');
|
||||
}
|
||||
});
|
||||
|
||||
it('should log item addition', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedExpiryService.addInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
await controller.addInventoryItem(request, {
|
||||
item_name: 'Milk',
|
||||
source: 'manual_entry',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ userId: 'test-user-id', itemName: 'Milk' },
|
||||
'Adding item to inventory',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// EXPIRING ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getExpiringSummary()', () => {
|
||||
it('should return expiring items grouped by urgency', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
expired: [],
|
||||
expiring_today: [],
|
||||
expiring_this_week: [createMockInventoryItem()],
|
||||
expiring_this_month: [],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItemsGrouped.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getExpiringSummary(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedExpiryService.getExpiringItemsGrouped).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpiringItems()', () => {
|
||||
it('should return expiring items with default 7 days', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockInventoryItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getExpiringItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.total).toBe(1);
|
||||
}
|
||||
expect(mockedExpiryService.getExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
7,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap days at 90', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getExpiringItems(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
90,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor days at 1', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiringItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getExpiringItems(request, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpiredItems()', () => {
|
||||
it('should return expired items', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockInventoryItem({ expiry_date: '2023-12-01' })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getExpiredItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getExpiredItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ALERT SETTINGS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAlertSettings()', () => {
|
||||
it('should return alert settings', async () => {
|
||||
// Arrange
|
||||
const mockSettings = [
|
||||
{ alert_method: 'email', days_before_expiry: 3, is_enabled: true },
|
||||
{ alert_method: 'push', days_before_expiry: 1, is_enabled: true },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getAlertSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAlertSettings(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAlertSettings()', () => {
|
||||
it('should update alert settings', async () => {
|
||||
// Arrange
|
||||
const mockUpdated = { alert_method: 'email', days_before_expiry: 5, is_enabled: true };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.updateAlertSettings.mockResolvedValue(mockUpdated);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateAlertSettings('email', request, {
|
||||
days_before_expiry: 5,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.days_before_expiry).toBe(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// RECIPE SUGGESTIONS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getRecipeSuggestions()', () => {
|
||||
it('should return recipe suggestions for expiring items', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
recipes: [{ recipe_id: 1, name: 'Test Recipe' }],
|
||||
total: 1,
|
||||
considered_items: [createMockInventoryItem()],
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getRecipeSuggestionsForExpiringItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeSuggestions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.recipes).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should normalize pagination parameters', async () => {
|
||||
// Arrange
|
||||
const mockResult = { recipes: [], total: 0, considered_items: [] };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getRecipeSuggestionsForExpiringItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getRecipeSuggestions(request, 100, 100, 100);
|
||||
|
||||
// Assert
|
||||
expect(mockedExpiryService.getRecipeSuggestionsForExpiringItems).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
90, // days capped at 90
|
||||
expect.anything(),
|
||||
{ limit: 50, offset: 100 }, // limit capped at 50
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM BY ID ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getInventoryItemById()', () => {
|
||||
it('should return an inventory item by ID', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventoryItemById.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.getInventoryItemById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.inventory_id).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInventoryItem()', () => {
|
||||
it('should update an inventory item', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem({ quantity: 2 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.updateInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateInventoryItem(1, request, { quantity: 2 });
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.quantity).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject update with no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateInventoryItem(1, request, {})).rejects.toThrow(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteInventoryItem()', () => {
|
||||
it('should delete an inventory item', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.deleteInventoryItem.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteInventoryItem(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExpiryService.deleteInventoryItem).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markItemConsumed()', () => {
|
||||
it('should mark an item as consumed', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.markItemConsumed.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.markItemConsumed(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExpiryService.markItemConsumed).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.getInventoryItemById.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.getInventoryItemById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockItem = createMockInventoryItem();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.addInventoryItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addInventoryItem(request, {
|
||||
item_name: 'Test',
|
||||
source: 'manual_entry',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.deleteInventoryItem.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteInventoryItem(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
535
src/controllers/inventory.controller.ts
Normal file
535
src/controllers/inventory.controller.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
// src/controllers/inventory.controller.ts
|
||||
// ============================================================================
|
||||
// INVENTORY CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing pantry inventory, expiry tracking, and alerts.
|
||||
// All endpoints require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import type { UserProfile } from '../types';
|
||||
import type {
|
||||
UserInventoryItem,
|
||||
StorageLocation,
|
||||
InventorySource,
|
||||
ExpiryAlertSettings,
|
||||
ExpiringItemsResponse,
|
||||
AlertMethod,
|
||||
} from '../types/expiry';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for adding an inventory item.
|
||||
*/
|
||||
interface AddInventoryItemRequest {
|
||||
/** Link to products table */
|
||||
product_id?: number;
|
||||
/** Link to master grocery items */
|
||||
master_item_id?: number;
|
||||
/**
|
||||
* Item name (required)
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
*/
|
||||
item_name: string;
|
||||
/** Quantity of item (default: 1) */
|
||||
quantity?: number;
|
||||
/**
|
||||
* Unit of measurement
|
||||
* @maxLength 50
|
||||
*/
|
||||
unit?: string;
|
||||
/** When the item was purchased (YYYY-MM-DD format) */
|
||||
purchase_date?: string;
|
||||
/** Expected expiry date (YYYY-MM-DD format) */
|
||||
expiry_date?: string;
|
||||
/** How the item is being added */
|
||||
source: InventorySource;
|
||||
/** Where the item will be stored */
|
||||
location?: StorageLocation;
|
||||
/**
|
||||
* User notes
|
||||
* @maxLength 500
|
||||
*/
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for updating an inventory item.
|
||||
* At least one field must be provided.
|
||||
*/
|
||||
interface UpdateInventoryItemRequest {
|
||||
/** Updated quantity */
|
||||
quantity?: number;
|
||||
/**
|
||||
* Updated unit
|
||||
* @maxLength 50
|
||||
*/
|
||||
unit?: string;
|
||||
/** Updated expiry date (YYYY-MM-DD format) */
|
||||
expiry_date?: string;
|
||||
/** Updated storage location */
|
||||
location?: StorageLocation;
|
||||
/**
|
||||
* Updated notes
|
||||
* @maxLength 500
|
||||
*/
|
||||
notes?: string;
|
||||
/** Mark as consumed */
|
||||
is_consumed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for updating alert settings.
|
||||
*/
|
||||
interface UpdateAlertSettingsRequest {
|
||||
/**
|
||||
* Days before expiry to send alert
|
||||
* @minimum 1
|
||||
* @maximum 30
|
||||
*/
|
||||
days_before_expiry?: number;
|
||||
/** Whether this alert type is enabled */
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for expiring items list.
|
||||
*/
|
||||
interface ExpiringItemsListResponse {
|
||||
/** Array of expiring items */
|
||||
items: UserInventoryItem[];
|
||||
/** Total count of items */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for recipe suggestions.
|
||||
*/
|
||||
interface RecipeSuggestionsResponse {
|
||||
/** Recipes that use expiring items */
|
||||
recipes: unknown[];
|
||||
/** Total count for pagination */
|
||||
total: number;
|
||||
/** Items considered for matching */
|
||||
considered_items: UserInventoryItem[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INVENTORY CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for managing pantry inventory and expiry tracking.
|
||||
*
|
||||
* All endpoints require JWT authentication. Users can only access
|
||||
* their own inventory - the user ID is extracted from the JWT token.
|
||||
*/
|
||||
@Route('inventory')
|
||||
@Tags('Inventory')
|
||||
@Security('bearerAuth')
|
||||
export class InventoryController extends BaseController {
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get inventory items.
|
||||
*
|
||||
* Retrieves the user's pantry inventory with optional filtering and pagination.
|
||||
*
|
||||
* @summary Get inventory items
|
||||
* @param request Express request with authenticated user
|
||||
* @param limit Maximum number of items to return (default: 50, max: 100)
|
||||
* @param offset Number of items to skip for pagination (default: 0)
|
||||
* @param location Filter by storage location
|
||||
* @param is_consumed Filter by consumed status
|
||||
* @param expiring_within_days Filter items expiring within N days
|
||||
* @param category_id Filter by category ID
|
||||
* @param search Search by item name
|
||||
* @param sort_by Sort field
|
||||
* @param sort_order Sort direction
|
||||
* @returns List of inventory items
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'Inventory items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getInventory(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
@Query() location?: StorageLocation,
|
||||
@Query() is_consumed?: boolean,
|
||||
@Query() expiring_within_days?: number,
|
||||
@Query() category_id?: number,
|
||||
@Query() search?: string,
|
||||
@Query() sort_by?: 'expiry_date' | 'purchase_date' | 'item_name' | 'created_at',
|
||||
@Query() sort_order?: 'asc' | 'desc',
|
||||
): Promise<SuccessResponseType<unknown>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize pagination parameters
|
||||
const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 50)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const result = await expiryService.getInventory(
|
||||
{
|
||||
user_id: userProfile.user.user_id,
|
||||
location,
|
||||
is_consumed,
|
||||
expiring_within_days,
|
||||
category_id,
|
||||
search,
|
||||
limit: normalizedLimit,
|
||||
offset: normalizedOffset,
|
||||
sort_by,
|
||||
sort_order,
|
||||
},
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add inventory item.
|
||||
*
|
||||
* Add a new item to the user's pantry inventory.
|
||||
*
|
||||
* @summary Add inventory item
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Item data
|
||||
* @returns The created inventory item
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'Item added to inventory')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async addInventoryItem(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: AddInventoryItemRequest,
|
||||
): Promise<SuccessResponseType<UserInventoryItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
request.log.info(
|
||||
{ userId: userProfile.user.user_id, itemName: body.item_name },
|
||||
'Adding item to inventory',
|
||||
);
|
||||
|
||||
const item = await expiryService.addInventoryItem(userProfile.user.user_id, body, request.log);
|
||||
return this.created(item);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// EXPIRING ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get expiring items summary.
|
||||
*
|
||||
* Get items grouped by expiry urgency (today, this week, this month, expired).
|
||||
*
|
||||
* @summary Get expiring items summary
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Expiring items grouped by urgency with counts
|
||||
*/
|
||||
@Get('expiring/summary')
|
||||
@SuccessResponse(200, 'Expiring items grouped by urgency')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getExpiringSummary(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExpiringItemsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const result = await expiryService.getExpiringItemsGrouped(
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expiring items.
|
||||
*
|
||||
* Get items expiring within a specified number of days.
|
||||
*
|
||||
* @summary Get expiring items
|
||||
* @param request Express request with authenticated user
|
||||
* @param days Number of days to look ahead (1-90, default: 7)
|
||||
* @returns List of expiring items
|
||||
*/
|
||||
@Get('expiring')
|
||||
@SuccessResponse(200, 'Expiring items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getExpiringItems(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() days?: number,
|
||||
): Promise<SuccessResponseType<ExpiringItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize days parameter: default 7, min 1, max 90
|
||||
const normalizedDays = Math.min(90, Math.max(1, Math.floor(days ?? 7)));
|
||||
|
||||
const items = await expiryService.getExpiringItems(
|
||||
userProfile.user.user_id,
|
||||
normalizedDays,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expired items.
|
||||
*
|
||||
* Get all items that have already expired.
|
||||
*
|
||||
* @summary Get expired items
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of expired items
|
||||
*/
|
||||
@Get('expired')
|
||||
@SuccessResponse(200, 'Expired items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getExpiredItems(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExpiringItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const items = await expiryService.getExpiredItems(userProfile.user.user_id, request.log);
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ALERT SETTINGS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get alert settings.
|
||||
*
|
||||
* Get the user's expiry alert settings for all notification methods.
|
||||
*
|
||||
* @summary Get alert settings
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Alert settings for all methods
|
||||
*/
|
||||
@Get('alerts')
|
||||
@SuccessResponse(200, 'Alert settings retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getAlertSettings(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ExpiryAlertSettings[]>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const settings = await expiryService.getAlertSettings(userProfile.user.user_id, request.log);
|
||||
return this.success(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update alert settings.
|
||||
*
|
||||
* Update alert settings for a specific notification method.
|
||||
*
|
||||
* @summary Update alert settings
|
||||
* @param alertMethod The notification method to update (email, push, in_app)
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Settings to update
|
||||
* @returns Updated alert settings
|
||||
*/
|
||||
@Put('alerts/{alertMethod}')
|
||||
@SuccessResponse(200, 'Alert settings updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async updateAlertSettings(
|
||||
@Path() alertMethod: AlertMethod,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateAlertSettingsRequest,
|
||||
): Promise<SuccessResponseType<ExpiryAlertSettings>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
const settings = await expiryService.updateAlertSettings(
|
||||
userProfile.user.user_id,
|
||||
alertMethod,
|
||||
body,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(settings);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// RECIPE SUGGESTIONS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get recipe suggestions for expiring items.
|
||||
*
|
||||
* Get recipes that use items expiring soon to reduce food waste.
|
||||
*
|
||||
* @summary Get recipe suggestions for expiring items
|
||||
* @param request Express request with authenticated user
|
||||
* @param days Consider items expiring within this many days (1-90, default: 7)
|
||||
* @param limit Maximum number of recipes to return (1-50, default: 10)
|
||||
* @param offset Number of recipes to skip for pagination (default: 0)
|
||||
* @returns Recipe suggestions with matching expiring items
|
||||
*/
|
||||
@Get('recipes/suggestions')
|
||||
@SuccessResponse(200, 'Recipe suggestions retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getRecipeSuggestions(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() days?: number,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
): Promise<SuccessResponseType<RecipeSuggestionsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize parameters
|
||||
const normalizedDays = Math.min(90, Math.max(1, Math.floor(days ?? 7)));
|
||||
const normalizedLimit = Math.min(50, Math.max(1, Math.floor(limit ?? 10)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const result = await expiryService.getRecipeSuggestionsForExpiringItems(
|
||||
userProfile.user.user_id,
|
||||
normalizedDays,
|
||||
request.log,
|
||||
{ limit: normalizedLimit, offset: normalizedOffset },
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// INVENTORY ITEM BY ID ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get inventory item by ID.
|
||||
*
|
||||
* Retrieve a specific inventory item.
|
||||
*
|
||||
* @summary Get inventory item by ID
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
* @returns The inventory item
|
||||
*/
|
||||
@Get('{inventoryId}')
|
||||
@SuccessResponse(200, 'Inventory item retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async getInventoryItemById(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<UserInventoryItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const item = await expiryService.getInventoryItemById(
|
||||
inventoryId,
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update inventory item.
|
||||
*
|
||||
* Update an existing inventory item. At least one field must be provided.
|
||||
*
|
||||
* @summary Update inventory item
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Fields to update
|
||||
* @returns The updated inventory item
|
||||
*/
|
||||
@Put('{inventoryId}')
|
||||
@SuccessResponse(200, 'Item updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error - at least one field required')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async updateInventoryItem(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateInventoryItemRequest,
|
||||
): Promise<SuccessResponseType<UserInventoryItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Validate at least one field is provided
|
||||
if (Object.keys(body).length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('At least one field to update must be provided.');
|
||||
}
|
||||
|
||||
const item = await expiryService.updateInventoryItem(
|
||||
inventoryId,
|
||||
userProfile.user.user_id,
|
||||
body,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete inventory item.
|
||||
*
|
||||
* Remove an item from the user's inventory.
|
||||
*
|
||||
* @summary Delete inventory item
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Delete('{inventoryId}')
|
||||
@SuccessResponse(204, 'Item deleted')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async deleteInventoryItem(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await expiryService.deleteInventoryItem(inventoryId, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark item as consumed.
|
||||
*
|
||||
* Mark an inventory item as consumed.
|
||||
*
|
||||
* @summary Mark item as consumed
|
||||
* @param inventoryId The unique identifier of the inventory item
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Post('{inventoryId}/consume')
|
||||
@SuccessResponse(204, 'Item marked as consumed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async markItemConsumed(
|
||||
@Path() inventoryId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await expiryService.markItemConsumed(inventoryId, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
}
|
||||
476
src/controllers/personalization.controller.test.ts
Normal file
476
src/controllers/personalization.controller.test.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
// src/controllers/personalization.controller.test.ts
|
||||
// ============================================================================
|
||||
// PERSONALIZATION CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the PersonalizationController class. These tests verify
|
||||
// controller logic in isolation by mocking the personalization repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Query: () => () => {},
|
||||
Request: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock personalization repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
personalizationRepo: {
|
||||
getAllMasterItems: vi.fn(),
|
||||
getDietaryRestrictions: vi.fn(),
|
||||
getAppliances: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
publicReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { PersonalizationController } from './personalization.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedPersonalizationRepo = db.personalizationRepo as Mocked<typeof db.personalizationRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
res: {
|
||||
set: vi.fn(),
|
||||
},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock master grocery item.
|
||||
*/
|
||||
function createMockMasterItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
master_item_id: 1,
|
||||
name: 'Milk 2%',
|
||||
category_id: 1,
|
||||
category_name: 'Dairy & Eggs',
|
||||
typical_shelf_life_days: 14,
|
||||
storage_recommendation: 'refrigerator',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock dietary restriction.
|
||||
*/
|
||||
function createMockDietaryRestriction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
restriction_id: 1,
|
||||
name: 'Vegetarian',
|
||||
description: 'No meat or fish',
|
||||
icon: 'leaf',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock appliance.
|
||||
*/
|
||||
function createMockAppliance(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
appliance_id: 1,
|
||||
name: 'Air Fryer',
|
||||
icon: 'air-fryer',
|
||||
category: 'cooking',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('PersonalizationController', () => {
|
||||
let controller: PersonalizationController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new PersonalizationController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// MASTER ITEMS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getMasterItems()', () => {
|
||||
it('should return master items without pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
items: [createMockMasterItem(), createMockMasterItem({ master_item_id: 2, name: 'Bread' })],
|
||||
total: 2,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
undefined, // no limit
|
||||
0, // default offset
|
||||
);
|
||||
});
|
||||
|
||||
it('should support pagination with limit and offset', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
items: [createMockMasterItem()],
|
||||
total: 100,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 50, 100);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
50,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 500', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 1000, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
500,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor limit at 1', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 0, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor offset at 0', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 50, -10);
|
||||
|
||||
// Assert
|
||||
expect(mockedPersonalizationRepo.getAllMasterItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
50,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set cache control header', async () => {
|
||||
// Arrange
|
||||
const mockSet = vi.fn();
|
||||
const request = createMockRequest({
|
||||
res: { set: mockSet } as unknown as ExpressRequest['res'],
|
||||
});
|
||||
const mockResult = { items: [], total: 0 };
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(mockSet).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
|
||||
});
|
||||
|
||||
it('should log request details', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
const mockResult = { items: [], total: 0 };
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getMasterItems(request, 100, 50);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
limit: 100,
|
||||
offset: 50,
|
||||
}),
|
||||
'Fetching master items list from database...',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DIETARY RESTRICTIONS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getDietaryRestrictions()', () => {
|
||||
it('should return dietary restrictions', async () => {
|
||||
// Arrange
|
||||
const mockRestrictions = [
|
||||
createMockDietaryRestriction(),
|
||||
createMockDietaryRestriction({ restriction_id: 2, name: 'Vegan' }),
|
||||
createMockDietaryRestriction({ restriction_id: 3, name: 'Gluten-Free' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue(mockRestrictions);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].name).toBe('Vegetarian');
|
||||
}
|
||||
expect(mockedPersonalizationRepo.getDietaryRestrictions).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no restrictions exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should set cache control header', async () => {
|
||||
// Arrange
|
||||
const mockSet = vi.fn();
|
||||
const request = createMockRequest({
|
||||
res: { set: mockSet } as unknown as ExpressRequest['res'],
|
||||
});
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(mockSet).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// APPLIANCES ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAppliances()', () => {
|
||||
it('should return appliances', async () => {
|
||||
// Arrange
|
||||
const mockAppliances = [
|
||||
createMockAppliance(),
|
||||
createMockAppliance({ appliance_id: 2, name: 'Instant Pot' }),
|
||||
createMockAppliance({ appliance_id: 3, name: 'Stand Mixer' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue(mockAppliances);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].name).toBe('Air Fryer');
|
||||
}
|
||||
expect(mockedPersonalizationRepo.getAppliances).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no appliances exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should set cache control header', async () => {
|
||||
// Arrange
|
||||
const mockSet = vi.fn();
|
||||
const request = createMockRequest({
|
||||
res: { set: mockSet } as unknown as ExpressRequest['res'],
|
||||
});
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(mockSet).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ACCESS (NO AUTH REQUIRED)
|
||||
// ==========================================================================
|
||||
|
||||
describe('Public access', () => {
|
||||
it('should work without user authentication for master items', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [createMockMasterItem()], total: 1 };
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should work without user authentication for dietary restrictions', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedPersonalizationRepo.getDietaryRestrictions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should work without user authentication for appliances', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedPersonalizationRepo.getAppliances.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAppliances(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockResult = { items: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPersonalizationRepo.getAllMasterItems.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMasterItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/controllers/personalization.controller.ts
Normal file
150
src/controllers/personalization.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// src/controllers/personalization.controller.ts
|
||||
// ============================================================================
|
||||
// PERSONALIZATION CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for personalization data including master grocery items,
|
||||
// dietary restrictions, and kitchen appliances. These are public endpoints
|
||||
// used by the frontend for dropdown/selection components.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Query, Request, SuccessResponse, Middlewares } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType } from './types';
|
||||
import * as db from '../services/db/index.db';
|
||||
import type { MasterGroceryItem, DietaryRestriction, Appliance } from '../types';
|
||||
import { publicReadLimiter } from '../config/rateLimiters';
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response for paginated master items list.
|
||||
*/
|
||||
interface MasterItemsResponse {
|
||||
/** Array of master grocery items */
|
||||
items: MasterGroceryItem[];
|
||||
/** Total count of all items */
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PERSONALIZATION CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for personalization reference data.
|
||||
*
|
||||
* All endpoints are public and do not require authentication.
|
||||
* Data is used for dropdown/selection components in the UI.
|
||||
*
|
||||
* Responses are cached for 1 hour (Cache-Control header) as this
|
||||
* reference data changes infrequently.
|
||||
*/
|
||||
@Route('personalization')
|
||||
@Tags('Personalization')
|
||||
export class PersonalizationController extends BaseController {
|
||||
// ==========================================================================
|
||||
// MASTER ITEMS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get master items list.
|
||||
*
|
||||
* Get the master list of all grocery items with optional pagination.
|
||||
* Response is cached for 1 hour as this data changes infrequently.
|
||||
*
|
||||
* @summary Get master items list
|
||||
* @param request Express request for logging and response headers
|
||||
* @param limit Maximum number of items to return (max: 500). If omitted, returns all items.
|
||||
* @param offset Number of items to skip for pagination (default: 0)
|
||||
* @returns Paginated list of master grocery items with total count
|
||||
*/
|
||||
@Get('master-items')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of master grocery items with total count')
|
||||
public async getMasterItems(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
): Promise<SuccessResponseType<MasterItemsResponse>> {
|
||||
// Normalize parameters
|
||||
const normalizedLimit =
|
||||
limit !== undefined ? Math.min(500, Math.max(1, Math.floor(limit))) : undefined;
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
// Log database call for tracking
|
||||
request.log.info(
|
||||
{ limit: normalizedLimit, offset: normalizedOffset },
|
||||
'Fetching master items list from database...',
|
||||
);
|
||||
|
||||
// Set cache control header - data changes rarely
|
||||
request.res?.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const result = await db.personalizationRepo.getAllMasterItems(
|
||||
request.log,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DIETARY RESTRICTIONS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get dietary restrictions.
|
||||
*
|
||||
* Get the master list of all available dietary restrictions.
|
||||
* Response is cached for 1 hour.
|
||||
*
|
||||
* @summary Get dietary restrictions
|
||||
* @param request Express request for logging and response headers
|
||||
* @returns List of all dietary restrictions
|
||||
*/
|
||||
@Get('dietary-restrictions')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of all dietary restrictions')
|
||||
public async getDietaryRestrictions(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<DietaryRestriction[]>> {
|
||||
// Set cache control header - data changes rarely
|
||||
request.res?.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const restrictions = await db.personalizationRepo.getDietaryRestrictions(request.log);
|
||||
return this.success(restrictions);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// APPLIANCES ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get kitchen appliances.
|
||||
*
|
||||
* Get the master list of all available kitchen appliances.
|
||||
* Response is cached for 1 hour.
|
||||
*
|
||||
* @summary Get kitchen appliances
|
||||
* @param request Express request for logging and response headers
|
||||
* @returns List of all kitchen appliances
|
||||
*/
|
||||
@Get('appliances')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of all kitchen appliances')
|
||||
public async getAppliances(
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<Appliance[]>> {
|
||||
// Set cache control header - data changes rarely
|
||||
request.res?.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const appliances = await db.personalizationRepo.getAppliances(request.log);
|
||||
return this.success(appliances);
|
||||
}
|
||||
}
|
||||
43
src/controllers/placeholder.controller.ts
Normal file
43
src/controllers/placeholder.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Placeholder controller for tsoa configuration verification.
|
||||
*
|
||||
* This minimal controller exists only to verify that tsoa is correctly configured.
|
||||
* It should be removed once actual controllers are implemented.
|
||||
*
|
||||
* @see ADR-055 for the OpenAPI specification migration plan
|
||||
*/
|
||||
import { Controller, Get, Route, Tags } from 'tsoa';
|
||||
|
||||
/**
|
||||
* Placeholder response type for configuration verification.
|
||||
*/
|
||||
interface PlaceholderResponse {
|
||||
message: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder controller for verifying tsoa configuration.
|
||||
*
|
||||
* This controller is temporary and should be removed once actual
|
||||
* API controllers are implemented using tsoa decorators.
|
||||
*/
|
||||
@Route('_tsoa')
|
||||
@Tags('Internal')
|
||||
export class PlaceholderController extends Controller {
|
||||
/**
|
||||
* Verify tsoa configuration is working.
|
||||
*
|
||||
* This endpoint exists only for configuration verification and
|
||||
* should be removed in production.
|
||||
*
|
||||
* @returns A simple message confirming tsoa is configured
|
||||
*/
|
||||
@Get('verify')
|
||||
public async verify(): Promise<PlaceholderResponse> {
|
||||
return {
|
||||
message: 'tsoa is correctly configured',
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
393
src/controllers/price.controller.test.ts
Normal file
393
src/controllers/price.controller.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
// src/controllers/price.controller.test.ts
|
||||
// ============================================================================
|
||||
// PRICE CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the PriceController class. These tests verify controller
|
||||
// logic in isolation by mocking the price repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock price repository
|
||||
vi.mock('../services/db/price.db', () => ({
|
||||
priceRepo: {
|
||||
getPriceHistory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { priceRepo } from '../services/db/price.db';
|
||||
import { PriceController } from './price.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedPriceRepo = priceRepo as Mocked<typeof priceRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock price history data point.
|
||||
*/
|
||||
function createMockPriceHistoryData(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
master_item_id: 1,
|
||||
price_cents: 350,
|
||||
flyer_start_date: '2024-01-15',
|
||||
flyer_id: 10,
|
||||
store_name: 'Superstore',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('PriceController', () => {
|
||||
let controller: PriceController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new PriceController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GET PRICE HISTORY
|
||||
// ==========================================================================
|
||||
|
||||
describe('getPriceHistory()', () => {
|
||||
it('should return price history for specified items', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData(),
|
||||
createMockPriceHistoryData({ flyer_start_date: '2024-01-08', price_cents: 399 }),
|
||||
createMockPriceHistoryData({ master_item_id: 2, price_cents: 450 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1, 2],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].price_cents).toBe(350);
|
||||
}
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1, 2],
|
||||
expect.anything(),
|
||||
1000, // default limit
|
||||
0, // default offset
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default limit and offset when not provided', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith([1], expect.anything(), 1000, 0);
|
||||
});
|
||||
|
||||
it('should use custom limit and offset', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
limit: 500,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1],
|
||||
expect.anything(),
|
||||
500,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when masterItemIds is empty', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error when masterItemIds is not an array', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: null as unknown as number[],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should normalize limit to at least 1', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1],
|
||||
expect.anything(),
|
||||
1, // floored to 1
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize offset to at least 0', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
offset: -10,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1],
|
||||
expect.anything(),
|
||||
1000,
|
||||
0, // floored to 0
|
||||
);
|
||||
});
|
||||
|
||||
it('should log request details', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1, 2, 3],
|
||||
limit: 100,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
itemCount: 3,
|
||||
limit: 100,
|
||||
offset: 50,
|
||||
}),
|
||||
'[API /price-history] Received request for historical price data.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no price history exists', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle single item request', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData(),
|
||||
createMockPriceHistoryData({ flyer_start_date: '2024-01-08' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith([1], expect.anything(), 1000, 0);
|
||||
});
|
||||
|
||||
it('should handle multiple items request', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [
|
||||
createMockPriceHistoryData({ master_item_id: 1 }),
|
||||
createMockPriceHistoryData({ master_item_id: 2 }),
|
||||
createMockPriceHistoryData({ master_item_id: 3 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1, 2, 3, 4, 5],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedPriceRepo.getPriceHistory).toHaveBeenCalledWith(
|
||||
[1, 2, 3, 4, 5],
|
||||
expect.anything(),
|
||||
1000,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockPriceHistory = [createMockPriceHistoryData()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedPriceRepo.getPriceHistory.mockResolvedValue(mockPriceHistory);
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [1],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for validation errors', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.getPriceHistory(request, {
|
||||
masterItemIds: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/controllers/price.controller.ts
Normal file
113
src/controllers/price.controller.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// src/controllers/price.controller.ts
|
||||
// ============================================================================
|
||||
// PRICE CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for retrieving historical price data for grocery items.
|
||||
// Used for price trend analysis and charting.
|
||||
//
|
||||
// All endpoints require authentication.
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Post, Route, Tags, Security, Body, Request, SuccessResponse, Response } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { priceRepo } from '../services/db/price.db';
|
||||
import type { PriceHistoryData } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for fetching price history.
|
||||
*/
|
||||
interface PriceHistoryRequest {
|
||||
/**
|
||||
* Array of master item IDs to get price history for.
|
||||
* Must be a non-empty array of positive integers.
|
||||
*/
|
||||
masterItemIds: number[];
|
||||
/**
|
||||
* Maximum number of price points to return.
|
||||
* @default 1000
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Number of price points to skip.
|
||||
* @default 0
|
||||
*/
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRICE CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for retrieving price history data.
|
||||
*
|
||||
* All endpoints require JWT authentication. Price history is fetched
|
||||
* for specified master grocery items, useful for trend analysis and charting.
|
||||
*/
|
||||
@Route('price-history')
|
||||
@Tags('Price')
|
||||
@Security('bearerAuth')
|
||||
export class PriceController extends BaseController {
|
||||
// ==========================================================================
|
||||
// GET PRICE HISTORY
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get price history for specified items.
|
||||
*
|
||||
* Fetches historical price data for a given list of master item IDs.
|
||||
* Returns the price in cents and the start date of each flyer where
|
||||
* the item appeared, ordered by master_item_id and date ascending.
|
||||
*
|
||||
* Use POST instead of GET because the list of item IDs can be large
|
||||
* and would exceed URL length limits as query parameters.
|
||||
*
|
||||
* @summary Get price history
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Request body with master item IDs and optional pagination
|
||||
* @returns Historical price data for specified items
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(200, 'Historical price data for specified items')
|
||||
@Response<ErrorResponse>(400, 'Validation error - masterItemIds must be a non-empty array')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getPriceHistory(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: PriceHistoryRequest,
|
||||
): Promise<SuccessResponseType<PriceHistoryData[]>> {
|
||||
const { masterItemIds, limit = 1000, offset = 0 } = body;
|
||||
|
||||
// Validate masterItemIds
|
||||
if (!Array.isArray(masterItemIds) || masterItemIds.length === 0) {
|
||||
this.setStatus(400);
|
||||
return this.error(
|
||||
this.ErrorCode.VALIDATION_ERROR,
|
||||
'masterItemIds must be a non-empty array of positive integers.',
|
||||
) as unknown as SuccessResponseType<PriceHistoryData[]>;
|
||||
}
|
||||
|
||||
// Normalize limit and offset
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset));
|
||||
|
||||
request.log.info(
|
||||
{ itemCount: masterItemIds.length, limit: normalizedLimit, offset: normalizedOffset },
|
||||
'[API /price-history] Received request for historical price data.',
|
||||
);
|
||||
|
||||
const priceHistory = await priceRepo.getPriceHistory(
|
||||
masterItemIds,
|
||||
request.log,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
);
|
||||
return this.success(priceHistory);
|
||||
}
|
||||
}
|
||||
531
src/controllers/reactions.controller.test.ts
Normal file
531
src/controllers/reactions.controller.test.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
// src/controllers/reactions.controller.test.ts
|
||||
// ============================================================================
|
||||
// REACTIONS CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the ReactionsController class. These tests verify controller
|
||||
// logic in isolation by mocking the reaction repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock reaction repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
reactionRepo: {
|
||||
getReactions: vi.fn(),
|
||||
getReactionSummary: vi.fn(),
|
||||
toggleReaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
publicReadLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
reactionToggleLimiter: (req: unknown, res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { reactionRepo } from '../services/db/index.db';
|
||||
import { ReactionsController } from './reactions.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedReactionRepo = reactionRepo as Mocked<typeof reactionRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user reaction.
|
||||
*/
|
||||
function createMockReaction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
reaction_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock reaction summary entry.
|
||||
*/
|
||||
function createMockReactionSummary(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
reaction_type: 'like',
|
||||
count: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('ReactionsController', () => {
|
||||
let controller: ReactionsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new ReactionsController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getReactions()', () => {
|
||||
it('should return reactions without filters', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [
|
||||
createMockReaction(),
|
||||
createMockReaction({ reaction_id: 2, reaction_type: 'love' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: undefined, entityType: undefined, entityId: undefined },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, 'user-123');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: 'user-123', entityType: undefined, entityId: undefined },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by entityType', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, undefined, 'recipe');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: undefined, entityType: 'recipe', entityId: undefined },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by entityId', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, undefined, undefined, '123');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: undefined, entityType: undefined, entityId: '123' },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support multiple filters', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
await controller.getReactions(request, 'user-123', 'recipe', '456');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
{ userId: 'user-123', entityType: 'recipe', entityId: '456' },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no reactions exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockReactions = [createMockReaction()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue(mockReactions);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReactionSummary()', () => {
|
||||
it('should return reaction summary for an entity', async () => {
|
||||
// Arrange
|
||||
const mockSummary = [
|
||||
createMockReactionSummary(),
|
||||
createMockReactionSummary({ reaction_type: 'love', count: 5 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactionSummary(request, 'recipe', '123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].reaction_type).toBe('like');
|
||||
expect(result.data[0].count).toBe(10);
|
||||
}
|
||||
expect(mockedReactionRepo.getReactionSummary).toHaveBeenCalledWith(
|
||||
'recipe',
|
||||
'123',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no reactions exist for entity', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactionSummary(request, 'recipe', '999');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with different entity types', async () => {
|
||||
// Arrange
|
||||
const mockSummary = [createMockReactionSummary()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
// Act
|
||||
await controller.getReactionSummary(request, 'comment', '456');
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.getReactionSummary).toHaveBeenCalledWith(
|
||||
'comment',
|
||||
'456',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockSummary = [createMockReactionSummary()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedReactionRepo.getReactionSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactionSummary(request, 'recipe', '123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('toggleReaction()', () => {
|
||||
it('should add reaction when it does not exist', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction added.');
|
||||
expect((result.data as { reaction: typeof mockReaction }).reaction).toEqual(mockReaction);
|
||||
}
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
{
|
||||
user_id: 'test-user-id',
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove reaction when it already exists', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction removed.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should use user ID from authenticated profile', async () => {
|
||||
// Arrange
|
||||
const customProfile = {
|
||||
full_name: 'Custom User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'custom-user-id',
|
||||
email: 'custom@example.com',
|
||||
},
|
||||
};
|
||||
const request = createMockRequest({ user: customProfile });
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_id: 'custom-user-id' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support different reaction types', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction({ reaction_type: 'love' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'love',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reaction_type: 'love' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support different entity types', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction({ entity_type: 'comment' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
await controller.toggleReaction(request, {
|
||||
entity_type: 'comment',
|
||||
entity_id: '456',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedReactionRepo.toggleReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entity_type: 'comment', entity_id: '456' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.getReactions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReactions(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should set 201 status when reaction is added', async () => {
|
||||
// Arrange
|
||||
const mockReaction = createMockReaction();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(mockReaction);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction added.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should set 200 status when reaction is removed', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReactionRepo.toggleReaction.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.toggleReaction(request, {
|
||||
entity_type: 'recipe',
|
||||
entity_id: '123',
|
||||
reaction_type: 'like',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Reaction removed.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
204
src/controllers/reactions.controller.ts
Normal file
204
src/controllers/reactions.controller.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/controllers/reactions.controller.ts
|
||||
// ============================================================================
|
||||
// REACTIONS CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for user reactions on content (recipes, comments, etc.).
|
||||
// Includes public endpoints for viewing reactions and authenticated endpoint
|
||||
// for toggling reactions.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
Middlewares,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { reactionRepo } from '../services/db/index.db';
|
||||
import type { UserProfile, UserReaction } from '../types';
|
||||
import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for toggling a reaction.
|
||||
*/
|
||||
interface ToggleReactionRequest {
|
||||
/**
|
||||
* Entity type (e.g., 'recipe', 'comment')
|
||||
* @minLength 1
|
||||
*/
|
||||
entity_type: string;
|
||||
/**
|
||||
* Entity ID
|
||||
* @minLength 1
|
||||
*/
|
||||
entity_id: string;
|
||||
/**
|
||||
* Type of reaction (e.g., 'like', 'love')
|
||||
* @minLength 1
|
||||
*/
|
||||
reaction_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for toggling a reaction - when added.
|
||||
*/
|
||||
interface ReactionAddedResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** The created reaction */
|
||||
reaction: UserReaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for toggling a reaction - when removed.
|
||||
*/
|
||||
interface ReactionRemovedResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction summary entry showing count by type.
|
||||
*/
|
||||
interface ReactionSummaryEntry {
|
||||
/** Reaction type */
|
||||
reaction_type: string;
|
||||
/** Count of this reaction type */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REACTIONS CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for user reactions on content.
|
||||
*
|
||||
* Public endpoints:
|
||||
* - GET /reactions - Get reactions with optional filters
|
||||
* - GET /reactions/summary - Get reaction summary for an entity
|
||||
*
|
||||
* Authenticated endpoints:
|
||||
* - POST /reactions/toggle - Toggle (add/remove) a reaction
|
||||
*/
|
||||
@Route('reactions')
|
||||
@Tags('Reactions')
|
||||
export class ReactionsController extends BaseController {
|
||||
// ==========================================================================
|
||||
// PUBLIC ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get reactions.
|
||||
*
|
||||
* Fetches user reactions based on query filters. Supports filtering by
|
||||
* userId, entityType, and entityId. All filters are optional.
|
||||
*
|
||||
* @summary Get reactions
|
||||
* @param request Express request for logging
|
||||
* @param userId Filter by user ID (UUID format)
|
||||
* @param entityType Filter by entity type (e.g., 'recipe', 'comment')
|
||||
* @param entityId Filter by entity ID
|
||||
* @returns List of reactions matching filters
|
||||
*/
|
||||
@Get()
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'List of reactions matching filters')
|
||||
public async getReactions(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() userId?: string,
|
||||
@Query() entityType?: string,
|
||||
@Query() entityId?: string,
|
||||
): Promise<SuccessResponseType<UserReaction[]>> {
|
||||
const reactions = await reactionRepo.getReactions(
|
||||
{ userId, entityType, entityId },
|
||||
request.log,
|
||||
);
|
||||
return this.success(reactions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reaction summary.
|
||||
*
|
||||
* Fetches a summary of reactions for a specific entity, showing
|
||||
* the count of each reaction type.
|
||||
*
|
||||
* @summary Get reaction summary
|
||||
* @param request Express request for logging
|
||||
* @param entityType Entity type (e.g., 'recipe', 'comment') - required
|
||||
* @param entityId Entity ID - required
|
||||
* @returns Reaction summary with counts by type
|
||||
*/
|
||||
@Get('summary')
|
||||
@Middlewares(publicReadLimiter)
|
||||
@SuccessResponse(200, 'Reaction summary with counts by type')
|
||||
@Response<ErrorResponse>(400, 'Missing required query parameters')
|
||||
public async getReactionSummary(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() entityType: string,
|
||||
@Query() entityId: string,
|
||||
): Promise<SuccessResponseType<ReactionSummaryEntry[]>> {
|
||||
const summary = await reactionRepo.getReactionSummary(entityType, entityId, request.log);
|
||||
return this.success(summary);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Toggle reaction.
|
||||
*
|
||||
* Toggles a user's reaction to an entity. If the reaction exists,
|
||||
* it's removed; otherwise, it's added.
|
||||
*
|
||||
* @summary Toggle reaction
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Reaction details
|
||||
* @returns Reaction added (201) or removed (200) confirmation
|
||||
*/
|
||||
@Post('toggle')
|
||||
@Security('bearerAuth')
|
||||
@Middlewares(reactionToggleLimiter)
|
||||
@SuccessResponse(200, 'Reaction removed')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async toggleReaction(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: ToggleReactionRequest,
|
||||
): Promise<SuccessResponseType<ReactionAddedResponse | ReactionRemovedResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
const reactionData = {
|
||||
user_id: userProfile.user.user_id,
|
||||
entity_type: body.entity_type,
|
||||
entity_id: body.entity_id,
|
||||
reaction_type: body.reaction_type,
|
||||
};
|
||||
|
||||
const result = await reactionRepo.toggleReaction(reactionData, request.log);
|
||||
|
||||
if (result) {
|
||||
// Reaction was added
|
||||
this.setStatus(201);
|
||||
return this.success({ message: 'Reaction added.', reaction: result });
|
||||
} else {
|
||||
// Reaction was removed
|
||||
return this.success({ message: 'Reaction removed.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
659
src/controllers/receipt.controller.test.ts
Normal file
659
src/controllers/receipt.controller.test.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
// src/controllers/receipt.controller.test.ts
|
||||
// ============================================================================
|
||||
// RECEIPT CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the ReceiptController class. These tests verify controller
|
||||
// logic in isolation by mocking the receipt service and queue.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
FormField: () => () => {},
|
||||
UploadedFile: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock receipt service
|
||||
vi.mock('../services/receiptService.server', () => ({
|
||||
getReceipts: vi.fn(),
|
||||
createReceipt: vi.fn(),
|
||||
getReceiptById: vi.fn(),
|
||||
deleteReceipt: vi.fn(),
|
||||
getReceiptItems: vi.fn(),
|
||||
getUnaddedItems: vi.fn(),
|
||||
updateReceiptItem: vi.fn(),
|
||||
getProcessingLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock expiry service
|
||||
vi.mock('../services/expiryService.server', () => ({
|
||||
addItemsFromReceipt: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock receipt queue
|
||||
vi.mock('../services/queues.server', () => ({
|
||||
receiptQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as receiptService from '../services/receiptService.server';
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import { receiptQueue } from '../services/queues.server';
|
||||
import { ReceiptController } from './receipt.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedReceiptService = receiptService as Mocked<typeof receiptService>;
|
||||
const mockedExpiryService = expiryService as Mocked<typeof expiryService>;
|
||||
const mockedReceiptQueue = receiptQueue as Mocked<typeof receiptQueue>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
file: undefined,
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
bindings: vi.fn().mockReturnValue({ request_id: 'test-request-id' }),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock receipt scan record.
|
||||
*/
|
||||
function createMockReceipt(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
receipt_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
receipt_image_url: '/uploads/receipt-123.jpg',
|
||||
status: 'processed' as const,
|
||||
store_location_id: null,
|
||||
transaction_date: '2024-01-15',
|
||||
total_amount_cents: 5000,
|
||||
tax_amount_cents: 500,
|
||||
item_count: 5,
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
updated_at: '2024-01-15T10:00:00.000Z',
|
||||
processed_at: '2024-01-15T10:01:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock receipt item.
|
||||
*/
|
||||
function createMockReceiptItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
receipt_item_id: 1,
|
||||
receipt_id: 1,
|
||||
raw_text: 'Milk 2%',
|
||||
quantity: 1,
|
||||
price_cents: 350,
|
||||
unit_price_cents: 350,
|
||||
status: 'matched' as const,
|
||||
master_item_id: 100,
|
||||
product_id: 200,
|
||||
match_confidence: 0.95,
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock processing log record.
|
||||
*/
|
||||
function createMockProcessingLog(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
log_id: 1,
|
||||
receipt_id: 1,
|
||||
status: 'processing',
|
||||
message: 'Started processing receipt',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('ReceiptController', () => {
|
||||
let controller: ReceiptController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new ReceiptController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// RECEIPT MANAGEMENT ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getReceipts()', () => {
|
||||
it('should return receipts with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
receipts: [createMockReceipt()],
|
||||
total: 1,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceipts(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.receipts).toHaveLength(1);
|
||||
expect(result.data.total).toBe(1);
|
||||
}
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: 'test-user-id',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getReceipts(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support filtering by status', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getReceipts(request, 50, 0, 'processed');
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'processed' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support date range filtering', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getReceipts(
|
||||
request,
|
||||
50,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
'2024-01-01',
|
||||
'2024-01-31',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.getReceipts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from_date: '2024-01-01',
|
||||
to_date: '2024-01-31',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadReceipt()', () => {
|
||||
it('should upload a receipt and queue for processing', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt({ status: 'pending' });
|
||||
const mockFile = {
|
||||
filename: 'receipt-123.jpg',
|
||||
path: '/uploads/receipt-123.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 1024,
|
||||
};
|
||||
const request = createMockRequest({ file: mockFile as Express.Multer.File });
|
||||
|
||||
mockedReceiptService.createReceipt.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-123' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadReceipt(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.receipt_id).toBe(1);
|
||||
expect(result.data.job_id).toBe('job-123');
|
||||
}
|
||||
expect(mockedReceiptService.createReceipt).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'/uploads/receipt-123.jpg',
|
||||
expect.anything(),
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(mockedReceiptQueue.add).toHaveBeenCalledWith(
|
||||
'process-receipt',
|
||||
expect.objectContaining({
|
||||
receiptId: 1,
|
||||
userId: 'test-user-id',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when no file is uploaded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.uploadReceipt(request)).rejects.toThrow('Receipt image is required.');
|
||||
});
|
||||
|
||||
it('should support optional store location and transaction date', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockFile = {
|
||||
filename: 'receipt-123.jpg',
|
||||
path: '/uploads/receipt-123.jpg',
|
||||
};
|
||||
const request = createMockRequest({ file: mockFile as Express.Multer.File });
|
||||
|
||||
mockedReceiptService.createReceipt.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-123' } as never);
|
||||
|
||||
// Act
|
||||
await controller.uploadReceipt(request, 5, '2024-01-15');
|
||||
|
||||
// Assert
|
||||
expect(mockedReceiptService.createReceipt).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'/uploads/receipt-123.jpg',
|
||||
expect.anything(),
|
||||
{
|
||||
storeLocationId: 5,
|
||||
transactionDate: '2024-01-15',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReceiptById()', () => {
|
||||
it('should return a receipt with its items', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockItems = [createMockReceiptItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getReceiptItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceiptById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.receipt.receipt_id).toBe(1);
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteReceipt()', () => {
|
||||
it('should delete a receipt', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.deleteReceipt.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteReceipt(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedReceiptService.deleteReceipt).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reprocessReceipt()', () => {
|
||||
it('should queue a receipt for reprocessing', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt({ status: 'failed' });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-456' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.reprocessReceipt(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Receipt queued for reprocessing');
|
||||
expect(result.data.receipt_id).toBe(1);
|
||||
expect(result.data.job_id).toBe('job-456');
|
||||
}
|
||||
expect(mockedReceiptQueue.add).toHaveBeenCalledWith(
|
||||
'process-receipt',
|
||||
expect.objectContaining({
|
||||
receiptId: 1,
|
||||
imagePath: '/uploads/receipt-123.jpg',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// RECEIPT ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getReceiptItems()', () => {
|
||||
it('should return receipt items', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockItems = [createMockReceiptItem(), createMockReceiptItem({ receipt_item_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getReceiptItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceiptItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnaddedItems()', () => {
|
||||
it('should return unadded receipt items', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockItems = [createMockReceiptItem({ status: 'unmatched' })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getUnaddedItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUnaddedItems(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.data.total).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReceiptItem()', () => {
|
||||
it('should update a receipt item', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockUpdatedItem = createMockReceiptItem({ status: 'confirmed', match_confidence: 1.0 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.updateReceiptItem.mockResolvedValue(mockUpdatedItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateReceiptItem(1, 1, request, {
|
||||
status: 'confirmed',
|
||||
match_confidence: 1.0,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.status).toBe('confirmed');
|
||||
expect(result.data.match_confidence).toBe(1.0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject update with no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateReceiptItem(1, 1, request, {})).rejects.toThrow(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmItems()', () => {
|
||||
it('should confirm items and add to inventory', async () => {
|
||||
// Arrange
|
||||
const mockAddedItems = [
|
||||
{ inventory_id: 1, item_name: 'Milk' },
|
||||
{ inventory_id: 2, item_name: 'Bread' },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedExpiryService.addItemsFromReceipt.mockResolvedValue(mockAddedItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.confirmItems(1, request, {
|
||||
items: [
|
||||
{ receipt_item_id: 1, include: true },
|
||||
{ receipt_item_id: 2, include: true },
|
||||
],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.added_items).toHaveLength(2);
|
||||
expect(result.data.count).toBe(2);
|
||||
}
|
||||
expect(mockedExpiryService.addItemsFromReceipt).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
1,
|
||||
expect.arrayContaining([expect.objectContaining({ receipt_item_id: 1, include: true })]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log confirmation request', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
bindings: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedExpiryService.addItemsFromReceipt.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.confirmItems(1, request, {
|
||||
items: [{ receipt_item_id: 1, include: true }],
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'test-user-id',
|
||||
receiptId: 1,
|
||||
itemCount: 1,
|
||||
}),
|
||||
'Confirming receipt items for inventory',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PROCESSING LOGS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
describe('getProcessingLogs()', () => {
|
||||
it('should return processing logs', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockLogs = [
|
||||
createMockProcessingLog({ status: 'started' }),
|
||||
createMockProcessingLog({ log_id: 2, status: 'completed' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceiptById.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptService.getProcessingLogs.mockResolvedValue(mockLogs);
|
||||
|
||||
// Act
|
||||
const result = await controller.getProcessingLogs(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.logs).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockResult = { receipts: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.getReceipts.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getReceipts(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockReceipt = createMockReceipt();
|
||||
const mockFile = {
|
||||
filename: 'receipt.jpg',
|
||||
path: '/uploads/receipt.jpg',
|
||||
};
|
||||
const request = createMockRequest({ file: mockFile as Express.Multer.File });
|
||||
|
||||
mockedReceiptService.createReceipt.mockResolvedValue(mockReceipt);
|
||||
mockedReceiptQueue.add.mockResolvedValue({ id: 'job-1' } as never);
|
||||
|
||||
// Act
|
||||
const result = await controller.uploadReceipt(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedReceiptService.deleteReceipt.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteReceipt(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
578
src/controllers/receipt.controller.ts
Normal file
578
src/controllers/receipt.controller.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
// src/controllers/receipt.controller.ts
|
||||
// ============================================================================
|
||||
// RECEIPT CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for uploading, processing, and managing scanned receipts.
|
||||
// All endpoints require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Security,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
FormField,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as receiptService from '../services/receiptService.server';
|
||||
import * as expiryService from '../services/expiryService.server';
|
||||
import { receiptQueue } from '../services/queues.server';
|
||||
import type { UserProfile } from '../types';
|
||||
import type {
|
||||
ReceiptScan,
|
||||
ReceiptItem,
|
||||
ReceiptStatus,
|
||||
ReceiptItemStatus,
|
||||
ReceiptProcessingLogRecord,
|
||||
StorageLocation,
|
||||
} from '../types/expiry';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response for a receipt with its items.
|
||||
*/
|
||||
interface ReceiptWithItemsResponse {
|
||||
/** The receipt record */
|
||||
receipt: ReceiptScan;
|
||||
/** Extracted items from the receipt */
|
||||
items: ReceiptItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for receipt items list.
|
||||
*/
|
||||
interface ReceiptItemsListResponse {
|
||||
/** Array of receipt items */
|
||||
items: ReceiptItem[];
|
||||
/** Total count of items */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for receipt upload.
|
||||
*/
|
||||
interface ReceiptUploadResponse extends ReceiptScan {
|
||||
/** Background job ID for tracking processing */
|
||||
job_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for reprocessing a receipt.
|
||||
*/
|
||||
interface ReprocessResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** Receipt ID */
|
||||
receipt_id: number;
|
||||
/** Background job ID for tracking */
|
||||
job_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for processing logs.
|
||||
*/
|
||||
interface ProcessingLogsResponse {
|
||||
/** Array of log records */
|
||||
logs: ReceiptProcessingLogRecord[];
|
||||
/** Total count of logs */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for updating a receipt item.
|
||||
*/
|
||||
interface UpdateReceiptItemRequest {
|
||||
/** Matching status */
|
||||
status?: ReceiptItemStatus;
|
||||
/** Matched master item ID (null to unlink) */
|
||||
master_item_id?: number | null;
|
||||
/** Matched product ID (null to unlink) */
|
||||
product_id?: number | null;
|
||||
/**
|
||||
* Match confidence score
|
||||
* @minimum 0
|
||||
* @maximum 1
|
||||
*/
|
||||
match_confidence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item confirmation for adding to inventory.
|
||||
*/
|
||||
interface ConfirmItemEntry {
|
||||
/** Receipt item ID */
|
||||
receipt_item_id: number;
|
||||
/**
|
||||
* Override item name
|
||||
* @maxLength 255
|
||||
*/
|
||||
item_name?: string;
|
||||
/** Override quantity */
|
||||
quantity?: number;
|
||||
/** Storage location */
|
||||
location?: StorageLocation;
|
||||
/** Expiry date (YYYY-MM-DD format) */
|
||||
expiry_date?: string;
|
||||
/** Whether to add this item (false = skip) */
|
||||
include: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for confirming items to add to inventory.
|
||||
*/
|
||||
interface ConfirmItemsRequest {
|
||||
/** Array of items to confirm */
|
||||
items: ConfirmItemEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for confirmed items.
|
||||
*/
|
||||
interface ConfirmItemsResponse {
|
||||
/** Items added to inventory */
|
||||
added_items: unknown[];
|
||||
/** Count of items added */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECEIPT CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for managing receipt scanning and processing.
|
||||
*
|
||||
* All endpoints require JWT authentication. Users can only access
|
||||
* their own receipts - the user ID is extracted from the JWT token.
|
||||
*
|
||||
* Note: File upload functionality uses Express middleware that is
|
||||
* configured separately from tsoa. The POST /receipts endpoint
|
||||
* expects multipart/form-data with a 'receipt' file field.
|
||||
*/
|
||||
@Route('receipts')
|
||||
@Tags('Receipts')
|
||||
@Security('bearerAuth')
|
||||
export class ReceiptController extends BaseController {
|
||||
// ==========================================================================
|
||||
// RECEIPT MANAGEMENT ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get user's receipts.
|
||||
*
|
||||
* Retrieve the user's scanned receipts with optional filtering and pagination.
|
||||
*
|
||||
* @summary Get user's receipts
|
||||
* @param request Express request with authenticated user
|
||||
* @param limit Maximum number of receipts to return (default: 50, max: 100)
|
||||
* @param offset Number of receipts to skip for pagination (default: 0)
|
||||
* @param status Filter by processing status
|
||||
* @param store_location_id Filter by store location ID
|
||||
* @param from_date Filter by transaction date (start, YYYY-MM-DD format)
|
||||
* @param to_date Filter by transaction date (end, YYYY-MM-DD format)
|
||||
* @returns List of receipts with total count
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'Receipts retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async getReceipts(
|
||||
@Request() request: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
@Query() status?: ReceiptStatus,
|
||||
@Query() store_location_id?: number,
|
||||
@Query() from_date?: string,
|
||||
@Query() to_date?: string,
|
||||
): Promise<SuccessResponseType<{ receipts: ReceiptScan[]; total: number }>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Normalize pagination parameters
|
||||
const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 50)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
const result = await receiptService.getReceipts(
|
||||
{
|
||||
user_id: userProfile.user.user_id,
|
||||
status,
|
||||
store_location_id,
|
||||
from_date,
|
||||
to_date,
|
||||
limit: normalizedLimit,
|
||||
offset: normalizedOffset,
|
||||
},
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a receipt.
|
||||
*
|
||||
* Upload a receipt image for processing and item extraction.
|
||||
* The receipt will be queued for background processing.
|
||||
*
|
||||
* Note: This endpoint is handled by Express middleware for file uploads.
|
||||
* See receipt.routes.ts for the actual implementation with multer.
|
||||
*
|
||||
* @summary Upload a receipt
|
||||
* @param request Express request with authenticated user and uploaded file
|
||||
* @param store_location_id Store location ID if known
|
||||
* @param transaction_date Transaction date if known (YYYY-MM-DD format)
|
||||
* @returns Created receipt record with job ID
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'Receipt uploaded and queued for processing')
|
||||
@Response<ErrorResponse>(400, 'Validation error - no file uploaded')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
public async uploadReceipt(
|
||||
@Request() request: ExpressRequest,
|
||||
@FormField() store_location_id?: number,
|
||||
@FormField() transaction_date?: string,
|
||||
): Promise<SuccessResponseType<ReceiptUploadResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
const file = request.file as Express.Multer.File | undefined;
|
||||
|
||||
// Validate file was uploaded (middleware should handle this, but double-check)
|
||||
if (!file) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Receipt image is required.');
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
{ userId: userProfile.user.user_id, filename: file.filename },
|
||||
'Uploading receipt',
|
||||
);
|
||||
|
||||
// Create receipt record with the actual file path
|
||||
const receipt = await receiptService.createReceipt(
|
||||
userProfile.user.user_id,
|
||||
file.path,
|
||||
request.log,
|
||||
{
|
||||
storeLocationId: store_location_id,
|
||||
transactionDate: transaction_date,
|
||||
},
|
||||
);
|
||||
|
||||
// Queue the receipt for processing via BullMQ
|
||||
const bindings = request.log.bindings?.() || {};
|
||||
const job = await receiptQueue.add(
|
||||
'process-receipt',
|
||||
{
|
||||
receiptId: receipt.receipt_id,
|
||||
userId: userProfile.user.user_id,
|
||||
imagePath: file.path,
|
||||
meta: {
|
||||
requestId: bindings.request_id as string | undefined,
|
||||
userId: userProfile.user.user_id,
|
||||
origin: 'api',
|
||||
},
|
||||
},
|
||||
{
|
||||
jobId: `receipt-${receipt.receipt_id}`,
|
||||
},
|
||||
);
|
||||
|
||||
request.log.info(
|
||||
{ receiptId: receipt.receipt_id, jobId: job.id },
|
||||
'Receipt queued for processing',
|
||||
);
|
||||
|
||||
return this.created({ ...receipt, job_id: job.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get receipt by ID.
|
||||
*
|
||||
* Retrieve a specific receipt with its extracted items.
|
||||
*
|
||||
* @summary Get receipt by ID
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Receipt with its items
|
||||
*/
|
||||
@Get('{receiptId}')
|
||||
@SuccessResponse(200, 'Receipt retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getReceiptById(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReceiptWithItemsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
const receipt = await receiptService.getReceiptById(
|
||||
receiptId,
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
|
||||
// Also get the items
|
||||
const items = await receiptService.getReceiptItems(receiptId, request.log);
|
||||
|
||||
return this.success({ receipt, items });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete receipt.
|
||||
*
|
||||
* Delete a receipt and all associated data.
|
||||
*
|
||||
* @summary Delete receipt
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
*/
|
||||
@Delete('{receiptId}')
|
||||
@SuccessResponse(204, 'Receipt deleted')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async deleteReceipt(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
await receiptService.deleteReceipt(receiptId, userProfile.user.user_id, request.log);
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprocess receipt.
|
||||
*
|
||||
* Queue a failed receipt for reprocessing.
|
||||
*
|
||||
* @summary Reprocess receipt
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Confirmation with job ID
|
||||
*/
|
||||
@Post('{receiptId}/reprocess')
|
||||
@SuccessResponse(200, 'Receipt queued for reprocessing')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async reprocessReceipt(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReprocessResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify the receipt exists and belongs to user
|
||||
const receipt = await receiptService.getReceiptById(
|
||||
receiptId,
|
||||
userProfile.user.user_id,
|
||||
request.log,
|
||||
);
|
||||
|
||||
// Queue for reprocessing via BullMQ
|
||||
const bindings = request.log.bindings?.() || {};
|
||||
const job = await receiptQueue.add(
|
||||
'process-receipt',
|
||||
{
|
||||
receiptId: receipt.receipt_id,
|
||||
userId: userProfile.user.user_id,
|
||||
imagePath: receipt.receipt_image_url, // Use stored image path
|
||||
meta: {
|
||||
requestId: bindings.request_id as string | undefined,
|
||||
userId: userProfile.user.user_id,
|
||||
origin: 'api-reprocess',
|
||||
},
|
||||
},
|
||||
{
|
||||
jobId: `receipt-${receipt.receipt_id}-reprocess-${Date.now()}`,
|
||||
},
|
||||
);
|
||||
|
||||
request.log.info({ receiptId, jobId: job.id }, 'Receipt queued for reprocessing');
|
||||
|
||||
return this.success({
|
||||
message: 'Receipt queued for reprocessing',
|
||||
receipt_id: receipt.receipt_id,
|
||||
job_id: job.id,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// RECEIPT ITEMS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get receipt items.
|
||||
*
|
||||
* Get all extracted items from a receipt.
|
||||
*
|
||||
* @summary Get receipt items
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of receipt items
|
||||
*/
|
||||
@Get('{receiptId}/items')
|
||||
@SuccessResponse(200, 'Receipt items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getReceiptItems(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReceiptItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const items = await receiptService.getReceiptItems(receiptId, request.log);
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unadded items.
|
||||
*
|
||||
* Get receipt items that haven't been added to inventory yet.
|
||||
*
|
||||
* @summary Get unadded items
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns List of unadded receipt items
|
||||
*/
|
||||
@Get('{receiptId}/items/unadded')
|
||||
@SuccessResponse(200, 'Unadded items retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getUnaddedItems(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ReceiptItemsListResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const items = await receiptService.getUnaddedItems(receiptId, request.log);
|
||||
return this.success({ items, total: items.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update receipt item.
|
||||
*
|
||||
* Update a receipt item's matching status or linked product.
|
||||
*
|
||||
* @summary Update receipt item
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param itemId The unique identifier of the receipt item
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Fields to update
|
||||
* @returns The updated receipt item
|
||||
*/
|
||||
@Put('{receiptId}/items/{itemId}')
|
||||
@SuccessResponse(200, 'Item updated')
|
||||
@Response<ErrorResponse>(400, 'Validation error - at least one field required')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt or item not found')
|
||||
public async updateReceiptItem(
|
||||
@Path() receiptId: number,
|
||||
@Path() itemId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: UpdateReceiptItemRequest,
|
||||
): Promise<SuccessResponseType<ReceiptItem>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Validate at least one field is provided
|
||||
if (Object.keys(body).length === 0) {
|
||||
this.setStatus(400);
|
||||
throw new Error('At least one field to update must be provided.');
|
||||
}
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const item = await receiptService.updateReceiptItem(itemId, body, request.log);
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm items for inventory.
|
||||
*
|
||||
* Confirm selected receipt items to add to user's inventory.
|
||||
*
|
||||
* @summary Confirm items for inventory
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @param body Items to confirm
|
||||
* @returns Added items with count
|
||||
*/
|
||||
@Post('{receiptId}/confirm')
|
||||
@SuccessResponse(200, 'Items added to inventory')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async confirmItems(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: ConfirmItemsRequest,
|
||||
): Promise<SuccessResponseType<ConfirmItemsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
userId: userProfile.user.user_id,
|
||||
receiptId,
|
||||
itemCount: body.items.length,
|
||||
},
|
||||
'Confirming receipt items for inventory',
|
||||
);
|
||||
|
||||
const addedItems = await expiryService.addItemsFromReceipt(
|
||||
userProfile.user.user_id,
|
||||
receiptId,
|
||||
body.items,
|
||||
request.log,
|
||||
);
|
||||
|
||||
return this.success({ added_items: addedItems, count: addedItems.length });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PROCESSING LOGS ENDPOINT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get processing logs.
|
||||
*
|
||||
* Get the processing log history for a receipt.
|
||||
*
|
||||
* @summary Get processing logs
|
||||
* @param receiptId The unique identifier of the receipt
|
||||
* @param request Express request with authenticated user
|
||||
* @returns Processing log history
|
||||
*/
|
||||
@Get('{receiptId}/logs')
|
||||
@SuccessResponse(200, 'Processing logs retrieved')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized - invalid or missing token')
|
||||
@Response<ErrorResponse>(404, 'Receipt not found')
|
||||
public async getProcessingLogs(
|
||||
@Path() receiptId: number,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ProcessingLogsResponse>> {
|
||||
const userProfile = request.user as UserProfile;
|
||||
|
||||
// Verify receipt belongs to user
|
||||
await receiptService.getReceiptById(receiptId, userProfile.user.user_id, request.log);
|
||||
|
||||
const logs = await receiptService.getProcessingLogs(receiptId, request.log);
|
||||
return this.success({ logs, total: logs.length });
|
||||
}
|
||||
}
|
||||
691
src/controllers/recipe.controller.test.ts
Normal file
691
src/controllers/recipe.controller.test.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
// src/controllers/recipe.controller.test.ts
|
||||
// ============================================================================
|
||||
// RECIPE CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the RecipeController class. These tests verify controller
|
||||
// logic in isolation by mocking database repositories and AI service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock database repositories
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
recipeRepo: {
|
||||
getRecipesBySalePercentage: vi.fn(),
|
||||
getRecipesByMinSaleIngredients: vi.fn(),
|
||||
findRecipesByIngredientAndTag: vi.fn(),
|
||||
getRecipeById: vi.fn(),
|
||||
getRecipeComments: vi.fn(),
|
||||
addRecipeComment: vi.fn(),
|
||||
forkRecipe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock AI service
|
||||
vi.mock('../services/aiService.server', () => ({
|
||||
aiService: {
|
||||
generateRecipeSuggestion: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { aiService } from '../services/aiService.server';
|
||||
import { RecipeController } from './recipe.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
const mockedAiService = aiService as Mocked<typeof aiService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock recipe object.
|
||||
*/
|
||||
function createMockRecipe(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
recipe_id: 1,
|
||||
user_id: 'user-123',
|
||||
name: 'Test Recipe',
|
||||
description: 'A delicious test recipe',
|
||||
instructions: 'Mix ingredients and cook',
|
||||
prep_time_minutes: 15,
|
||||
cook_time_minutes: 30,
|
||||
servings: 4,
|
||||
photo_url: '/uploads/recipes/test.jpg',
|
||||
status: 'public' as const,
|
||||
forked_from: null,
|
||||
ingredients: [
|
||||
{
|
||||
recipe_ingredient_id: 1,
|
||||
master_item_name: 'Flour',
|
||||
quantity: '2',
|
||||
unit: 'cups',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
tag_id: 1,
|
||||
name: 'vegetarian',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock recipe comment object.
|
||||
*/
|
||||
function createMockComment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
recipe_comment_id: 1,
|
||||
recipe_id: 1,
|
||||
user_id: 'user-123',
|
||||
content: 'Great recipe!',
|
||||
parent_comment_id: null,
|
||||
user_full_name: 'Test User',
|
||||
user_avatar_url: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('RecipeController', () => {
|
||||
let controller: RecipeController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new RecipeController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DISCOVERY ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getRecipesBySalePercentage()', () => {
|
||||
it('should return recipes with default 50% minimum', async () => {
|
||||
// Arrange
|
||||
const mockRecipes = [createMockRecipe(), createMockRecipe({ recipe_id: 2 })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipesBySalePercentage(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
50,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect custom percentage parameter', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, 75);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
75,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap percentage at 100', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, 150);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor percentage at 0', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesBySalePercentage.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySalePercentage(request, -10);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecipesBySaleIngredients()', () => {
|
||||
it('should return recipes with default 3 minimum ingredients', async () => {
|
||||
// Arrange
|
||||
const mockRecipes = [createMockRecipe()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipesBySaleIngredients(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(
|
||||
3,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor minimum ingredients at 1', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySaleIngredients(request, 0);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor decimal values', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipesByMinSaleIngredients.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getRecipesBySaleIngredients(request, 5.9);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRecipesByIngredientAndTag()', () => {
|
||||
it('should find recipes by ingredient and tag', async () => {
|
||||
// Arrange
|
||||
const mockRecipes = [createMockRecipe()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.findRecipesByIngredientAndTag.mockResolvedValue(mockRecipes);
|
||||
|
||||
// Act
|
||||
const result = await controller.findRecipesByIngredientAndTag(request, 'chicken', 'dinner');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.findRecipesByIngredientAndTag).toHaveBeenCalledWith(
|
||||
'chicken',
|
||||
'dinner',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log search parameters', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.findRecipesByIngredientAndTag.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.findRecipesByIngredientAndTag(request, 'beef', 'quick');
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith(
|
||||
{ ingredient: 'beef', tag: 'quick' },
|
||||
'Finding recipes by ingredient and tag',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getRecipeById()', () => {
|
||||
it('should return recipe by ID', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.recipe_id).toBe(1);
|
||||
expect(result.data.name).toBe('Test Recipe');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipeById).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should include ingredients and tags', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.ingredients).toHaveLength(1);
|
||||
expect(result.data.tags).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecipeComments()', () => {
|
||||
it('should return recipe comments', async () => {
|
||||
// Arrange
|
||||
const mockComments = [
|
||||
createMockComment(),
|
||||
createMockComment({ recipe_comment_id: 2, content: 'Another comment' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeComments.mockResolvedValue(mockComments);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeComments(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].content).toBe('Great recipe!');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should return empty array when no comments', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeComments.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeComments(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('suggestRecipe()', () => {
|
||||
it('should return AI-generated recipe suggestion', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue(
|
||||
'Here is a delicious recipe using chicken and rice...',
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await controller.suggestRecipe(
|
||||
{ ingredients: ['chicken', 'rice', 'vegetables'] },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.suggestion).toContain('chicken');
|
||||
}
|
||||
expect(mockedAiService.generateRecipeSuggestion).toHaveBeenCalledWith(
|
||||
['chicken', 'rice', 'vegetables'],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when AI service fails', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.suggestRecipe({ ingredients: ['chicken'] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('SERVICE_UNAVAILABLE');
|
||||
}
|
||||
});
|
||||
|
||||
it('should log suggestion generation', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue('Recipe suggestion');
|
||||
|
||||
// Act
|
||||
await controller.suggestRecipe({ ingredients: ['chicken', 'rice'] }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ userId: 'test-user-id', ingredientCount: 2 },
|
||||
'Generating recipe suggestion',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addComment()', () => {
|
||||
it('should add comment to recipe', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(1, { content: 'Great recipe!' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.content).toBe('Great recipe!');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.addRecipeComment).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
'Great recipe!',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support nested replies', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment({ parent_comment_id: 5 });
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(
|
||||
1,
|
||||
{ content: 'Reply to comment', parentCommentId: 5 },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.parent_comment_id).toBe(5);
|
||||
}
|
||||
expect(mockedDb.recipeRepo.addRecipeComment).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
'Reply to comment',
|
||||
expect.anything(),
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it('should log comment addition', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
await controller.addComment(1, { content: 'Test' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ recipeId: 1, userId: 'test-user-id', hasParent: false },
|
||||
'Adding comment to recipe',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forkRecipe()', () => {
|
||||
it('should fork a recipe', async () => {
|
||||
// Arrange
|
||||
const mockForkedRecipe = createMockRecipe({
|
||||
recipe_id: 10,
|
||||
user_id: 'test-user-id',
|
||||
forked_from: 1,
|
||||
status: 'private',
|
||||
});
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.forkRecipe.mockResolvedValue(mockForkedRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.forkRecipe(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.recipe_id).toBe(10);
|
||||
expect(result.data.forked_from).toBe(1);
|
||||
expect(result.data.user_id).toBe('test-user-id');
|
||||
}
|
||||
expect(mockedDb.recipeRepo.forkRecipe).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log fork operation', async () => {
|
||||
// Arrange
|
||||
const mockForkedRecipe = createMockRecipe({ recipe_id: 10, forked_from: 1 });
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedDb.recipeRepo.forkRecipe.mockResolvedValue(mockForkedRecipe);
|
||||
|
||||
// Act
|
||||
await controller.forkRecipe(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ recipeId: 1, userId: 'test-user-id' },
|
||||
'Forking recipe',
|
||||
);
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ originalRecipeId: 1, forkedRecipeId: 10 },
|
||||
'Recipe forked successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = createMockRecipe();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.getRecipeById.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.getRecipeById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const mockComment = createMockComment();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.addRecipeComment.mockResolvedValue(mockComment);
|
||||
|
||||
// Act
|
||||
const result = await controller.addComment(1, { content: 'Test' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use error helper for error responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAiService.generateRecipeSuggestion.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.suggestRecipe({ ingredients: ['test'] }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toHaveProperty('code');
|
||||
expect(result.error).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
441
src/controllers/recipe.controller.ts
Normal file
441
src/controllers/recipe.controller.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
// src/controllers/recipe.controller.ts
|
||||
// ============================================================================
|
||||
// RECIPE CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing recipes and recipe interactions.
|
||||
// Implements endpoints for:
|
||||
// - Getting recipes by sale percentage
|
||||
// - Getting recipes by minimum sale ingredients
|
||||
// - Finding recipes by ingredient and tag
|
||||
// - Getting a single recipe by ID
|
||||
// - Getting recipe comments
|
||||
// - AI-powered recipe suggestions (authenticated)
|
||||
// - Adding comments to recipes (authenticated)
|
||||
// - Forking recipes (authenticated)
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Query,
|
||||
Body,
|
||||
Request,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController, ControllerErrorCode } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { aiService } from '../services/aiService.server';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
// Data Transfer Objects that are tsoa-compatible for API documentation.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Recipe ingredient data.
|
||||
*/
|
||||
interface RecipeIngredientDto {
|
||||
/** Recipe ingredient ID */
|
||||
recipe_ingredient_id: number;
|
||||
/** Master grocery item name */
|
||||
master_item_name: string | null;
|
||||
/** Quantity required */
|
||||
quantity: string | null;
|
||||
/** Unit of measurement */
|
||||
unit: string | null;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipe tag data.
|
||||
*/
|
||||
interface RecipeTagDto {
|
||||
/** Tag ID */
|
||||
tag_id: number;
|
||||
/** Tag name */
|
||||
name: string;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipe data transfer object for API responses.
|
||||
*/
|
||||
interface RecipeDto {
|
||||
/** Unique recipe identifier */
|
||||
readonly recipe_id: number;
|
||||
/** User ID who created the recipe */
|
||||
readonly user_id: string;
|
||||
/** Recipe name */
|
||||
name: string;
|
||||
/** Recipe description */
|
||||
description: string | null;
|
||||
/** Cooking instructions */
|
||||
instructions: string | null;
|
||||
/** Preparation time in minutes */
|
||||
prep_time_minutes: number | null;
|
||||
/** Cooking time in minutes */
|
||||
cook_time_minutes: number | null;
|
||||
/** Number of servings */
|
||||
servings: number | null;
|
||||
/** URL to recipe photo */
|
||||
photo_url: string | null;
|
||||
/** Recipe status (public, private, archived) */
|
||||
status: 'public' | 'private' | 'archived';
|
||||
/** ID of the original recipe if this is a fork */
|
||||
forked_from: number | null;
|
||||
/** Recipe ingredients */
|
||||
ingredients?: RecipeIngredientDto[];
|
||||
/** Recipe tags */
|
||||
tags?: RecipeTagDto[];
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipe comment data transfer object.
|
||||
*/
|
||||
interface RecipeCommentDto {
|
||||
/** Unique comment identifier */
|
||||
readonly recipe_comment_id: number;
|
||||
/** Recipe ID this comment belongs to */
|
||||
readonly recipe_id: number;
|
||||
/** User ID who posted the comment */
|
||||
readonly user_id: string;
|
||||
/** Comment content */
|
||||
content: string;
|
||||
/** Parent comment ID for threaded replies */
|
||||
parent_comment_id: number | null;
|
||||
/** User's full name */
|
||||
user_full_name: string | null;
|
||||
/** User's avatar URL */
|
||||
user_avatar_url: string | null;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for AI recipe suggestion.
|
||||
*/
|
||||
interface SuggestRecipeRequest {
|
||||
/**
|
||||
* List of ingredients to use for the suggestion.
|
||||
* @minItems 1
|
||||
* @example ["chicken", "rice", "broccoli"]
|
||||
*/
|
||||
ingredients: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for AI recipe suggestion.
|
||||
*/
|
||||
interface SuggestRecipeResponseData {
|
||||
/** The AI-generated recipe suggestion */
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for adding a comment to a recipe.
|
||||
*/
|
||||
interface AddCommentRequest {
|
||||
/**
|
||||
* The comment content.
|
||||
* @minLength 1
|
||||
* @example "This recipe is delicious! I added some garlic for extra flavor."
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Parent comment ID for threaded replies (optional).
|
||||
*/
|
||||
parentCommentId?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECIPE CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for recipe endpoints.
|
||||
*
|
||||
* Provides read access for browsing recipes and write access for
|
||||
* authenticated users to interact with recipes (comments, forks, AI suggestions).
|
||||
*/
|
||||
@Route('recipes')
|
||||
@Tags('Recipes')
|
||||
export class RecipeController extends BaseController {
|
||||
// ==========================================================================
|
||||
// DISCOVERY ENDPOINTS (PUBLIC)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get recipes by sale percentage.
|
||||
*
|
||||
* Returns recipes where at least the specified percentage of ingredients
|
||||
* are currently on sale. Useful for finding budget-friendly meals.
|
||||
*
|
||||
* @summary Get recipes by sale percentage
|
||||
* @param minPercentage Minimum percentage of ingredients on sale (0-100, default: 50)
|
||||
* @returns Array of recipes matching the criteria
|
||||
*/
|
||||
@Get('by-sale-percentage')
|
||||
@SuccessResponse(200, 'Recipes retrieved successfully')
|
||||
public async getRecipesBySalePercentage(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() minPercentage?: number,
|
||||
): Promise<SuccessResponseType<RecipeDto[]>> {
|
||||
// Apply defaults and bounds
|
||||
const normalizedPercentage = Math.min(100, Math.max(0, minPercentage ?? 50));
|
||||
|
||||
req.log.debug({ minPercentage: normalizedPercentage }, 'Fetching recipes by sale percentage');
|
||||
|
||||
const recipes = await db.recipeRepo.getRecipesBySalePercentage(normalizedPercentage, req.log);
|
||||
|
||||
return this.success(recipes as unknown as RecipeDto[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipes by sale ingredients count.
|
||||
*
|
||||
* Returns recipes with at least the specified number of ingredients
|
||||
* currently on sale. Helps find recipes that maximize current deals.
|
||||
*
|
||||
* @summary Get recipes by minimum sale ingredients
|
||||
* @param minIngredients Minimum number of sale ingredients required (default: 3)
|
||||
* @returns Array of recipes matching the criteria
|
||||
*/
|
||||
@Get('by-sale-ingredients')
|
||||
@SuccessResponse(200, 'Recipes retrieved successfully')
|
||||
public async getRecipesBySaleIngredients(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() minIngredients?: number,
|
||||
): Promise<SuccessResponseType<RecipeDto[]>> {
|
||||
// Apply defaults and bounds
|
||||
const normalizedCount = Math.max(1, Math.floor(minIngredients ?? 3));
|
||||
|
||||
req.log.debug({ minIngredients: normalizedCount }, 'Fetching recipes by sale ingredients');
|
||||
|
||||
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(normalizedCount, req.log);
|
||||
|
||||
return this.success(recipes as unknown as RecipeDto[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find recipes by ingredient and tag.
|
||||
*
|
||||
* Returns recipes that contain a specific ingredient and have a specific tag.
|
||||
* Both parameters are required for filtering.
|
||||
*
|
||||
* @summary Find recipes by ingredient and tag
|
||||
* @param ingredient Ingredient name to search for
|
||||
* @param tag Tag to filter by
|
||||
* @returns Array of matching recipes
|
||||
*/
|
||||
@Get('by-ingredient-and-tag')
|
||||
@SuccessResponse(200, 'Recipes retrieved successfully')
|
||||
@Response<ErrorResponse>(400, 'Missing required query parameters')
|
||||
public async findRecipesByIngredientAndTag(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() ingredient: string,
|
||||
@Query() tag: string,
|
||||
): Promise<SuccessResponseType<RecipeDto[]>> {
|
||||
req.log.debug({ ingredient, tag }, 'Finding recipes by ingredient and tag');
|
||||
|
||||
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(ingredient, tag, req.log);
|
||||
|
||||
return this.success(recipes as unknown as RecipeDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get recipe by ID.
|
||||
*
|
||||
* Returns a single recipe with its ingredients and tags.
|
||||
*
|
||||
* @summary Get a single recipe
|
||||
* @param recipeId The unique identifier of the recipe
|
||||
* @returns The recipe object with ingredients and tags
|
||||
*/
|
||||
@Get('{recipeId}')
|
||||
@SuccessResponse(200, 'Recipe retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Recipe not found')
|
||||
public async getRecipeById(
|
||||
@Path() recipeId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeDto>> {
|
||||
req.log.debug({ recipeId }, 'Fetching recipe by ID');
|
||||
|
||||
const recipe = await db.recipeRepo.getRecipeById(recipeId, req.log);
|
||||
|
||||
return this.success(recipe as unknown as RecipeDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe comments.
|
||||
*
|
||||
* Returns all comments for a specific recipe, ordered by creation date.
|
||||
* Includes user information (name, avatar) for each comment.
|
||||
*
|
||||
* @summary Get comments for a recipe
|
||||
* @param recipeId The unique identifier of the recipe
|
||||
* @returns Array of comments for the recipe
|
||||
*/
|
||||
@Get('{recipeId}/comments')
|
||||
@SuccessResponse(200, 'Comments retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Recipe not found')
|
||||
public async getRecipeComments(
|
||||
@Path() recipeId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeCommentDto[]>> {
|
||||
req.log.debug({ recipeId }, 'Fetching recipe comments');
|
||||
|
||||
const comments = await db.recipeRepo.getRecipeComments(recipeId, req.log);
|
||||
|
||||
return this.success(comments as unknown as RecipeCommentDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// AUTHENTICATED ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get AI recipe suggestion.
|
||||
*
|
||||
* Uses AI to generate a recipe suggestion based on provided ingredients.
|
||||
* Requires authentication due to API usage costs.
|
||||
*
|
||||
* @summary Get AI-powered recipe suggestion
|
||||
* @param body List of ingredients to use
|
||||
* @returns AI-generated recipe suggestion
|
||||
*/
|
||||
@Post('suggest')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(200, 'Suggestion generated successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(503, 'AI service unavailable')
|
||||
public async suggestRecipe(
|
||||
@Body() body: SuggestRecipeRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<SuggestRecipeResponseData>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
req.log.info(
|
||||
{ userId: userProfile.user.user_id, ingredientCount: body.ingredients.length },
|
||||
'Generating recipe suggestion',
|
||||
);
|
||||
|
||||
const suggestion = await aiService.generateRecipeSuggestion(body.ingredients, req.log);
|
||||
|
||||
if (!suggestion) {
|
||||
this.setStatus(503);
|
||||
return this.error(
|
||||
ControllerErrorCode.SERVICE_UNAVAILABLE,
|
||||
'AI service is currently unavailable or failed to generate a suggestion.',
|
||||
) as unknown as SuccessResponseType<SuggestRecipeResponseData>;
|
||||
}
|
||||
|
||||
req.log.info({ userId: userProfile.user.user_id }, 'Recipe suggestion generated successfully');
|
||||
|
||||
return this.success({ suggestion });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment to recipe.
|
||||
*
|
||||
* Adds a comment to a recipe. Supports nested replies via parentCommentId.
|
||||
*
|
||||
* @summary Add a comment to a recipe
|
||||
* @param recipeId The unique identifier of the recipe
|
||||
* @param body Comment content and optional parent comment ID
|
||||
* @returns The created comment
|
||||
*/
|
||||
@Post('{recipeId}/comments')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Comment added successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(404, 'Recipe or parent comment not found')
|
||||
public async addComment(
|
||||
@Path() recipeId: number,
|
||||
@Body() body: AddCommentRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeCommentDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.info(
|
||||
{ recipeId, userId, hasParent: !!body.parentCommentId },
|
||||
'Adding comment to recipe',
|
||||
);
|
||||
|
||||
const comment = await db.recipeRepo.addRecipeComment(
|
||||
recipeId,
|
||||
userId,
|
||||
body.content,
|
||||
req.log,
|
||||
body.parentCommentId,
|
||||
);
|
||||
|
||||
req.log.info({ recipeId, commentId: comment.recipe_comment_id }, 'Comment added successfully');
|
||||
|
||||
return this.created(comment as unknown as RecipeCommentDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a recipe.
|
||||
*
|
||||
* Creates a personal, editable copy of a public recipe.
|
||||
* The forked recipe can be modified without affecting the original.
|
||||
*
|
||||
* @summary Fork a recipe
|
||||
* @param recipeId The unique identifier of the recipe to fork
|
||||
* @returns The newly created forked recipe
|
||||
*/
|
||||
@Post('{recipeId}/fork')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Recipe forked successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(404, 'Recipe not found or not public')
|
||||
public async forkRecipe(
|
||||
@Path() recipeId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<RecipeDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.info({ recipeId, userId }, 'Forking recipe');
|
||||
|
||||
const forkedRecipe = await db.recipeRepo.forkRecipe(userId, recipeId, req.log);
|
||||
|
||||
req.log.info(
|
||||
{ originalRecipeId: recipeId, forkedRecipeId: forkedRecipe.recipe_id },
|
||||
'Recipe forked successfully',
|
||||
);
|
||||
|
||||
return this.created(forkedRecipe as unknown as RecipeDto);
|
||||
}
|
||||
}
|
||||
336
src/controllers/stats.controller.test.ts
Normal file
336
src/controllers/stats.controller.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
// src/controllers/stats.controller.test.ts
|
||||
// ============================================================================
|
||||
// STATS CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the StatsController class. These tests verify controller
|
||||
// logic in isolation by mocking the admin repository.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Query: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock admin repository
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
adminRepo: {
|
||||
getMostFrequentSaleItems: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { adminRepo } from '../services/db/index.db';
|
||||
import { StatsController } from './stats.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedAdminRepo = adminRepo as Mocked<typeof adminRepo>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock most frequent sale item.
|
||||
*/
|
||||
function createMockSaleItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
master_item_id: 1,
|
||||
item_name: 'Milk 2%',
|
||||
category_name: 'Dairy & Eggs',
|
||||
sale_count: 15,
|
||||
avg_discount_percent: 25.5,
|
||||
lowest_price_cents: 299,
|
||||
highest_price_cents: 450,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('StatsController', () => {
|
||||
let controller: StatsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new StatsController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// MOST FREQUENT SALES
|
||||
// ==========================================================================
|
||||
|
||||
describe('getMostFrequentSales()', () => {
|
||||
it('should return most frequent sale items with default parameters', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
createMockSaleItem(),
|
||||
createMockSaleItem({ master_item_id: 2, item_name: 'Bread', sale_count: 12 }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].item_name).toBe('Milk 2%');
|
||||
expect(result.data[0].sale_count).toBe(15);
|
||||
}
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30, // default days
|
||||
10, // default limit
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom days parameter', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(60, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
60,
|
||||
10,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom limit parameter', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(undefined, 25, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30,
|
||||
25,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap days at 365', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(500, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
365,
|
||||
10,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor days at 1', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(0, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
1,
|
||||
10,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 50', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(undefined, 100, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30,
|
||||
50,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should floor limit at 1', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(undefined, 0, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
30,
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle negative values', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(-10, -5, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
1, // floored to 1
|
||||
1, // floored to 1
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no sale items exist', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle decimal values by flooring them', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
await controller.getMostFrequentSales(45.7, 15.3, request);
|
||||
|
||||
// Assert
|
||||
expect(mockedAdminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(
|
||||
45, // floored
|
||||
15, // floored
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ACCESS (NO AUTH REQUIRED)
|
||||
// ==========================================================================
|
||||
|
||||
describe('Public access', () => {
|
||||
it('should work without user authentication', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockItems = [createMockSaleItem()];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedAdminRepo.getMostFrequentSaleItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getMostFrequentSales(undefined, undefined, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/controllers/stats.controller.ts
Normal file
67
src/controllers/stats.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// src/controllers/stats.controller.ts
|
||||
// ============================================================================
|
||||
// STATS CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides public endpoints for statistical analysis of sale data.
|
||||
// These endpoints are useful for data analysis and do not require authentication.
|
||||
//
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Route, Tags, Query, Request, SuccessResponse } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType } from './types';
|
||||
import { adminRepo } from '../services/db/index.db';
|
||||
import type { MostFrequentSaleItem } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// STATS CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for statistical analysis endpoints.
|
||||
*
|
||||
* All endpoints are public and do not require authentication.
|
||||
* These endpoints provide aggregated statistics useful for data analysis.
|
||||
*/
|
||||
@Route('stats')
|
||||
@Tags('Stats')
|
||||
export class StatsController extends BaseController {
|
||||
// ==========================================================================
|
||||
// MOST FREQUENT SALES
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get most frequent sale items.
|
||||
*
|
||||
* Returns a list of items that have been on sale most frequently
|
||||
* within the specified time period. This is useful for identifying
|
||||
* items that are regularly discounted.
|
||||
*
|
||||
* @summary Get most frequent sale items
|
||||
* @param days Number of days to look back (1-365, default: 30)
|
||||
* @param limit Maximum number of items to return (1-50, default: 10)
|
||||
* @param request Express request for logging
|
||||
* @returns List of most frequently on-sale items
|
||||
*/
|
||||
@Get('most-frequent-sales')
|
||||
@SuccessResponse(200, 'List of most frequently on-sale items')
|
||||
public async getMostFrequentSales(
|
||||
@Query() days?: number,
|
||||
@Query() limit?: number,
|
||||
@Request() request?: ExpressRequest,
|
||||
): Promise<SuccessResponseType<MostFrequentSaleItem[]>> {
|
||||
// Apply defaults and bounds per the original route implementation
|
||||
// Default: 30 days, 10 items. Max: 365 days, 50 items.
|
||||
const normalizedDays = Math.min(365, Math.max(1, Math.floor(days ?? 30)));
|
||||
const normalizedLimit = Math.min(50, Math.max(1, Math.floor(limit ?? 10)));
|
||||
|
||||
const items = await adminRepo.getMostFrequentSaleItems(
|
||||
normalizedDays,
|
||||
normalizedLimit,
|
||||
request!.log,
|
||||
);
|
||||
return this.success(items);
|
||||
}
|
||||
}
|
||||
653
src/controllers/store.controller.test.ts
Normal file
653
src/controllers/store.controller.test.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
// src/controllers/store.controller.test.ts
|
||||
// ============================================================================
|
||||
// STORE CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the StoreController class. These tests verify controller
|
||||
// logic in isolation by mocking database repositories and cache service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock repository methods - these will be accessible via the class instances
|
||||
const mockStoreRepoMethods = {
|
||||
getAllStores: vi.fn(),
|
||||
createStore: vi.fn(),
|
||||
updateStore: vi.fn(),
|
||||
deleteStore: vi.fn(),
|
||||
};
|
||||
|
||||
const mockStoreLocationRepoMethods = {
|
||||
getAllStoresWithLocations: vi.fn(),
|
||||
getStoreWithLocations: vi.fn(),
|
||||
createStoreLocation: vi.fn(),
|
||||
deleteStoreLocation: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAddressRepoMethods = {
|
||||
upsertAddress: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock StoreRepository as a class constructor
|
||||
vi.mock('../services/db/store.db', () => ({
|
||||
StoreRepository: class MockStoreRepository {
|
||||
getAllStores = mockStoreRepoMethods.getAllStores;
|
||||
createStore = mockStoreRepoMethods.createStore;
|
||||
updateStore = mockStoreRepoMethods.updateStore;
|
||||
deleteStore = mockStoreRepoMethods.deleteStore;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock StoreLocationRepository as a class constructor
|
||||
vi.mock('../services/db/storeLocation.db', () => ({
|
||||
StoreLocationRepository: class MockStoreLocationRepository {
|
||||
getAllStoresWithLocations = mockStoreLocationRepoMethods.getAllStoresWithLocations;
|
||||
getStoreWithLocations = mockStoreLocationRepoMethods.getStoreWithLocations;
|
||||
createStoreLocation = mockStoreLocationRepoMethods.createStoreLocation;
|
||||
deleteStoreLocation = mockStoreLocationRepoMethods.deleteStoreLocation;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock AddressRepository as a class constructor
|
||||
vi.mock('../services/db/address.db', () => ({
|
||||
AddressRepository: class MockAddressRepository {
|
||||
upsertAddress = mockAddressRepoMethods.upsertAddress;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock database connection
|
||||
vi.mock('../services/db/connection.db', () => ({
|
||||
getPool: vi.fn(),
|
||||
withTransaction: vi.fn(async (callback) => {
|
||||
const mockClient = { query: vi.fn() };
|
||||
return callback(mockClient);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock cache service
|
||||
vi.mock('../services/cacheService.server', () => ({
|
||||
cacheService: {
|
||||
invalidateStores: vi.fn(),
|
||||
invalidateStore: vi.fn(),
|
||||
invalidateStoreLocations: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { cacheService } from '../services/cacheService.server';
|
||||
import { StoreController } from './store.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedCacheService = cacheService as Mocked<typeof cacheService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock store object.
|
||||
*/
|
||||
function createMockStore(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
store_id: 1,
|
||||
name: 'Test Store',
|
||||
logo_url: '/uploads/logos/store.jpg',
|
||||
created_by: 'admin-user-id',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock store with locations.
|
||||
*/
|
||||
function createMockStoreWithLocations(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...createMockStore(overrides),
|
||||
locations: [
|
||||
{
|
||||
store_location_id: 1,
|
||||
store_id: 1,
|
||||
address_id: 1,
|
||||
address: {
|
||||
address_id: 1,
|
||||
address_line_1: '123 Main St',
|
||||
city: 'Toronto',
|
||||
province_state: 'ON',
|
||||
postal_code: 'M5V 1A1',
|
||||
country: 'Canada',
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('StoreController', () => {
|
||||
let controller: StoreController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new StoreController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LIST ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getStores()', () => {
|
||||
it('should return stores without locations by default', async () => {
|
||||
// Arrange
|
||||
const mockStores = [createMockStore(), createMockStore({ store_id: 2, name: 'Store 2' })];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.getAllStores.mockResolvedValue(mockStores);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStores(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
expect(mockStoreRepoMethods.getAllStores).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should return stores with locations when requested', async () => {
|
||||
// Arrange
|
||||
const mockStoresWithLocations = [
|
||||
createMockStoreWithLocations(),
|
||||
createMockStoreWithLocations({ store_id: 2, name: 'Store 2' }),
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreLocationRepoMethods.getAllStoresWithLocations.mockResolvedValue(
|
||||
mockStoresWithLocations,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStores(request, true);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toHaveProperty('locations');
|
||||
}
|
||||
expect(mockStoreLocationRepoMethods.getAllStoresWithLocations).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getStoreById()', () => {
|
||||
it('should return store with locations', async () => {
|
||||
// Arrange
|
||||
const mockStore = createMockStoreWithLocations();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreLocationRepoMethods.getStoreWithLocations.mockResolvedValue(mockStore);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStoreById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.store_id).toBe(1);
|
||||
expect(result.data.locations).toHaveLength(1);
|
||||
}
|
||||
expect(mockStoreLocationRepoMethods.getStoreWithLocations).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log successful retrieval', async () => {
|
||||
// Arrange
|
||||
const mockStore = createMockStoreWithLocations();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreLocationRepoMethods.getStoreWithLocations.mockResolvedValue(mockStore);
|
||||
|
||||
// Act
|
||||
await controller.getStoreById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.debug).toHaveBeenCalledWith({ storeId: 1 }, 'Retrieved store by ID');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN CREATE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('createStore()', () => {
|
||||
it('should create store without address', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
const result = await controller.createStore({ name: 'New Store' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.store_id).toBe(1);
|
||||
expect(result.data.address_id).toBeUndefined();
|
||||
expect(result.data.store_location_id).toBeUndefined();
|
||||
}
|
||||
expect(mockedCacheService.invalidateStores).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create store with address and location', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||
mockAddressRepoMethods.upsertAddress.mockResolvedValue(10);
|
||||
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(20);
|
||||
|
||||
// Act
|
||||
const result = await controller.createStore(
|
||||
{
|
||||
name: 'New Store',
|
||||
logo_url: 'http://example.com/logo.png',
|
||||
address: {
|
||||
address_line_1: '456 Oak Ave',
|
||||
city: 'Vancouver',
|
||||
province_state: 'BC',
|
||||
postal_code: 'V6B 1A1',
|
||||
},
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.store_id).toBe(1);
|
||||
expect(result.data.address_id).toBe(10);
|
||||
expect(result.data.store_location_id).toBe(20);
|
||||
}
|
||||
expect(mockAddressRepoMethods.upsertAddress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
address_line_1: '456 Oak Ave',
|
||||
city: 'Vancouver',
|
||||
country: 'Canada', // default
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log store creation', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
await controller.createStore({ name: 'New Store' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ storeName: 'New Store', hasAddress: false },
|
||||
'Creating new store',
|
||||
);
|
||||
expect(mockLog.info).toHaveBeenCalledWith({ storeId: 1 }, 'Store created successfully');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN UPDATE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('updateStore()', () => {
|
||||
it('should update store name', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.updateStore.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateStore(1, { name: 'Updated Store Name' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockStoreRepoMethods.updateStore).toHaveBeenCalledWith(
|
||||
1,
|
||||
{ name: 'Updated Store Name' },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockedCacheService.invalidateStore).toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
it('should update store logo', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.updateStore.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.updateStore(1, { logo_url: 'http://example.com/new-logo.png' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockStoreRepoMethods.updateStore).toHaveBeenCalledWith(
|
||||
1,
|
||||
{ logo_url: 'http://example.com/new-logo.png' },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log update operation', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreRepoMethods.updateStore.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.updateStore(1, { name: 'New Name' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ storeId: 1, updates: { name: 'New Name' } },
|
||||
'Updating store',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN DELETE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('deleteStore()', () => {
|
||||
it('should delete store and invalidate cache', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.deleteStore.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteStore(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockStoreRepoMethods.deleteStore).toHaveBeenCalledWith(1, expect.anything());
|
||||
expect(mockedCacheService.invalidateStores).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it('should log deletion', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreRepoMethods.deleteStore.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteStore(1, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith({ storeId: 1 }, 'Deleting store');
|
||||
expect(mockLog.info).toHaveBeenCalledWith({ storeId: 1 }, 'Store deleted successfully');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LOCATION MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
describe('addLocation()', () => {
|
||||
it('should add location to store', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockAddressRepoMethods.upsertAddress.mockResolvedValue(5);
|
||||
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(10);
|
||||
|
||||
// Act
|
||||
const result = await controller.addLocation(
|
||||
1,
|
||||
{
|
||||
address_line_1: '789 Elm St',
|
||||
city: 'Montreal',
|
||||
province_state: 'QC',
|
||||
postal_code: 'H3B 1A1',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.store_location_id).toBe(10);
|
||||
expect(result.data.address_id).toBe(5);
|
||||
}
|
||||
expect(mockedCacheService.invalidateStoreLocations).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default country if not specified', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockAddressRepoMethods.upsertAddress.mockResolvedValue(5);
|
||||
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(10);
|
||||
|
||||
// Act
|
||||
await controller.addLocation(
|
||||
1,
|
||||
{
|
||||
address_line_1: '789 Elm St',
|
||||
city: 'Montreal',
|
||||
province_state: 'QC',
|
||||
postal_code: 'H3B 1A1',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockAddressRepoMethods.upsertAddress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ country: 'Canada' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLocation()', () => {
|
||||
it('should delete location and invalidate cache', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreLocationRepoMethods.deleteStoreLocation.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteLocation(1, 5, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockStoreLocationRepoMethods.deleteStoreLocation).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockedCacheService.invalidateStoreLocations).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log location removal', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockStoreLocationRepoMethods.deleteStoreLocation.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.deleteLocation(1, 5, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
{ storeId: 1, locationId: 5 },
|
||||
'Removing location from store',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStore = createMockStoreWithLocations();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreLocationRepoMethods.getStoreWithLocations.mockResolvedValue(mockStore);
|
||||
|
||||
// Act
|
||||
const result = await controller.getStoreById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use created helper for 201 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
const result = await controller.createStore({ name: 'Test' }, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockStoreRepoMethods.deleteStore.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteStore(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
500
src/controllers/store.controller.ts
Normal file
500
src/controllers/store.controller.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
// src/controllers/store.controller.ts
|
||||
// ============================================================================
|
||||
// STORE CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for managing stores and store locations.
|
||||
// Implements endpoints for:
|
||||
// - Listing all stores (optionally with locations)
|
||||
// - Getting a single store by ID
|
||||
// - Creating a new store (admin only)
|
||||
// - Updating a store (admin only)
|
||||
// - Deleting a store (admin only)
|
||||
// - Adding a location to a store (admin only)
|
||||
// - Removing a location from a store (admin only)
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Query,
|
||||
Body,
|
||||
Request,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { StoreRepository } from '../services/db/store.db';
|
||||
import { StoreLocationRepository } from '../services/db/storeLocation.db';
|
||||
import { AddressRepository } from '../services/db/address.db';
|
||||
import { withTransaction } from '../services/db/connection.db';
|
||||
import { cacheService } from '../services/cacheService.server';
|
||||
import type { UserProfile } from '../types';
|
||||
import type { StoreDto, StoreWithLocationsDto } from '../dtos/common.dto';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request body for creating a new store.
|
||||
*/
|
||||
interface CreateStoreRequest {
|
||||
/**
|
||||
* Store name (must be unique).
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
* @example "Walmart"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* URL to store logo image (optional).
|
||||
* @format uri
|
||||
*/
|
||||
logo_url?: string | null;
|
||||
|
||||
/**
|
||||
* Initial address for the store (optional).
|
||||
* If provided, creates a store location with this address.
|
||||
*/
|
||||
address?: {
|
||||
/**
|
||||
* Street address line 1.
|
||||
* @minLength 1
|
||||
*/
|
||||
address_line_1: string;
|
||||
|
||||
/**
|
||||
* Street address line 2 (optional).
|
||||
*/
|
||||
address_line_2?: string | null;
|
||||
|
||||
/**
|
||||
* City name.
|
||||
* @minLength 1
|
||||
*/
|
||||
city: string;
|
||||
|
||||
/**
|
||||
* Province or state.
|
||||
* @minLength 1
|
||||
*/
|
||||
province_state: string;
|
||||
|
||||
/**
|
||||
* Postal or ZIP code.
|
||||
* @minLength 1
|
||||
*/
|
||||
postal_code: string;
|
||||
|
||||
/**
|
||||
* Country name (defaults to "Canada").
|
||||
*/
|
||||
country?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for store creation.
|
||||
*/
|
||||
interface CreateStoreResponseData {
|
||||
/** The created store ID */
|
||||
store_id: number;
|
||||
/** The created address ID (if address was provided) */
|
||||
address_id?: number;
|
||||
/** The created store location ID (if address was provided) */
|
||||
store_location_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for updating a store.
|
||||
*/
|
||||
interface UpdateStoreRequest {
|
||||
/**
|
||||
* New store name (optional).
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* New logo URL (optional, set to null to remove).
|
||||
* @format uri
|
||||
*/
|
||||
logo_url?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for adding a location to a store.
|
||||
*/
|
||||
interface CreateLocationRequest {
|
||||
/**
|
||||
* Street address line 1.
|
||||
* @minLength 1
|
||||
*/
|
||||
address_line_1: string;
|
||||
|
||||
/**
|
||||
* Street address line 2 (optional).
|
||||
*/
|
||||
address_line_2?: string | null;
|
||||
|
||||
/**
|
||||
* City name.
|
||||
* @minLength 1
|
||||
*/
|
||||
city: string;
|
||||
|
||||
/**
|
||||
* Province or state.
|
||||
* @minLength 1
|
||||
*/
|
||||
province_state: string;
|
||||
|
||||
/**
|
||||
* Postal or ZIP code.
|
||||
* @minLength 1
|
||||
*/
|
||||
postal_code: string;
|
||||
|
||||
/**
|
||||
* Country name (defaults to "Canada").
|
||||
*/
|
||||
country?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data for location creation.
|
||||
*/
|
||||
interface CreateLocationResponseData {
|
||||
/** The created store location ID */
|
||||
store_location_id: number;
|
||||
/** The created address ID */
|
||||
address_id: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STORE CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for store management endpoints.
|
||||
*
|
||||
* Provides read access to all users and write access to admins only.
|
||||
* Supports store CRUD operations and location management.
|
||||
*/
|
||||
@Route('stores')
|
||||
@Tags('Stores')
|
||||
export class StoreController extends BaseController {
|
||||
// ==========================================================================
|
||||
// LIST ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get all stores.
|
||||
*
|
||||
* Returns a list of all stores, optionally including their locations and addresses.
|
||||
* Stores are ordered alphabetically by name.
|
||||
*
|
||||
* @summary List all stores
|
||||
* @param includeLocations If true, includes locations and addresses for each store
|
||||
* @returns Array of store objects
|
||||
*/
|
||||
@Get()
|
||||
@SuccessResponse(200, 'List of stores retrieved successfully')
|
||||
public async getStores(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() includeLocations?: boolean,
|
||||
): Promise<SuccessResponseType<StoreDto[] | StoreWithLocationsDto[]>> {
|
||||
const storeRepo = new StoreRepository();
|
||||
const storeLocationRepo = new StoreLocationRepository();
|
||||
|
||||
if (includeLocations) {
|
||||
const storesWithLocations = await storeLocationRepo.getAllStoresWithLocations(req.log);
|
||||
return this.success(storesWithLocations as unknown as StoreWithLocationsDto[]);
|
||||
}
|
||||
|
||||
const stores = await storeRepo.getAllStores(req.log);
|
||||
return this.success(stores as unknown as StoreDto[]);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SINGLE RESOURCE ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get store by ID.
|
||||
*
|
||||
* Returns a single store with all its locations and addresses.
|
||||
*
|
||||
* @summary Get a single store with locations
|
||||
* @param id The unique identifier of the store
|
||||
* @returns The store object with full location details
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Store retrieved successfully')
|
||||
@Response<ErrorResponse>(404, 'Store not found')
|
||||
public async getStoreById(
|
||||
@Path() id: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<StoreWithLocationsDto>> {
|
||||
const storeLocationRepo = new StoreLocationRepository();
|
||||
const store = await storeLocationRepo.getStoreWithLocations(id, req.log);
|
||||
req.log.debug({ storeId: id }, 'Retrieved store by ID');
|
||||
return this.success(store as unknown as StoreWithLocationsDto);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS - CREATE
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a new store.
|
||||
*
|
||||
* Creates a new store, optionally with an initial address/location.
|
||||
* If an address is provided, it will be created and linked to the store.
|
||||
*
|
||||
* @summary Create a new store (admin only)
|
||||
* @param body Store creation data
|
||||
* @returns The created store IDs
|
||||
*/
|
||||
@Post()
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@SuccessResponse(201, 'Store created successfully')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - admin access required')
|
||||
@Response<ErrorResponse>(409, 'Store with this name already exists')
|
||||
public async createStore(
|
||||
@Body() body: CreateStoreRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<CreateStoreResponseData>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.info({ storeName: body.name, hasAddress: !!body.address }, 'Creating new store');
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
// Create the store
|
||||
const storeRepo = new StoreRepository(client);
|
||||
const storeId = await storeRepo.createStore(body.name, req.log, body.logo_url, userId);
|
||||
|
||||
// If address provided, create address and link to store
|
||||
let addressId: number | undefined;
|
||||
let storeLocationId: number | undefined;
|
||||
|
||||
if (body.address) {
|
||||
const addressRepo = new AddressRepository(client);
|
||||
addressId = await addressRepo.upsertAddress(
|
||||
{
|
||||
address_line_1: body.address.address_line_1,
|
||||
address_line_2: body.address.address_line_2 || null,
|
||||
city: body.address.city,
|
||||
province_state: body.address.province_state,
|
||||
postal_code: body.address.postal_code,
|
||||
country: body.address.country || 'Canada',
|
||||
},
|
||||
req.log,
|
||||
);
|
||||
|
||||
const storeLocationRepo = new StoreLocationRepository(client);
|
||||
storeLocationId = await storeLocationRepo.createStoreLocation(storeId, addressId, req.log);
|
||||
}
|
||||
|
||||
return { storeId, addressId, storeLocationId };
|
||||
});
|
||||
|
||||
// Invalidate store cache after successful creation
|
||||
await cacheService.invalidateStores(req.log);
|
||||
|
||||
req.log.info({ storeId: result.storeId }, 'Store created successfully');
|
||||
|
||||
return this.created({
|
||||
store_id: result.storeId,
|
||||
address_id: result.addressId,
|
||||
store_location_id: result.storeLocationId,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS - UPDATE
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Update a store.
|
||||
*
|
||||
* Updates a store's name and/or logo URL.
|
||||
*
|
||||
* @summary Update a store (admin only)
|
||||
* @param id The unique identifier of the store
|
||||
* @param body Update data
|
||||
*/
|
||||
@Put('{id}')
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@SuccessResponse(204, 'Store updated successfully')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - admin access required')
|
||||
@Response<ErrorResponse>(404, 'Store not found')
|
||||
@Response<ErrorResponse>(409, 'Store with this name already exists')
|
||||
public async updateStore(
|
||||
@Path() id: number,
|
||||
@Body() body: UpdateStoreRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const storeRepo = new StoreRepository();
|
||||
|
||||
req.log.info({ storeId: id, updates: body }, 'Updating store');
|
||||
|
||||
await storeRepo.updateStore(id, body, req.log);
|
||||
|
||||
// Invalidate cache for this specific store
|
||||
await cacheService.invalidateStore(id, req.log);
|
||||
|
||||
req.log.info({ storeId: id }, 'Store updated successfully');
|
||||
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS - DELETE
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Delete a store.
|
||||
*
|
||||
* Deletes a store and all its associated locations.
|
||||
* This operation cascades to delete all store_locations entries.
|
||||
*
|
||||
* @summary Delete a store (admin only)
|
||||
* @param id The unique identifier of the store
|
||||
*/
|
||||
@Delete('{id}')
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@SuccessResponse(204, 'Store deleted successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - admin access required')
|
||||
@Response<ErrorResponse>(404, 'Store not found')
|
||||
public async deleteStore(@Path() id: number, @Request() req: ExpressRequest): Promise<void> {
|
||||
const storeRepo = new StoreRepository();
|
||||
|
||||
req.log.info({ storeId: id }, 'Deleting store');
|
||||
|
||||
await storeRepo.deleteStore(id, req.log);
|
||||
|
||||
// Invalidate all store cache after deletion
|
||||
await cacheService.invalidateStores(req.log);
|
||||
|
||||
req.log.info({ storeId: id }, 'Store deleted successfully');
|
||||
|
||||
return this.noContent();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// LOCATION MANAGEMENT ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Add a location to a store.
|
||||
*
|
||||
* Creates a new address and links it to the store as a location.
|
||||
*
|
||||
* @summary Add a location to a store (admin only)
|
||||
* @param id The store ID
|
||||
* @param body Address data for the new location
|
||||
* @returns The created location and address IDs
|
||||
*/
|
||||
@Post('{id}/locations')
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@SuccessResponse(201, 'Location added successfully')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - admin access required')
|
||||
@Response<ErrorResponse>(404, 'Store not found')
|
||||
@Response<ErrorResponse>(409, 'This store is already linked to this address')
|
||||
public async addLocation(
|
||||
@Path() id: number,
|
||||
@Body() body: CreateLocationRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<CreateLocationResponseData>> {
|
||||
req.log.info({ storeId: id }, 'Adding location to store');
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
// Create the address
|
||||
const addressRepo = new AddressRepository(client);
|
||||
const addressId = await addressRepo.upsertAddress(
|
||||
{
|
||||
address_line_1: body.address_line_1,
|
||||
address_line_2: body.address_line_2 || null,
|
||||
city: body.city,
|
||||
province_state: body.province_state,
|
||||
postal_code: body.postal_code,
|
||||
country: body.country || 'Canada',
|
||||
},
|
||||
req.log,
|
||||
);
|
||||
|
||||
// Link to store
|
||||
const storeLocationRepo = new StoreLocationRepository(client);
|
||||
const storeLocationId = await storeLocationRepo.createStoreLocation(id, addressId, req.log);
|
||||
|
||||
return { storeLocationId, addressId };
|
||||
});
|
||||
|
||||
// Invalidate cache for this store's locations
|
||||
await cacheService.invalidateStoreLocations(id, req.log);
|
||||
|
||||
req.log.info(
|
||||
{ storeId: id, storeLocationId: result.storeLocationId },
|
||||
'Location added successfully',
|
||||
);
|
||||
|
||||
return this.created({
|
||||
store_location_id: result.storeLocationId,
|
||||
address_id: result.addressId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a location from a store.
|
||||
*
|
||||
* Deletes the link between a store and an address.
|
||||
* The address itself is not deleted (it may be used by other entities).
|
||||
*
|
||||
* @summary Remove a location from a store (admin only)
|
||||
* @param id The store ID
|
||||
* @param locationId The store location ID to remove
|
||||
*/
|
||||
@Delete('{id}/locations/{locationId}')
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@SuccessResponse(204, 'Location removed successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - admin access required')
|
||||
@Response<ErrorResponse>(404, 'Location not found')
|
||||
public async deleteLocation(
|
||||
@Path() id: number,
|
||||
@Path() locationId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const storeLocationRepo = new StoreLocationRepository();
|
||||
|
||||
req.log.info({ storeId: id, locationId }, 'Removing location from store');
|
||||
|
||||
await storeLocationRepo.deleteStoreLocation(locationId, req.log);
|
||||
|
||||
// Invalidate cache for this store's locations
|
||||
await cacheService.invalidateStoreLocations(id, req.log);
|
||||
|
||||
req.log.info({ storeId: id, locationId }, 'Location removed successfully');
|
||||
|
||||
return this.noContent();
|
||||
}
|
||||
}
|
||||
345
src/controllers/system.controller.test.ts
Normal file
345
src/controllers/system.controller.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
// src/controllers/system.controller.test.ts
|
||||
// ============================================================================
|
||||
// SYSTEM CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the SystemController class. These tests verify controller
|
||||
// logic in isolation by mocking the system and geocoding services.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock system service
|
||||
vi.mock('../services/systemService', () => ({
|
||||
systemService: {
|
||||
getPm2Status: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock geocoding service
|
||||
vi.mock('../services/geocodingService.server', () => ({
|
||||
geocodingService: {
|
||||
geocodeAddress: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import { systemService } from '../services/systemService';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
import { SystemController } from './system.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedSystemService = systemService as Mocked<typeof systemService>;
|
||||
const mockedGeocodingService = geocodingService as Mocked<typeof geocodingService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('SystemController', () => {
|
||||
let controller: SystemController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new SystemController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PM2 STATUS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getPm2Status()', () => {
|
||||
it('should return PM2 status when process is online', async () => {
|
||||
// Arrange
|
||||
mockedSystemService.getPm2Status.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'flyer-crawler-api is online',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.getPm2Status();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.success).toBe(true);
|
||||
expect(result.data.message).toBe('flyer-crawler-api is online');
|
||||
}
|
||||
expect(mockedSystemService.getPm2Status).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return PM2 status when process is offline', async () => {
|
||||
// Arrange
|
||||
mockedSystemService.getPm2Status.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'flyer-crawler-api is not running',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.getPm2Status();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.success).toBe(false);
|
||||
expect(result.data.message).toBe('flyer-crawler-api is not running');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle PM2 not installed', async () => {
|
||||
// Arrange
|
||||
mockedSystemService.getPm2Status.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'PM2 is not installed or not in PATH',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.getPm2Status();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GEOCODE
|
||||
// ==========================================================================
|
||||
|
||||
describe('geocodeAddress()', () => {
|
||||
it('should return coordinates for valid address', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGeocodingService.geocodeAddress.mockResolvedValue({
|
||||
lat: 49.2827,
|
||||
lng: -123.1207,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.geocodeAddress(request, {
|
||||
address: '123 Main St, Vancouver, BC',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.lat).toBe(49.2827);
|
||||
expect(result.data.lng).toBe(-123.1207);
|
||||
}
|
||||
expect(mockedGeocodingService.geocodeAddress).toHaveBeenCalledWith(
|
||||
'123 Main St, Vancouver, BC',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for empty address', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.geocodeAddress(request, {
|
||||
address: '',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return error for whitespace-only address', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.geocodeAddress(request, {
|
||||
address: ' ',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return 404 when address cannot be geocoded', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGeocodingService.geocodeAddress.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await controller.geocodeAddress(request, {
|
||||
address: 'Invalid Address That Does Not Exist',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle complex addresses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedGeocodingService.geocodeAddress.mockResolvedValue({
|
||||
lat: 43.6532,
|
||||
lng: -79.3832,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.geocodeAddress(request, {
|
||||
address: '123 King St W, Suite 500, Toronto, ON M5V 1J2, Canada',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.lat).toBe(43.6532);
|
||||
expect(result.data.lng).toBe(-79.3832);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass logger to geocoding service', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedGeocodingService.geocodeAddress.mockResolvedValue({
|
||||
lat: 49.2827,
|
||||
lng: -123.1207,
|
||||
});
|
||||
|
||||
// Act
|
||||
await controller.geocodeAddress(request, {
|
||||
address: '123 Main St',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockedGeocodingService.geocodeAddress).toHaveBeenCalledWith('123 Main St', mockLog);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PUBLIC ACCESS (NO AUTH REQUIRED)
|
||||
// ==========================================================================
|
||||
|
||||
describe('Public access', () => {
|
||||
it('should work without user authentication for PM2 status', async () => {
|
||||
// Arrange
|
||||
mockedSystemService.getPm2Status.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Process is online',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.getPm2Status();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should work without user authentication for geocoding', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: undefined });
|
||||
|
||||
mockedGeocodingService.geocodeAddress.mockResolvedValue({
|
||||
lat: 49.2827,
|
||||
lng: -123.1207,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.geocodeAddress(request, {
|
||||
address: '123 Main St',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
mockedSystemService.getPm2Status.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Online',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await controller.getPm2Status();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use error helper for validation errors', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act
|
||||
const result = await controller.geocodeAddress(request, {
|
||||
address: '',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
src/controllers/system.controller.ts
Normal file
135
src/controllers/system.controller.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// src/controllers/system.controller.ts
|
||||
// ============================================================================
|
||||
// SYSTEM CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides system-level endpoints for diagnostics and utility services.
|
||||
// Includes PM2 status checking and geocoding functionality.
|
||||
//
|
||||
// Most endpoints are public (no authentication required).
|
||||
// Implements ADR-028 (API Response Format) via BaseController.
|
||||
// ============================================================================
|
||||
|
||||
import { Get, Post, Route, Tags, Body, Request, SuccessResponse, Response } from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import { systemService } from '../services/systemService';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST/RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response from PM2 status check.
|
||||
*/
|
||||
interface Pm2StatusResponse {
|
||||
/** Whether the PM2 process is online */
|
||||
success: boolean;
|
||||
/** Human-readable status message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for geocoding an address.
|
||||
*/
|
||||
interface GeocodeRequest {
|
||||
/** Address string to geocode */
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoded coordinates response.
|
||||
*/
|
||||
interface GeocodeResponse {
|
||||
/** Latitude */
|
||||
lat: number;
|
||||
/** Longitude */
|
||||
lng: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SYSTEM CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for system-level operations.
|
||||
*
|
||||
* Provides diagnostic and utility endpoints. These endpoints are primarily
|
||||
* used for development, monitoring, and supporting application features
|
||||
* that need server-side processing (like geocoding).
|
||||
*/
|
||||
@Route('system')
|
||||
@Tags('System')
|
||||
export class SystemController extends BaseController {
|
||||
// ==========================================================================
|
||||
// PM2 STATUS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get PM2 process status.
|
||||
*
|
||||
* Checks the status of the 'flyer-crawler-api' process managed by PM2.
|
||||
* Useful for development and diagnostic purposes to verify the application
|
||||
* is running correctly under PM2 process management.
|
||||
*
|
||||
* @summary Get PM2 process status
|
||||
* @returns PM2 process status information
|
||||
*/
|
||||
@Get('pm2-status')
|
||||
@SuccessResponse(200, 'PM2 process status information')
|
||||
public async getPm2Status(): Promise<SuccessResponseType<Pm2StatusResponse>> {
|
||||
const status = await systemService.getPm2Status();
|
||||
return this.success(status);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GEOCODE
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Geocode an address.
|
||||
*
|
||||
* Geocodes a given address string, returning latitude and longitude
|
||||
* coordinates. Acts as a secure proxy to geocoding services (Google Maps
|
||||
* Geocoding API with Nominatim fallback), keeping API keys server-side.
|
||||
*
|
||||
* Results are cached in Redis for 30 days to reduce API calls.
|
||||
*
|
||||
* @summary Geocode an address
|
||||
* @param request Express request for logging
|
||||
* @param body Request body containing the address to geocode
|
||||
* @returns Geocoded coordinates
|
||||
*/
|
||||
@Post('geocode')
|
||||
@SuccessResponse(200, 'Geocoded coordinates')
|
||||
@Response<ErrorResponse>(400, 'Invalid request - address is required')
|
||||
@Response<ErrorResponse>(404, 'Could not geocode the provided address')
|
||||
public async geocodeAddress(
|
||||
@Request() request: ExpressRequest,
|
||||
@Body() body: GeocodeRequest,
|
||||
): Promise<SuccessResponseType<GeocodeResponse>> {
|
||||
const { address } = body;
|
||||
|
||||
// Validate address
|
||||
if (!address || address.trim() === '') {
|
||||
this.setStatus(400);
|
||||
return this.error(
|
||||
this.ErrorCode.BAD_REQUEST,
|
||||
'An address string is required.',
|
||||
) as unknown as SuccessResponseType<GeocodeResponse>;
|
||||
}
|
||||
|
||||
const coordinates = await geocodingService.geocodeAddress(address, request.log);
|
||||
|
||||
if (!coordinates) {
|
||||
this.setStatus(404);
|
||||
return this.error(
|
||||
this.ErrorCode.NOT_FOUND,
|
||||
'Could not geocode the provided address.',
|
||||
) as unknown as SuccessResponseType<GeocodeResponse>;
|
||||
}
|
||||
|
||||
return this.success(coordinates);
|
||||
}
|
||||
}
|
||||
381
src/controllers/types.ts
Normal file
381
src/controllers/types.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
// src/controllers/types.ts
|
||||
// ============================================================================
|
||||
// TSOA CONTROLLER TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
// Shared types for tsoa controllers that match the existing API response
|
||||
// patterns defined in ADR-028 and implemented in src/types/api.ts.
|
||||
//
|
||||
// These types are designed to be used with tsoa's automatic OpenAPI generation
|
||||
// while maintaining full compatibility with the existing Express route handlers.
|
||||
// ============================================================================
|
||||
|
||||
import type { Logger } from 'pino';
|
||||
import type { PoolClient } from 'pg';
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE TYPES
|
||||
// ============================================================================
|
||||
// These types mirror the structures in src/types/api.ts but are designed
|
||||
// for tsoa's type inference system. tsoa generates OpenAPI specs from these
|
||||
// types, so they must be concrete interfaces (not type aliases with generics
|
||||
// that tsoa cannot introspect).
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard pagination metadata included in paginated responses.
|
||||
* Matches the PaginationMeta interface in src/types/api.ts.
|
||||
*/
|
||||
export interface PaginationMeta {
|
||||
/** Current page number (1-indexed) */
|
||||
page: number;
|
||||
/** Number of items per page */
|
||||
limit: number;
|
||||
/** Total number of items across all pages */
|
||||
total: number;
|
||||
/** Total number of pages */
|
||||
totalPages: number;
|
||||
/** Whether there is a next page */
|
||||
hasNextPage: boolean;
|
||||
/** Whether there is a previous page */
|
||||
hasPrevPage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional metadata that can be included in any response.
|
||||
* Matches the ResponseMeta interface in src/types/api.ts.
|
||||
*/
|
||||
export interface ResponseMeta {
|
||||
/** Unique request identifier for tracking/debugging */
|
||||
requestId?: string;
|
||||
/** ISO timestamp of when the response was generated */
|
||||
timestamp?: string;
|
||||
/** Pagination info (only for paginated responses) */
|
||||
pagination?: PaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard success response envelope.
|
||||
* All successful API responses follow this structure per ADR-028.
|
||||
*
|
||||
* @example
|
||||
* // Single item response
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": { "id": 1, "name": "Item" }
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Paginated list response
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": [{ "id": 1 }, { "id": 2 }],
|
||||
* "meta": {
|
||||
* "pagination": { "page": 1, "limit": 20, "total": 100, ... }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface SuccessResponse<T> {
|
||||
/** Always true for successful responses */
|
||||
success: true;
|
||||
/** The response payload */
|
||||
data: T;
|
||||
/** Optional metadata (requestId, pagination, etc.) */
|
||||
meta?: ResponseMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error details structure for API error responses.
|
||||
*/
|
||||
export interface ErrorDetails {
|
||||
/** Machine-readable error code (e.g., 'VALIDATION_ERROR', 'NOT_FOUND') */
|
||||
code: string;
|
||||
/** Human-readable error message */
|
||||
message: string;
|
||||
/** Additional error details (validation errors, stack trace in dev, etc.) */
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error response envelope.
|
||||
* All error responses follow this structure per ADR-028.
|
||||
*
|
||||
* @example
|
||||
* // Validation error
|
||||
* {
|
||||
* "success": false,
|
||||
* "error": {
|
||||
* "code": "VALIDATION_ERROR",
|
||||
* "message": "The request data is invalid.",
|
||||
* "details": [{ "path": ["email"], "message": "Invalid email format" }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Not found error
|
||||
* {
|
||||
* "success": false,
|
||||
* "error": {
|
||||
* "code": "NOT_FOUND",
|
||||
* "message": "User not found"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface ErrorResponse {
|
||||
/** Always false for error responses */
|
||||
success: false;
|
||||
/** Error information */
|
||||
error: ErrorDetails;
|
||||
/** Optional metadata (requestId for error tracking) */
|
||||
meta?: Pick<ResponseMeta, 'requestId' | 'timestamp'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all API responses.
|
||||
* Useful for frontend type narrowing based on `success` field.
|
||||
*/
|
||||
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
|
||||
|
||||
// ============================================================================
|
||||
// COMMON RESPONSE TYPES
|
||||
// ============================================================================
|
||||
// Pre-defined response types for common API patterns. These provide concrete
|
||||
// types that tsoa can use for OpenAPI generation.
|
||||
//
|
||||
// Note: MessageResponse is now imported from common.dto.ts to avoid duplicate
|
||||
// definitions that cause tsoa route generation errors.
|
||||
// ============================================================================
|
||||
|
||||
// Re-export MessageResponse from the central DTO definitions
|
||||
export type { MessageResponse } from '../dtos/common.dto';
|
||||
|
||||
/**
|
||||
* Health check response for liveness/readiness probes.
|
||||
*/
|
||||
export interface HealthResponse {
|
||||
status: 'ok' | 'healthy' | 'degraded' | 'unhealthy';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed health check response with service status.
|
||||
*/
|
||||
export interface DetailedHealthResponse extends HealthResponse {
|
||||
uptime: number;
|
||||
services: Record<string, ServiceHealth>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual service health status.
|
||||
*/
|
||||
export interface ServiceHealth {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
latency?: number;
|
||||
message?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST CONTEXT
|
||||
// ============================================================================
|
||||
// RequestContext provides dependency injection for controllers, allowing
|
||||
// access to request-scoped resources like loggers, database transactions,
|
||||
// and authenticated user information.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authenticated user context extracted from JWT token.
|
||||
* Contains the minimum information needed for authorization checks.
|
||||
*/
|
||||
export interface AuthenticatedUser {
|
||||
/** The user's unique identifier (UUID) */
|
||||
userId: string;
|
||||
/** The user's email address */
|
||||
email: string;
|
||||
/** User roles for authorization (e.g., ['user', 'admin']) */
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request context providing dependency injection for controller methods.
|
||||
*
|
||||
* Controllers should accept a RequestContext parameter (injected via tsoa's
|
||||
* @Request decorator) to access request-scoped resources like logging,
|
||||
* database transactions, and authenticated user information.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get('{id}')
|
||||
* public async getUser(
|
||||
* @Path() id: string,
|
||||
* @Request() ctx: RequestContext,
|
||||
* ): Promise<SuccessResponse<User>> {
|
||||
* ctx.logger.info({ userId: id }, 'Fetching user');
|
||||
* const user = await userService.getUserById(id, ctx.logger);
|
||||
* return this.success(user);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface RequestContext {
|
||||
/**
|
||||
* Request-scoped Pino logger instance.
|
||||
* Includes request context (requestId, userId, etc.) for log correlation.
|
||||
*
|
||||
* @see ADR-004 for logging standards
|
||||
*/
|
||||
logger: Logger;
|
||||
|
||||
/**
|
||||
* Unique identifier for this request.
|
||||
* Used for log correlation and error tracking in Bugsink.
|
||||
*/
|
||||
requestId: string;
|
||||
|
||||
/**
|
||||
* Authenticated user information extracted from JWT token.
|
||||
* Undefined for unauthenticated requests (public endpoints).
|
||||
*/
|
||||
user?: AuthenticatedUser;
|
||||
|
||||
/**
|
||||
* Optional database client for transaction support.
|
||||
* When provided, all database operations should use this client
|
||||
* to participate in the same transaction.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a controller method that needs a transaction:
|
||||
* const result = await withTransaction(async (client) => {
|
||||
* const ctx = { ...requestContext, dbClient: client };
|
||||
* return await this.createOrderWithItems(orderData, ctx);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
dbClient?: PoolClient;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAGINATION
|
||||
// ============================================================================
|
||||
// Types for handling paginated requests and responses.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard pagination query parameters.
|
||||
* Used in list endpoints to control result pagination.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get()
|
||||
* public async listUsers(
|
||||
* @Query() page?: number,
|
||||
* @Query() limit?: number,
|
||||
* ): Promise<PaginatedResponse<User>> {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
/** Page number (1-indexed). Defaults to 1. */
|
||||
page?: number;
|
||||
/** Number of items per page. Defaults to 20, max 100. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for calculating pagination metadata.
|
||||
* Used internally by the base controller's paginated response helpers.
|
||||
*/
|
||||
export interface PaginationInput {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated response wrapper.
|
||||
* Combines array data with pagination metadata.
|
||||
*/
|
||||
export interface PaginatedResponse<T> extends SuccessResponse<T[]> {
|
||||
meta: ResponseMeta & {
|
||||
pagination: PaginationMeta;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ERROR CODES
|
||||
// ============================================================================
|
||||
// Standard error codes used across the API, matching src/types/api.ts.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard error codes for consistent error identification.
|
||||
* These match the ErrorCode object in src/types/api.ts.
|
||||
*/
|
||||
export const ControllerErrorCode = {
|
||||
// Client errors (4xx)
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
CONFLICT: 'CONFLICT',
|
||||
BAD_REQUEST: 'BAD_REQUEST',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
||||
|
||||
// Server errors (5xx)
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||
EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR',
|
||||
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
|
||||
} as const;
|
||||
|
||||
export type ControllerErrorCodeType =
|
||||
(typeof ControllerErrorCode)[keyof typeof ControllerErrorCode];
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION
|
||||
// ============================================================================
|
||||
// Types for request validation errors.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A single validation issue from Zod or similar validation library.
|
||||
*/
|
||||
export interface ValidationIssue {
|
||||
/** Path to the invalid field (e.g., ['body', 'email']) */
|
||||
path: (string | number)[];
|
||||
/** Human-readable error message */
|
||||
message: string;
|
||||
/** Additional context (varies by validation library) */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error response with detailed field-level errors.
|
||||
*/
|
||||
export interface ValidationErrorResponse extends ErrorResponse {
|
||||
error: ErrorDetails & {
|
||||
/** Array of validation issues for each invalid field */
|
||||
details: ValidationIssue[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TYPE GUARDS
|
||||
// ============================================================================
|
||||
// Runtime type guards for response type narrowing.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard to check if a response is a success response.
|
||||
*/
|
||||
export function isSuccessResponse<T>(response: ApiResponse<T>): response is SuccessResponse<T> {
|
||||
return response.success === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a response is an error response.
|
||||
*/
|
||||
export function isErrorResponse<T>(response: ApiResponse<T>): response is ErrorResponse {
|
||||
return response.success === false;
|
||||
}
|
||||
515
src/controllers/upc.controller.test.ts
Normal file
515
src/controllers/upc.controller.test.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
// src/controllers/upc.controller.test.ts
|
||||
// ============================================================================
|
||||
// UPC CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the UpcController class. These tests verify controller
|
||||
// logic in isolation by mocking the UPC service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(status: number): void {
|
||||
this._status = status;
|
||||
}
|
||||
private _status = 200;
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Body: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock UPC service
|
||||
vi.mock('../services/upcService.server', () => ({
|
||||
scanUpc: vi.fn(),
|
||||
lookupUpc: vi.fn(),
|
||||
getScanHistory: vi.fn(),
|
||||
getScanById: vi.fn(),
|
||||
getScanStats: vi.fn(),
|
||||
linkUpcToProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as upcService from '../services/upcService.server';
|
||||
import { UpcController } from './upc.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedUpcService = upcService as Mocked<typeof upcService>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
user: createMockUserProfile(),
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
role: 'user' as const,
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock admin user profile.
|
||||
*/
|
||||
function createMockAdminProfile() {
|
||||
return {
|
||||
full_name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
user: {
|
||||
user_id: 'admin-user-id',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock scan result.
|
||||
*/
|
||||
function createMockScanResult() {
|
||||
return {
|
||||
scan_id: 1,
|
||||
upc_code: '012345678901',
|
||||
product: {
|
||||
product_id: 100,
|
||||
name: 'Test Product',
|
||||
brand: 'Test Brand',
|
||||
category: 'Grocery',
|
||||
description: 'A test product',
|
||||
size: '500g',
|
||||
upc_code: '012345678901',
|
||||
image_url: null,
|
||||
master_item_id: 50,
|
||||
},
|
||||
external_lookup: null,
|
||||
confidence: 0.95,
|
||||
lookup_successful: true,
|
||||
is_new_product: false,
|
||||
scanned_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('UpcController', () => {
|
||||
let controller: UpcController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new UpcController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SCAN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('scanUpc()', () => {
|
||||
it('should scan a UPC code successfully', async () => {
|
||||
// Arrange
|
||||
const mockResult = createMockScanResult();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.scanUpc.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.scanUpc(
|
||||
{
|
||||
upc_code: '012345678901',
|
||||
scan_source: 'manual_entry',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.upc_code).toBe('012345678901');
|
||||
expect(result.data.lookup_successful).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject when neither upc_code nor image provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.scanUpc({ scan_source: 'manual_entry' }, request)).rejects.toThrow(
|
||||
'Either upc_code or image_base64 must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should support image-based scanning', async () => {
|
||||
// Arrange
|
||||
const mockResult = createMockScanResult();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.scanUpc.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.scanUpc(
|
||||
{
|
||||
image_base64: 'base64encodedimage',
|
||||
scan_source: 'image_upload',
|
||||
},
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedUpcService.scanUpc).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.objectContaining({
|
||||
image_base64: 'base64encodedimage',
|
||||
scan_source: 'image_upload',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log scan requests', async () => {
|
||||
// Arrange
|
||||
const mockResult = createMockScanResult();
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({ log: mockLog });
|
||||
|
||||
mockedUpcService.scanUpc.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.scanUpc({ upc_code: '012345678901', scan_source: 'manual_entry' }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'test-user-id',
|
||||
scanSource: 'manual_entry',
|
||||
}),
|
||||
'UPC scan request received',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// LOOKUP ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('lookupUpc()', () => {
|
||||
it('should lookup a UPC code', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
upc_code: '012345678901',
|
||||
product: { product_id: 1, name: 'Test' },
|
||||
external_lookup: null,
|
||||
found: true,
|
||||
from_cache: false,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.lookupUpc.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.lookupUpc(request, '012345678901');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.upc_code).toBe('012345678901');
|
||||
expect(result.data.found).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should support force refresh option', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
upc_code: '012345678901',
|
||||
product: null,
|
||||
external_lookup: null,
|
||||
found: false,
|
||||
from_cache: false,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.lookupUpc.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.lookupUpc(request, '012345678901', true, true);
|
||||
|
||||
// Assert
|
||||
expect(mockedUpcService.lookupUpc).toHaveBeenCalledWith(
|
||||
{ upc_code: '012345678901', force_refresh: true },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// HISTORY ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getScanHistory()', () => {
|
||||
it('should return scan history with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockResult = {
|
||||
scans: [{ scan_id: 1, upc_code: '012345678901' }],
|
||||
total: 1,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.getScanHistory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
const result = await controller.getScanHistory(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.scans).toHaveLength(1);
|
||||
expect(result.data.total).toBe(1);
|
||||
}
|
||||
expect(mockedUpcService.getScanHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: 'test-user-id',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const mockResult = { scans: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.getScanHistory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getScanHistory(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedUpcService.getScanHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support filtering by scan source', async () => {
|
||||
// Arrange
|
||||
const mockResult = { scans: [], total: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.getScanHistory.mockResolvedValue(mockResult);
|
||||
|
||||
// Act
|
||||
await controller.getScanHistory(request, 50, 0, undefined, 'camera_scan');
|
||||
|
||||
// Assert
|
||||
expect(mockedUpcService.getScanHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scan_source: 'camera_scan' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScanById()', () => {
|
||||
it('should return a specific scan record', async () => {
|
||||
// Arrange
|
||||
const mockScan = {
|
||||
scan_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
upc_code: '012345678901',
|
||||
product_id: 100,
|
||||
scan_source: 'manual_entry',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.getScanById.mockResolvedValue(mockScan);
|
||||
|
||||
// Act
|
||||
const result = await controller.getScanById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.scan_id).toBe(1);
|
||||
}
|
||||
expect(mockedUpcService.getScanById).toHaveBeenCalledWith(
|
||||
1,
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// STATISTICS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getScanStats()', () => {
|
||||
it('should return scan statistics', async () => {
|
||||
// Arrange
|
||||
const mockStats = {
|
||||
total_scans: 100,
|
||||
successful_lookups: 85,
|
||||
unique_products: 50,
|
||||
scans_today: 5,
|
||||
scans_this_week: 20,
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.getScanStats.mockResolvedValue(mockStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getScanStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.total_scans).toBe(100);
|
||||
expect(result.data.successful_lookups).toBe(85);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('linkUpcToProduct()', () => {
|
||||
it('should link UPC to product (admin)', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: createMockAdminProfile() });
|
||||
|
||||
mockedUpcService.linkUpcToProduct.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.linkUpcToProduct(
|
||||
{ upc_code: '012345678901', product_id: 100 },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedUpcService.linkUpcToProduct).toHaveBeenCalledWith(
|
||||
100,
|
||||
'012345678901',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log link operations', async () => {
|
||||
// Arrange
|
||||
const mockLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const request = createMockRequest({
|
||||
user: createMockAdminProfile(),
|
||||
log: mockLog,
|
||||
});
|
||||
|
||||
mockedUpcService.linkUpcToProduct.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await controller.linkUpcToProduct({ upc_code: '012345678901', product_id: 100 }, request);
|
||||
|
||||
// Assert
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 100,
|
||||
upcCode: '012345678901',
|
||||
}),
|
||||
'UPC link request received',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const mockStats = { total_scans: 0 };
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUpcService.getScanStats.mockResolvedValue(mockStats);
|
||||
|
||||
// Act
|
||||
const result = await controller.getScanStats(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('should use noContent helper for 204 responses', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest({ user: createMockAdminProfile() });
|
||||
|
||||
mockedUpcService.linkUpcToProduct.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.linkUpcToProduct(
|
||||
{ upc_code: '012345678901', product_id: 1 },
|
||||
request,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
502
src/controllers/upc.controller.ts
Normal file
502
src/controllers/upc.controller.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
// src/controllers/upc.controller.ts
|
||||
// ============================================================================
|
||||
// UPC CONTROLLER
|
||||
// ============================================================================
|
||||
// Provides endpoints for UPC barcode scanning, lookup, and management.
|
||||
// Implements endpoints for:
|
||||
// - Scanning a UPC barcode (manual entry or image)
|
||||
// - Looking up a UPC code
|
||||
// - Getting scan history
|
||||
// - Getting a single scan by ID
|
||||
// - Getting scan statistics
|
||||
// - Linking a UPC to a product (admin only)
|
||||
//
|
||||
// All UPC endpoints require authentication.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Query,
|
||||
Body,
|
||||
Request,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { BaseController } from './base.controller';
|
||||
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
|
||||
import * as upcService from '../services/upcService.server';
|
||||
import type { UserProfile } from '../types';
|
||||
import type { UpcScanSource } from '../types/upc';
|
||||
|
||||
// ============================================================================
|
||||
// DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
// Data Transfer Objects that are tsoa-compatible for API documentation.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Product match from our database.
|
||||
*/
|
||||
interface ProductMatchDto {
|
||||
/** Internal product ID */
|
||||
product_id: number;
|
||||
/** Product name */
|
||||
name: string;
|
||||
/** Brand name, if known */
|
||||
brand: string | null;
|
||||
/** Product category */
|
||||
category: string | null;
|
||||
/** Product description */
|
||||
description: string | null;
|
||||
/** Product size/weight */
|
||||
size: string | null;
|
||||
/** The UPC code */
|
||||
upc_code: string;
|
||||
/** Product image URL */
|
||||
image_url: string | null;
|
||||
/** Link to master grocery item */
|
||||
master_item_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product information from external lookup.
|
||||
*/
|
||||
interface ExternalProductInfoDto {
|
||||
/** Product name from external source */
|
||||
name: string;
|
||||
/** Brand name from external source */
|
||||
brand: string | null;
|
||||
/** Product category from external source */
|
||||
category: string | null;
|
||||
/** Product description from external source */
|
||||
description: string | null;
|
||||
/** Product image URL from external source */
|
||||
image_url: string | null;
|
||||
/** Which external API provided this data */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete result from a UPC scan operation.
|
||||
*/
|
||||
interface ScanResultDto {
|
||||
/** ID of the recorded scan */
|
||||
scan_id: number;
|
||||
/** The scanned UPC code */
|
||||
upc_code: string;
|
||||
/** Matched product from our database, if found */
|
||||
product: ProductMatchDto | null;
|
||||
/** Product info from external lookup, if performed */
|
||||
external_lookup: ExternalProductInfoDto | null;
|
||||
/** Confidence score of barcode detection (0.0-1.0) */
|
||||
confidence: number | null;
|
||||
/** Whether any product info was found */
|
||||
lookup_successful: boolean;
|
||||
/** Whether this UPC was not previously in our database */
|
||||
is_new_product: boolean;
|
||||
/** Timestamp of the scan */
|
||||
scanned_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from a UPC lookup.
|
||||
*/
|
||||
interface LookupResultDto {
|
||||
/** The looked up UPC code */
|
||||
upc_code: string;
|
||||
/** Matched product from our database, if found */
|
||||
product: ProductMatchDto | null;
|
||||
/** Product info from external lookup, if performed */
|
||||
external_lookup: ExternalProductInfoDto | null;
|
||||
/** Whether any product info was found */
|
||||
found: boolean;
|
||||
/** Whether the lookup result came from cache */
|
||||
from_cache: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPC scan history record.
|
||||
*/
|
||||
interface ScanHistoryRecordDto {
|
||||
/** Primary key */
|
||||
scan_id: number;
|
||||
/** User who performed the scan */
|
||||
user_id: string;
|
||||
/** The scanned UPC code */
|
||||
upc_code: string;
|
||||
/** Matched product ID, if found */
|
||||
product_id: number | null;
|
||||
/** How the scan was performed */
|
||||
scan_source: string;
|
||||
/** Confidence score from barcode detection */
|
||||
scan_confidence: number | null;
|
||||
/** Path to uploaded barcode image */
|
||||
raw_image_path: string | null;
|
||||
/** Whether the lookup found product info */
|
||||
lookup_successful: boolean;
|
||||
/** When the scan was recorded */
|
||||
created_at: string;
|
||||
/** Last update timestamp */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan history list with total count.
|
||||
*/
|
||||
interface ScanHistoryResponseDto {
|
||||
/** List of scan history records */
|
||||
scans: ScanHistoryRecordDto[];
|
||||
/** Total count for pagination */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User scan statistics.
|
||||
*/
|
||||
interface ScanStatsDto {
|
||||
/** Total number of scans performed */
|
||||
total_scans: number;
|
||||
/** Number of scans that found product info */
|
||||
successful_lookups: number;
|
||||
/** Number of unique products scanned */
|
||||
unique_products: number;
|
||||
/** Number of scans today */
|
||||
scans_today: number;
|
||||
/** Number of scans this week */
|
||||
scans_this_week: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid scan source types.
|
||||
*/
|
||||
type ScanSourceType = 'image_upload' | 'manual_entry' | 'phone_app' | 'camera_scan';
|
||||
|
||||
/**
|
||||
* Request body for scanning a UPC barcode.
|
||||
*/
|
||||
interface ScanUpcRequest {
|
||||
/**
|
||||
* UPC code entered manually (8-14 digits).
|
||||
* Either this or image_base64 must be provided.
|
||||
* @pattern ^[0-9]{8,14}$
|
||||
* @example "012345678901"
|
||||
*/
|
||||
upc_code?: string;
|
||||
|
||||
/**
|
||||
* Base64-encoded image containing a barcode.
|
||||
* Either this or upc_code must be provided.
|
||||
*/
|
||||
image_base64?: string;
|
||||
|
||||
/**
|
||||
* How the scan was initiated.
|
||||
* @example "manual_entry"
|
||||
*/
|
||||
scan_source: ScanSourceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for linking a UPC to a product (admin only).
|
||||
*/
|
||||
interface LinkUpcRequest {
|
||||
/**
|
||||
* The UPC code to link (8-14 digits).
|
||||
* @pattern ^[0-9]{8,14}$
|
||||
* @example "012345678901"
|
||||
*/
|
||||
upc_code: string;
|
||||
|
||||
/**
|
||||
* The product ID to link the UPC to.
|
||||
* @isInt
|
||||
* @minimum 1
|
||||
*/
|
||||
product_id: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPC CONTROLLER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for UPC barcode scanning endpoints.
|
||||
*
|
||||
* All UPC endpoints require authentication. The link endpoint additionally
|
||||
* requires admin privileges.
|
||||
*/
|
||||
@Route('upc')
|
||||
@Tags('UPC Scanning')
|
||||
@Security('bearerAuth')
|
||||
export class UpcController extends BaseController {
|
||||
// ==========================================================================
|
||||
// SCAN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Scan a UPC barcode.
|
||||
*
|
||||
* Scans a UPC barcode either from a manually entered code or from an image.
|
||||
* Records the scan in history and returns product information if found.
|
||||
* If not found in our database, attempts to look up in external APIs.
|
||||
*
|
||||
* @summary Scan a UPC barcode
|
||||
* @param body Scan request with UPC code or image
|
||||
* @returns Complete scan result with product information
|
||||
*/
|
||||
@Post('scan')
|
||||
@SuccessResponse(200, 'Scan completed successfully')
|
||||
@Response<ErrorResponse>(400, 'Invalid UPC code format or missing data')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async scanUpc(
|
||||
@Body() body: ScanUpcRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ScanResultDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.info(
|
||||
{
|
||||
userId,
|
||||
scanSource: body.scan_source,
|
||||
hasUpc: !!body.upc_code,
|
||||
hasImage: !!body.image_base64,
|
||||
},
|
||||
'UPC scan request received',
|
||||
);
|
||||
|
||||
// Validate that at least one input method is provided
|
||||
if (!body.upc_code && !body.image_base64) {
|
||||
this.setStatus(400);
|
||||
throw new Error('Either upc_code or image_base64 must be provided.');
|
||||
}
|
||||
|
||||
const result = await upcService.scanUpc(
|
||||
userId,
|
||||
{
|
||||
upc_code: body.upc_code,
|
||||
image_base64: body.image_base64,
|
||||
scan_source: body.scan_source as UpcScanSource,
|
||||
},
|
||||
req.log,
|
||||
);
|
||||
|
||||
req.log.info(
|
||||
{ scanId: result.scan_id, upcCode: result.upc_code, found: result.lookup_successful },
|
||||
'UPC scan completed',
|
||||
);
|
||||
|
||||
return this.success(result as unknown as ScanResultDto);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// LOOKUP ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Look up a UPC code.
|
||||
*
|
||||
* Looks up product information for a UPC code without recording in scan history.
|
||||
* Useful for verification or quick lookups. First checks our database, then
|
||||
* optionally queries external APIs if not found locally.
|
||||
*
|
||||
* @summary Look up a UPC code
|
||||
* @param upc_code UPC code to look up (8-14 digits)
|
||||
* @param include_external Whether to check external APIs if not found locally (default: true)
|
||||
* @param force_refresh Skip cache and perform fresh external lookup (default: false)
|
||||
* @returns Lookup result with product information
|
||||
*/
|
||||
@Get('lookup')
|
||||
@SuccessResponse(200, 'Lookup completed successfully')
|
||||
@Response<ErrorResponse>(400, 'Invalid UPC code format')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async lookupUpc(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() upc_code: string,
|
||||
@Query() include_external?: boolean,
|
||||
@Query() force_refresh?: boolean,
|
||||
): Promise<SuccessResponseType<LookupResultDto>> {
|
||||
req.log.debug(
|
||||
{ upcCode: upc_code, forceRefresh: force_refresh },
|
||||
'UPC lookup request received',
|
||||
);
|
||||
|
||||
const result = await upcService.lookupUpc(
|
||||
{
|
||||
upc_code,
|
||||
force_refresh: force_refresh ?? false,
|
||||
},
|
||||
req.log,
|
||||
);
|
||||
|
||||
return this.success(result as unknown as LookupResultDto);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// HISTORY ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get scan history.
|
||||
*
|
||||
* Retrieves the authenticated user's UPC scan history with optional filtering.
|
||||
* Results are ordered by scan date (newest first).
|
||||
*
|
||||
* @summary Get user's scan history
|
||||
* @param limit Maximum number of results (1-100, default: 50)
|
||||
* @param offset Number of results to skip (default: 0)
|
||||
* @param lookup_successful Filter by lookup success status
|
||||
* @param scan_source Filter by scan source
|
||||
* @param from_date Filter scans from this date (YYYY-MM-DD)
|
||||
* @param to_date Filter scans until this date (YYYY-MM-DD)
|
||||
* @returns Paginated scan history
|
||||
*/
|
||||
@Get('history')
|
||||
@SuccessResponse(200, 'Scan history retrieved successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async getScanHistory(
|
||||
@Request() req: ExpressRequest,
|
||||
@Query() limit?: number,
|
||||
@Query() offset?: number,
|
||||
@Query() lookup_successful?: boolean,
|
||||
@Query() scan_source?: ScanSourceType,
|
||||
@Query() from_date?: string,
|
||||
@Query() to_date?: string,
|
||||
): Promise<SuccessResponseType<ScanHistoryResponseDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
// Apply defaults and bounds
|
||||
const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 50)));
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset ?? 0));
|
||||
|
||||
req.log.debug(
|
||||
{ userId, limit: normalizedLimit, offset: normalizedOffset },
|
||||
'Fetching scan history',
|
||||
);
|
||||
|
||||
const result = await upcService.getScanHistory(
|
||||
{
|
||||
user_id: userId,
|
||||
limit: normalizedLimit,
|
||||
offset: normalizedOffset,
|
||||
lookup_successful,
|
||||
scan_source: scan_source as UpcScanSource | undefined,
|
||||
from_date,
|
||||
to_date,
|
||||
},
|
||||
req.log,
|
||||
);
|
||||
|
||||
return this.success(result as unknown as ScanHistoryResponseDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan by ID.
|
||||
*
|
||||
* Retrieves a specific scan record by its ID.
|
||||
* Only returns scans belonging to the authenticated user.
|
||||
*
|
||||
* @summary Get a specific scan record
|
||||
* @param scanId The unique identifier of the scan
|
||||
* @returns The scan record
|
||||
*/
|
||||
@Get('history/{scanId}')
|
||||
@SuccessResponse(200, 'Scan record retrieved successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(404, 'Scan record not found')
|
||||
public async getScanById(
|
||||
@Path() scanId: number,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ScanHistoryRecordDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.debug({ scanId, userId }, 'Fetching scan by ID');
|
||||
|
||||
const scan = await upcService.getScanById(scanId, userId, req.log);
|
||||
|
||||
return this.success(scan as unknown as ScanHistoryRecordDto);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// STATISTICS ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get scan statistics.
|
||||
*
|
||||
* Returns scanning statistics for the authenticated user including
|
||||
* total scans, success rate, and activity metrics.
|
||||
*
|
||||
* @summary Get user's scan statistics
|
||||
* @returns Scan statistics
|
||||
*/
|
||||
@Get('stats')
|
||||
@SuccessResponse(200, 'Statistics retrieved successfully')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
public async getScanStats(
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ScanStatsDto>> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
const userId = userProfile.user.user_id;
|
||||
|
||||
req.log.debug({ userId }, 'Fetching scan statistics');
|
||||
|
||||
const stats = await upcService.getScanStats(userId, req.log);
|
||||
|
||||
return this.success(stats);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ADMIN ENDPOINTS
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Link UPC to product.
|
||||
*
|
||||
* Links a UPC code to an existing product in the database.
|
||||
* This is an admin-only operation used for data management.
|
||||
*
|
||||
* @summary Link a UPC code to a product (admin only)
|
||||
* @param body UPC code and product ID to link
|
||||
*/
|
||||
@Post('link')
|
||||
@Security('bearerAuth', ['admin'])
|
||||
@SuccessResponse(204, 'UPC linked successfully')
|
||||
@Response<ErrorResponse>(400, 'Invalid UPC code format')
|
||||
@Response<ErrorResponse>(401, 'Unauthorized')
|
||||
@Response<ErrorResponse>(403, 'Forbidden - admin access required')
|
||||
@Response<ErrorResponse>(404, 'Product not found')
|
||||
@Response<ErrorResponse>(409, 'UPC code already linked to another product')
|
||||
public async linkUpcToProduct(
|
||||
@Body() body: LinkUpcRequest,
|
||||
@Request() req: ExpressRequest,
|
||||
): Promise<void> {
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
req.log.info(
|
||||
{ userId: userProfile.user.user_id, productId: body.product_id, upcCode: body.upc_code },
|
||||
'UPC link request received',
|
||||
);
|
||||
|
||||
await upcService.linkUpcToProduct(body.product_id, body.upc_code, req.log);
|
||||
|
||||
req.log.info(
|
||||
{ productId: body.product_id, upcCode: body.upc_code },
|
||||
'UPC code linked successfully',
|
||||
);
|
||||
|
||||
return this.noContent();
|
||||
}
|
||||
}
|
||||
742
src/controllers/user.controller.test.ts
Normal file
742
src/controllers/user.controller.test.ts
Normal file
@@ -0,0 +1,742 @@
|
||||
// src/controllers/user.controller.test.ts
|
||||
// ============================================================================
|
||||
// USER CONTROLLER UNIT TESTS
|
||||
// ============================================================================
|
||||
// Unit tests for the UserController class. These tests verify controller
|
||||
// logic in isolation by mocking external dependencies like database
|
||||
// repositories and user service.
|
||||
// ============================================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SETUP
|
||||
// ============================================================================
|
||||
|
||||
// Mock tsoa decorators and Controller class
|
||||
vi.mock('tsoa', () => ({
|
||||
Controller: class Controller {
|
||||
protected setStatus(_status: number): void {
|
||||
// Mock setStatus
|
||||
}
|
||||
},
|
||||
Get: () => () => {},
|
||||
Post: () => () => {},
|
||||
Put: () => () => {},
|
||||
Delete: () => () => {},
|
||||
Route: () => () => {},
|
||||
Tags: () => () => {},
|
||||
Security: () => () => {},
|
||||
Body: () => () => {},
|
||||
Path: () => () => {},
|
||||
Query: () => () => {},
|
||||
Request: () => () => {},
|
||||
SuccessResponse: () => () => {},
|
||||
Response: () => () => {},
|
||||
FormField: () => () => {},
|
||||
Middlewares: () => () => {},
|
||||
}));
|
||||
|
||||
// Mock user service
|
||||
vi.mock('../services/userService', () => ({
|
||||
userService: {
|
||||
updateUserAvatar: vi.fn(),
|
||||
updateUserPassword: vi.fn(),
|
||||
deleteUserAccount: vi.fn(),
|
||||
getUserAddress: vi.fn(),
|
||||
upsertUserAddress: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock database repositories
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
userRepo: {
|
||||
findUserProfileById: vi.fn(),
|
||||
updateUserProfile: vi.fn(),
|
||||
updateUserPreferences: vi.fn(),
|
||||
},
|
||||
notificationRepo: {
|
||||
getNotificationsForUser: vi.fn(),
|
||||
getUnreadCount: vi.fn(),
|
||||
markAllNotificationsAsRead: vi.fn(),
|
||||
markNotificationAsRead: vi.fn(),
|
||||
deleteNotification: vi.fn(),
|
||||
},
|
||||
personalizationRepo: {
|
||||
getWatchedItems: vi.fn(),
|
||||
addWatchedItem: vi.fn(),
|
||||
removeWatchedItem: vi.fn(),
|
||||
getUserDietaryRestrictions: vi.fn(),
|
||||
setUserDietaryRestrictions: vi.fn(),
|
||||
getUserAppliances: vi.fn(),
|
||||
setUserAppliances: vi.fn(),
|
||||
},
|
||||
shoppingRepo: {
|
||||
getShoppingLists: vi.fn(),
|
||||
getShoppingListById: vi.fn(),
|
||||
createShoppingList: vi.fn(),
|
||||
deleteShoppingList: vi.fn(),
|
||||
addShoppingListItem: vi.fn(),
|
||||
updateShoppingListItem: vi.fn(),
|
||||
removeShoppingListItem: vi.fn(),
|
||||
},
|
||||
recipeRepo: {
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock password strength validation
|
||||
vi.mock('../utils/authUtils', () => ({
|
||||
validatePasswordStrength: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock file utilities
|
||||
vi.mock('../utils/fileUtils', () => ({
|
||||
cleanupUploadedFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock rate limiters
|
||||
vi.mock('../config/rateLimiters', () => ({
|
||||
userUpdateLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
userSensitiveUpdateLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
userUploadLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after mock definitions
|
||||
import * as db from '../services/db/index.db';
|
||||
import { userService } from '../services/userService';
|
||||
import { validatePasswordStrength } from '../utils/authUtils';
|
||||
import { UserController } from './user.controller';
|
||||
|
||||
// Cast mocked modules for type-safe access
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
const mockedUserService = userService as Mocked<typeof userService>;
|
||||
const mockedValidatePasswordStrength = validatePasswordStrength as ReturnType<typeof vi.fn>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a mock Express request object with authenticated user.
|
||||
*/
|
||||
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
|
||||
return {
|
||||
body: {},
|
||||
cookies: {},
|
||||
file: undefined,
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
user: createMockUserProfile(),
|
||||
...overrides,
|
||||
} as unknown as ExpressRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock user profile for testing.
|
||||
*/
|
||||
function createMockUserProfile() {
|
||||
return {
|
||||
full_name: 'Test User',
|
||||
avatar_url: null,
|
||||
address_id: null,
|
||||
points: 100,
|
||||
role: 'user' as const,
|
||||
preferences: { darkMode: false, unitSystem: 'metric' as const },
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
user: {
|
||||
user_id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
address: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock address object.
|
||||
*/
|
||||
function createMockAddress() {
|
||||
return {
|
||||
address_id: 1,
|
||||
address_line_1: '123 Main St',
|
||||
address_line_2: null,
|
||||
city: 'Toronto',
|
||||
province_state: 'ON',
|
||||
postal_code: 'M5V 1A1',
|
||||
country: 'Canada',
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('UserController', () => {
|
||||
let controller: UserController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new UserController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PROFILE MANAGEMENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getProfile()', () => {
|
||||
it('should return user profile successfully', async () => {
|
||||
// Arrange
|
||||
const mockProfile = createMockUserProfile();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.userRepo.findUserProfileById.mockResolvedValue(mockProfile);
|
||||
|
||||
// Act
|
||||
const result = await controller.getProfile(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.user.user_id).toBe('test-user-id');
|
||||
expect(result.data.user.email).toBe('test@example.com');
|
||||
}
|
||||
expect(mockedDb.userRepo.findUserProfileById).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateProfile()', () => {
|
||||
it('should update user profile successfully', async () => {
|
||||
// Arrange
|
||||
const updatedProfile = {
|
||||
full_name: 'Updated Name',
|
||||
avatar_url: null,
|
||||
points: 100,
|
||||
role: 'user' as const,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.userRepo.updateUserProfile.mockResolvedValue(updatedProfile);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateProfile(request, {
|
||||
full_name: 'Updated Name',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.full_name).toBe('Updated Name');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject update with no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateProfile(request, {})).rejects.toThrow(
|
||||
'At least one field to update must be provided.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePassword()', () => {
|
||||
it('should update password successfully', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({ isValid: true, feedback: '' });
|
||||
mockedUserService.updateUserPassword.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.updatePassword(request, {
|
||||
newPassword: 'NewSecurePassword123!',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Password updated successfully.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject weak password', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedValidatePasswordStrength.mockReturnValue({
|
||||
isValid: false,
|
||||
feedback: 'Password too weak',
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updatePassword(request, { newPassword: 'weak' })).rejects.toThrow(
|
||||
'Password too weak',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAccount()', () => {
|
||||
it('should delete account successfully', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUserService.deleteUserAccount.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await controller.deleteAccount(request, {
|
||||
password: 'currentPassword',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Account deleted successfully.');
|
||||
}
|
||||
expect(mockedUserService.deleteUserAccount).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'currentPassword',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// NOTIFICATION TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getNotifications()', () => {
|
||||
it('should return notifications with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockNotifications = [
|
||||
{
|
||||
notification_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
content: 'Test notification',
|
||||
is_read: false,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getNotificationsForUser.mockResolvedValue(mockNotifications);
|
||||
|
||||
// Act
|
||||
const result = await controller.getNotifications(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].content).toBe('Test notification');
|
||||
}
|
||||
expect(mockedDb.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
20, // default limit
|
||||
0, // default offset
|
||||
false, // default includeRead
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect pagination parameters', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getNotificationsForUser.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getNotifications(request, 50, 10, true);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
50,
|
||||
10,
|
||||
true,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap limit at 100', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getNotificationsForUser.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await controller.getNotifications(request, 200);
|
||||
|
||||
// Assert
|
||||
expect(mockedDb.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
100,
|
||||
0,
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadNotificationCount()', () => {
|
||||
it('should return unread notification count', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getUnreadCount.mockResolvedValue(5);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUnreadNotificationCount(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.count).toBe(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// WATCHED ITEMS TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getWatchedItems()', () => {
|
||||
it('should return watched items', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
{
|
||||
master_grocery_item_id: 1,
|
||||
name: 'Milk',
|
||||
category_id: 2,
|
||||
category_name: 'Dairy',
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.personalizationRepo.getWatchedItems.mockResolvedValue(mockItems);
|
||||
|
||||
// Act
|
||||
const result = await controller.getWatchedItems(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].name).toBe('Milk');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('addWatchedItem()', () => {
|
||||
it('should add a watched item successfully', async () => {
|
||||
// Arrange
|
||||
const mockItem = {
|
||||
master_grocery_item_id: 1,
|
||||
name: 'Eggs',
|
||||
category_id: 2,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.personalizationRepo.addWatchedItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addWatchedItem(request, {
|
||||
itemName: 'Eggs',
|
||||
category_id: 2,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Eggs');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// SHOPPING LIST TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getShoppingLists()', () => {
|
||||
it('should return shopping lists', async () => {
|
||||
// Arrange
|
||||
const mockLists = [
|
||||
{
|
||||
shopping_list_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
name: 'Weekly Groceries',
|
||||
items: [],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.shoppingRepo.getShoppingLists.mockResolvedValue(mockLists);
|
||||
|
||||
// Act
|
||||
const result = await controller.getShoppingLists(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].name).toBe('Weekly Groceries');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShoppingList()', () => {
|
||||
it('should create a shopping list successfully', async () => {
|
||||
// Arrange
|
||||
const mockList = {
|
||||
shopping_list_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
name: 'New List',
|
||||
items: [],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.shoppingRepo.createShoppingList.mockResolvedValue(mockList);
|
||||
|
||||
// Act
|
||||
const result = await controller.createShoppingList(request, {
|
||||
name: 'New List',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('New List');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('addShoppingListItem()', () => {
|
||||
it('should reject when neither masterItemId nor customItemName provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.addShoppingListItem(1, request, {})).rejects.toThrow(
|
||||
'Either masterItemId or customItemName must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add item with masterItemId', async () => {
|
||||
// Arrange
|
||||
const mockItem = {
|
||||
shopping_list_item_id: 1,
|
||||
shopping_list_id: 1,
|
||||
master_item_id: 5,
|
||||
quantity: 1,
|
||||
is_purchased: false,
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.shoppingRepo.addShoppingListItem.mockResolvedValue(mockItem);
|
||||
|
||||
// Act
|
||||
const result = await controller.addShoppingListItem(1, request, {
|
||||
masterItemId: 5,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateShoppingListItem()', () => {
|
||||
it('should reject when no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateShoppingListItem(1, request, {})).rejects.toThrow(
|
||||
'At least one field (quantity, is_purchased) must be provided.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DIETARY RESTRICTIONS TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getDietaryRestrictions()', () => {
|
||||
it('should return user dietary restrictions', async () => {
|
||||
// Arrange
|
||||
const mockRestrictions = [
|
||||
{ dietary_restriction_id: 1, name: 'Vegetarian', type: 'diet' as const },
|
||||
];
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.personalizationRepo.getUserDietaryRestrictions.mockResolvedValue(mockRestrictions);
|
||||
|
||||
// Act
|
||||
const result = await controller.getDietaryRestrictions(request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].name).toBe('Vegetarian');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ADDRESS TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('getAddressById()', () => {
|
||||
it('should return address by ID', async () => {
|
||||
// Arrange
|
||||
const mockAddress = createMockAddress();
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUserService.getUserAddress.mockResolvedValue(mockAddress);
|
||||
|
||||
// Act
|
||||
const result = await controller.getAddressById(1, request);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.city).toBe('Toronto');
|
||||
expect(result.data.province_state).toBe('ON');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAddress()', () => {
|
||||
it('should reject when no address fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateAddress(request, {})).rejects.toThrow(
|
||||
'At least one address field must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update address successfully', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedUserService.upsertUserAddress.mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
const result = await controller.updateAddress(request, {
|
||||
city: 'Vancouver',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.message).toBe('Address updated successfully');
|
||||
expect(result.data.address_id).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// RECIPE TESTS
|
||||
// ==========================================================================
|
||||
|
||||
describe('createRecipe()', () => {
|
||||
it('should create a recipe successfully', async () => {
|
||||
// Arrange
|
||||
const mockRecipe = {
|
||||
recipe_id: 1,
|
||||
user_id: 'test-user-id',
|
||||
name: 'Test Recipe',
|
||||
instructions: 'Test instructions',
|
||||
avg_rating: 0,
|
||||
status: 'private' as const,
|
||||
rating_count: 0,
|
||||
fork_count: 0,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.recipeRepo.createRecipe.mockResolvedValue(mockRecipe);
|
||||
|
||||
// Act
|
||||
const result = await controller.createRecipe(request, {
|
||||
name: 'Test Recipe',
|
||||
instructions: 'Test instructions',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Test Recipe');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecipe()', () => {
|
||||
it('should reject when no fields provided', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
// Act & Assert
|
||||
await expect(controller.updateRecipe(1, request, {})).rejects.toThrow(
|
||||
'No fields provided to update.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// BASE CONTROLLER INTEGRATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('BaseController integration', () => {
|
||||
it('should use success helper for consistent response format', async () => {
|
||||
// Arrange
|
||||
const request = createMockRequest();
|
||||
|
||||
mockedDb.notificationRepo.getUnreadCount.mockResolvedValue(3);
|
||||
|
||||
// Act
|
||||
const result = await controller.getUnreadNotificationCount(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
1394
src/controllers/user.controller.ts
Normal file
1394
src/controllers/user.controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
426
src/dtos/common.dto.ts
Normal file
426
src/dtos/common.dto.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
// src/dtos/common.dto.ts
|
||||
// ============================================================================
|
||||
// SHARED DTO TYPES FOR OPENAPI
|
||||
// ============================================================================
|
||||
// These Data Transfer Object types are tsoa-compatible versions of the
|
||||
// domain types. They avoid TypeScript features that tsoa cannot serialize
|
||||
// (like tuples) while maintaining the same structure for API responses.
|
||||
//
|
||||
// IMPORTANT: tsoa requires model definitions to be unique across all
|
||||
// controllers. All shared DTOs must be defined in this single file and
|
||||
// imported by controllers that need them.
|
||||
//
|
||||
// The actual domain types from src/types.ts are used at runtime; these DTOs
|
||||
// are only used for type annotations to generate the OpenAPI specification.
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// ADDRESS DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Address data with coordinates as separate fields (tsoa-compatible).
|
||||
* Note: GeoJSONPoint uses coordinates: [number, number] which tsoa cannot handle,
|
||||
* so we flatten to separate latitude/longitude fields.
|
||||
*/
|
||||
export interface AddressDto {
|
||||
/** Address unique identifier */
|
||||
readonly address_id: number;
|
||||
/** Street address line 1 */
|
||||
address_line_1: string;
|
||||
/** Street address line 2 (optional) */
|
||||
address_line_2?: string | null;
|
||||
/** City name */
|
||||
city: string;
|
||||
/** Province or state */
|
||||
province_state: string;
|
||||
/** Postal or ZIP code */
|
||||
postal_code: string;
|
||||
/** Country name */
|
||||
country: string;
|
||||
/** Latitude coordinate (extracted from GeoJSONPoint) */
|
||||
latitude?: number | null;
|
||||
/** Longitude coordinate (extracted from GeoJSONPoint) */
|
||||
longitude?: number | null;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STORE DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Store data transfer object for API responses.
|
||||
*/
|
||||
export interface StoreDto {
|
||||
/** Unique store identifier */
|
||||
readonly store_id: number;
|
||||
/** Store name */
|
||||
name: string;
|
||||
/** URL to store logo image */
|
||||
logo_url?: string | null;
|
||||
/** UUID of the user who created the store (optional) */
|
||||
readonly created_by?: string | null;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STORE LOCATION DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Store location with associated address.
|
||||
*/
|
||||
export interface StoreLocationDto {
|
||||
/** Unique store location identifier */
|
||||
store_location_id: number;
|
||||
/** Store ID reference (optional, included when needed) */
|
||||
store_id?: number;
|
||||
/** Address ID reference (optional, included when needed) */
|
||||
address_id?: number;
|
||||
/** Store information (optional, included when nested) */
|
||||
store?: StoreDto;
|
||||
/** Address information */
|
||||
address: AddressDto;
|
||||
/** Timestamp of record creation (optional) */
|
||||
readonly created_at?: string;
|
||||
/** Timestamp of last update (optional) */
|
||||
readonly updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store with all its locations.
|
||||
*/
|
||||
export interface StoreWithLocationsDto extends StoreDto {
|
||||
/** Array of store locations */
|
||||
locations: StoreLocationDto[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* User data returned in API responses.
|
||||
*/
|
||||
export interface UserDto {
|
||||
/** User's unique identifier (UUID) */
|
||||
readonly user_id: string;
|
||||
/** User's email address */
|
||||
email: string;
|
||||
/** Timestamp when user was created */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User preferences configuration.
|
||||
*/
|
||||
export interface UserPreferencesDto {
|
||||
/** Whether dark mode is enabled */
|
||||
darkMode?: boolean;
|
||||
/** Unit system preference */
|
||||
unitSystem?: 'metric' | 'imperial';
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile data transfer object.
|
||||
*/
|
||||
export interface UserProfileDto {
|
||||
/** User's full name */
|
||||
full_name?: string | null;
|
||||
/** URL to user's avatar image */
|
||||
avatar_url?: string | null;
|
||||
/** Associated address ID */
|
||||
address_id?: number | null;
|
||||
/** User's points balance */
|
||||
readonly points: number;
|
||||
/** User's role */
|
||||
readonly role: 'admin' | 'user';
|
||||
/** User preferences */
|
||||
preferences?: UserPreferencesDto | null;
|
||||
/** User who created this profile */
|
||||
readonly created_by?: string | null;
|
||||
/** User who last updated this profile */
|
||||
readonly updated_by?: string | null;
|
||||
/** Timestamp of profile creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
/** Nested user data */
|
||||
user: UserDto;
|
||||
/** Associated address (optional) */
|
||||
address?: AddressDto | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FLYER DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Flyer status enumeration.
|
||||
*/
|
||||
export type FlyerStatusDto = 'processed' | 'needs_review' | 'archived';
|
||||
|
||||
/**
|
||||
* Flyer data transfer object for API responses.
|
||||
*/
|
||||
export interface FlyerDto {
|
||||
/** Unique flyer identifier */
|
||||
readonly flyer_id: number;
|
||||
/** Original filename of the uploaded flyer */
|
||||
file_name: string;
|
||||
/** URL to the flyer image */
|
||||
image_url: string;
|
||||
/** URL to the 64x64 icon version of the flyer */
|
||||
icon_url: string;
|
||||
/** SHA-256 checksum of the original file */
|
||||
readonly checksum?: string;
|
||||
/** Legacy store ID (kept for backward compatibility) */
|
||||
readonly store_id?: number;
|
||||
/** Start date when flyer deals become valid */
|
||||
valid_from?: string | null;
|
||||
/** End date when flyer deals expire */
|
||||
valid_to?: string | null;
|
||||
/** Legacy store address field (will be deprecated) */
|
||||
store_address?: string | null;
|
||||
/** Processing status of the flyer */
|
||||
status: FlyerStatusDto;
|
||||
/** Number of items/deals in the flyer */
|
||||
item_count: number;
|
||||
/** UUID of the user who uploaded the flyer */
|
||||
readonly uploaded_by?: string | null;
|
||||
/** Legacy store relationship (single store) */
|
||||
store?: StoreDto;
|
||||
/** Store locations where this flyer is valid (many-to-many) */
|
||||
locations?: StoreLocationDto[];
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit price information for flyer items.
|
||||
*/
|
||||
export interface UnitPriceDto {
|
||||
/** Price value */
|
||||
value: number;
|
||||
/** Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'oz', 'lb', 'each') */
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flyer item data transfer object for API responses.
|
||||
*/
|
||||
export interface FlyerItemDto {
|
||||
/** Unique flyer item identifier */
|
||||
readonly flyer_item_id: number;
|
||||
/** Parent flyer identifier */
|
||||
readonly flyer_id: number;
|
||||
/** Item name/description */
|
||||
item: string;
|
||||
/** Human-readable price display (e.g., "2 for $5", "$3.99/lb") */
|
||||
price_display: string;
|
||||
/** Normalized price in cents (when calculable) */
|
||||
price_in_cents?: number | null;
|
||||
/** Quantity description */
|
||||
quantity: string;
|
||||
/** Numeric quantity value (when extractable) */
|
||||
quantity_num?: number | null;
|
||||
/** Linked master grocery item ID */
|
||||
master_item_id?: number;
|
||||
/** Master item name (if linked) */
|
||||
master_item_name?: string | null;
|
||||
/** Category ID */
|
||||
category_id?: number | null;
|
||||
/** Category name */
|
||||
category_name?: string | null;
|
||||
/** Calculated unit price */
|
||||
unit_price?: UnitPriceDto | null;
|
||||
/** Linked product ID */
|
||||
product_id?: number | null;
|
||||
/** Number of times this item has been viewed */
|
||||
readonly view_count: number;
|
||||
/** Number of times this item has been clicked */
|
||||
readonly click_count: number;
|
||||
/** Timestamp of record creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NOTIFICATION DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Notification data transfer object.
|
||||
*/
|
||||
export interface NotificationDto {
|
||||
/** Unique notification identifier */
|
||||
readonly notification_id: number;
|
||||
/** User ID this notification belongs to */
|
||||
readonly user_id: string;
|
||||
/** Notification content text */
|
||||
content: string;
|
||||
/** Optional link URL */
|
||||
link_url?: string | null;
|
||||
/** Whether notification has been read */
|
||||
is_read: boolean;
|
||||
/** Timestamp of creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MASTER GROCERY ITEM DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Master grocery item data transfer object.
|
||||
*/
|
||||
export interface MasterGroceryItemDto {
|
||||
/** Unique item identifier */
|
||||
readonly master_grocery_item_id: number;
|
||||
/** Item name */
|
||||
name: string;
|
||||
/** Category ID */
|
||||
category_id?: number | null;
|
||||
/** Category name (joined) */
|
||||
category_name?: string | null;
|
||||
/** Whether item is an allergen */
|
||||
is_allergen?: boolean;
|
||||
/** Allergy information */
|
||||
allergy_info?: unknown | null;
|
||||
/** User who created this item */
|
||||
readonly created_by?: string | null;
|
||||
/** Timestamp of creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHOPPING LIST DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Shopping list item data transfer object.
|
||||
*/
|
||||
export interface ShoppingListItemDto {
|
||||
/** Unique item identifier */
|
||||
readonly shopping_list_item_id: number;
|
||||
/** Parent shopping list ID */
|
||||
readonly shopping_list_id: number;
|
||||
/** Linked master item ID */
|
||||
readonly master_item_id?: number | null;
|
||||
/** Custom item name (if not linked to master item) */
|
||||
custom_item_name?: string | null;
|
||||
/** Item quantity */
|
||||
quantity: number;
|
||||
/** Whether item has been purchased */
|
||||
is_purchased: boolean;
|
||||
/** Notes about the item */
|
||||
notes?: string | null;
|
||||
/** Timestamp when item was added */
|
||||
readonly added_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
/** Master item data (joined) */
|
||||
master_item?: { name: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shopping list data transfer object.
|
||||
*/
|
||||
export interface ShoppingListDto {
|
||||
/** Unique list identifier */
|
||||
readonly shopping_list_id: number;
|
||||
/** User ID who owns the list */
|
||||
readonly user_id: string;
|
||||
/** List name */
|
||||
name: string;
|
||||
/** Items in the list */
|
||||
items: ShoppingListItemDto[];
|
||||
/** Timestamp of creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DIETARY RESTRICTION DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Dietary restriction data transfer object.
|
||||
*/
|
||||
export interface DietaryRestrictionDto {
|
||||
/** Unique restriction identifier */
|
||||
readonly dietary_restriction_id: number;
|
||||
/** Restriction name */
|
||||
name: string;
|
||||
/** Type (diet or allergy) */
|
||||
type: 'diet' | 'allergy';
|
||||
/** Timestamp of creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// APPLIANCE DTO
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Kitchen appliance data transfer object.
|
||||
*/
|
||||
export interface ApplianceDto {
|
||||
/** Unique appliance identifier */
|
||||
readonly appliance_id: number;
|
||||
/** Appliance name */
|
||||
name: string;
|
||||
/** Timestamp of creation */
|
||||
readonly created_at: string;
|
||||
/** Timestamp of last update */
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMMON RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple message response.
|
||||
*/
|
||||
export interface MessageResponse {
|
||||
/** Success or status message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread notification count response.
|
||||
*/
|
||||
export interface UnreadCountResponse {
|
||||
/** Number of unread notifications */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address update response.
|
||||
*/
|
||||
export interface AddressUpdateResponse {
|
||||
/** Success message */
|
||||
message: string;
|
||||
/** ID of the created/updated address */
|
||||
address_id: number;
|
||||
}
|
||||
@@ -46,8 +46,17 @@ export const useAppInitialization = () => {
|
||||
const lastSeenVersion = localStorage.getItem('lastSeenVersion');
|
||||
const onboardingCompleted = localStorage.getItem('flyer_crawler_onboarding_completed');
|
||||
|
||||
logger.info("What's New check:", {
|
||||
appVersion,
|
||||
lastSeenVersion,
|
||||
onboardingCompleted,
|
||||
versionMismatch: appVersion !== lastSeenVersion,
|
||||
shouldShow: appVersion !== lastSeenVersion && onboardingCompleted === 'true',
|
||||
});
|
||||
|
||||
// Only show "What's New" if onboarding tour has been completed
|
||||
if (appVersion !== lastSeenVersion && onboardingCompleted === 'true') {
|
||||
logger.info("Opening What's New modal");
|
||||
openModal('whatsNew');
|
||||
localStorage.setItem('lastSeenVersion', appVersion);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ export function detectApiVersion(req: Request, res: Response, next: NextFunction
|
||||
const log = req.log?.child({ middleware: 'detectApiVersion' }) ?? moduleLogger;
|
||||
|
||||
// Extract version from URL params (expects router mounted with :version param)
|
||||
const versionParam = req.params?.version;
|
||||
// Handle both string and array cases (Express types allow string[])
|
||||
const rawVersionParam = req.params?.version;
|
||||
const versionParam = Array.isArray(rawVersionParam) ? rawVersionParam[0] : rawVersionParam;
|
||||
|
||||
// If no version parameter found, this middleware was likely applied incorrectly.
|
||||
// Default to the default version and continue (allows for fallback behavior).
|
||||
|
||||
196
src/middleware/tsoaAuthentication.ts
Normal file
196
src/middleware/tsoaAuthentication.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// src/middleware/tsoaAuthentication.ts
|
||||
/**
|
||||
* @file tsoa Authentication Middleware
|
||||
*
|
||||
* Provides JWT authentication for tsoa-generated routes.
|
||||
* This middleware bridges tsoa's @Security decorators with the existing
|
||||
* Passport JWT strategy used throughout the application.
|
||||
*
|
||||
* tsoa calls this function when a controller method is decorated with:
|
||||
* - @Security('bearerAuth') - requires JWT authentication
|
||||
*
|
||||
* The security names must match those defined in tsoa.json:
|
||||
* - bearerAuth: type "http", scheme "bearer", bearerFormat "JWT"
|
||||
*
|
||||
* @see tsoa.json for security definitions
|
||||
* @see src/config/passport.ts for JWT strategy implementation
|
||||
*/
|
||||
|
||||
import { Request } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
/**
|
||||
* Custom error class for authentication failures.
|
||||
* tsoa catches errors thrown from expressAuthentication and converts them
|
||||
* to appropriate HTTP responses.
|
||||
*/
|
||||
class AuthenticationError extends Error {
|
||||
public status: number;
|
||||
|
||||
constructor(message: string, status: number = 401) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT payload structure matching the tokens issued by the application.
|
||||
* This interface defines what we expect to find in a decoded JWT.
|
||||
*/
|
||||
interface JwtPayload {
|
||||
user_id: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* tsoa Authentication Handler.
|
||||
*
|
||||
* This function is called by tsoa-generated routes when a controller method
|
||||
* is decorated with @Security('bearerAuth'). It validates the JWT token
|
||||
* and retrieves the user profile from the database.
|
||||
*
|
||||
* @param request - Express request object
|
||||
* @param securityName - Name of the security scheme (must match tsoa.json)
|
||||
* @param scopes - Optional array of required scopes (not currently used)
|
||||
* @returns Promise resolving to the authenticated UserProfile
|
||||
* @throws AuthenticationError when authentication fails
|
||||
*
|
||||
* @example
|
||||
* // In a tsoa controller:
|
||||
* @Security('bearerAuth')
|
||||
* @Get('profile')
|
||||
* public async getProfile(@Request() req: Express.Request): Promise<UserProfile> {
|
||||
* // req.user is populated by expressAuthentication
|
||||
* return req.user as UserProfile;
|
||||
* }
|
||||
*/
|
||||
export async function expressAuthentication(
|
||||
request: Request,
|
||||
securityName: string,
|
||||
_scopes?: string[],
|
||||
): Promise<UserProfile> {
|
||||
const requestLog = request.log || logger;
|
||||
|
||||
// Validate security scheme name
|
||||
if (securityName !== 'bearerAuth') {
|
||||
requestLog.error({ securityName }, '[tsoa Auth] Unknown security scheme requested');
|
||||
throw new AuthenticationError(`Unknown security scheme: ${securityName}`);
|
||||
}
|
||||
|
||||
// Extract JWT token from Authorization header
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) {
|
||||
requestLog.debug('[tsoa Auth] No Authorization header present');
|
||||
throw new AuthenticationError('No authorization header provided');
|
||||
}
|
||||
|
||||
// Validate Bearer token format
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
|
||||
requestLog.debug(
|
||||
{ authHeader: authHeader.substring(0, 20) + '...' },
|
||||
'[tsoa Auth] Invalid Authorization header format',
|
||||
);
|
||||
throw new AuthenticationError('Invalid authorization header format. Expected: Bearer <token>');
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
|
||||
// Validate JWT_SECRET is configured
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
requestLog.error('[tsoa Auth] JWT_SECRET is not configured');
|
||||
throw new AuthenticationError('Server configuration error', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify and decode the JWT
|
||||
const decoded = jwt.verify(token, jwtSecret) as JwtPayload;
|
||||
|
||||
requestLog.debug({ user_id: decoded.user_id }, '[tsoa Auth] JWT token verified successfully');
|
||||
|
||||
// Validate required claims
|
||||
if (!decoded.user_id) {
|
||||
requestLog.warn('[tsoa Auth] JWT payload missing user_id claim');
|
||||
throw new AuthenticationError('Invalid token: missing user_id');
|
||||
}
|
||||
|
||||
// Fetch user profile from database to ensure user is still valid
|
||||
const userProfile = await db.userRepo.findUserProfileById(decoded.user_id, requestLog);
|
||||
|
||||
if (!userProfile) {
|
||||
requestLog.warn({ user_id: decoded.user_id }, '[tsoa Auth] User not found in database');
|
||||
throw new AuthenticationError('User not found');
|
||||
}
|
||||
|
||||
requestLog.debug(
|
||||
{ user_id: decoded.user_id, role: userProfile.role },
|
||||
'[tsoa Auth] User authenticated successfully',
|
||||
);
|
||||
|
||||
// Attach user to request for use in controller methods
|
||||
// Note: tsoa also passes the resolved value to the controller
|
||||
request.user = userProfile;
|
||||
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
// Handle specific JWT errors
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
requestLog.debug('[tsoa Auth] JWT token expired');
|
||||
throw new AuthenticationError('Token expired');
|
||||
}
|
||||
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
requestLog.debug({ error: error.message }, '[tsoa Auth] JWT verification failed');
|
||||
throw new AuthenticationError('Invalid token');
|
||||
}
|
||||
|
||||
// Re-throw AuthenticationError as-is
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log and wrap unexpected errors
|
||||
requestLog.error({ error }, '[tsoa Auth] Unexpected error during authentication');
|
||||
throw new AuthenticationError('Authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a user object is a valid UserProfile.
|
||||
* Useful for runtime validation of req.user in controllers.
|
||||
*
|
||||
* @param user - Object to validate
|
||||
* @returns True if the object is a valid UserProfile
|
||||
*/
|
||||
export function isUserProfile(user: unknown): user is UserProfile {
|
||||
return (
|
||||
typeof user === 'object' &&
|
||||
user !== null &&
|
||||
'role' in user &&
|
||||
'user' in user &&
|
||||
typeof (user as { user: unknown }).user === 'object' &&
|
||||
(user as { user: unknown }).user !== null &&
|
||||
'user_id' in ((user as { user: unknown }).user as object)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the authenticated user has admin role.
|
||||
* For use in controller methods that require admin access.
|
||||
*
|
||||
* @param user - UserProfile from authentication
|
||||
* @throws AuthenticationError with 403 status if user is not an admin
|
||||
*/
|
||||
export function requireAdminRole(user: UserProfile): void {
|
||||
if (user.role !== 'admin') {
|
||||
throw new AuthenticationError('Forbidden: Administrator access required', 403);
|
||||
}
|
||||
}
|
||||
@@ -126,27 +126,6 @@ router.use(passport.authenticate('jwt', { session: false }), isAdmin);
|
||||
|
||||
// --- Admin Routes ---
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/corrections:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get suggested corrections
|
||||
* description: Retrieve all suggested corrections for review. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of suggested corrections
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get('/corrections', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
|
||||
@@ -157,23 +136,6 @@ router.get('/corrections', validateRequest(emptySchema), async (req, res, next:
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/review/flyers:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get flyers for review
|
||||
* description: Retrieve flyers pending admin review. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of flyers for review
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
req.log.debug('Fetching flyers for review via adminRepo');
|
||||
@@ -189,23 +151,6 @@ router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/brands:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get all brands
|
||||
* description: Retrieve all brands. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of brands
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const brands = await db.flyerRepo.getAllBrands(req.log);
|
||||
@@ -216,23 +161,6 @@ router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextF
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/stats:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get application stats
|
||||
* description: Retrieve overall application statistics. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Application statistics
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await db.adminRepo.getApplicationStats(req.log);
|
||||
@@ -243,23 +171,6 @@ router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFu
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/stats/daily:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get daily statistics
|
||||
* description: Retrieve daily statistics for the last 30 days. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Daily statistics for last 30 days
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
|
||||
@@ -270,32 +181,6 @@ router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next:
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/corrections/{id}/approve:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Approve a correction
|
||||
* description: Approve a suggested correction. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Correction ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Correction approved successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Correction not found
|
||||
*/
|
||||
router.post(
|
||||
'/corrections/:id/approve',
|
||||
validateRequest(numericIdParam('id')),
|
||||
@@ -312,32 +197,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/corrections/{id}/reject:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Reject a correction
|
||||
* description: Reject a suggested correction. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Correction ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Correction rejected successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Correction not found
|
||||
*/
|
||||
router.post(
|
||||
'/corrections/:id/reject',
|
||||
validateRequest(numericIdParam('id')),
|
||||
@@ -354,44 +213,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/corrections/{id}:
|
||||
* put:
|
||||
* tags: [Admin]
|
||||
* summary: Update a correction
|
||||
* description: Update a suggested correction's value. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Correction ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - suggested_value
|
||||
* properties:
|
||||
* suggested_value:
|
||||
* type: string
|
||||
* description: New suggested value
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Correction updated successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Correction not found
|
||||
*/
|
||||
router.put(
|
||||
'/corrections/:id',
|
||||
validateRequest(updateCorrectionSchema),
|
||||
@@ -412,44 +233,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/recipes/{id}/status:
|
||||
* put:
|
||||
* tags: [Admin]
|
||||
* summary: Update recipe status
|
||||
* description: Update a recipe's publication status. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Recipe ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [private, pending_review, public, rejected]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recipe status updated successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Recipe not found
|
||||
*/
|
||||
router.put(
|
||||
'/recipes/:id/status',
|
||||
validateRequest(updateRecipeStatusSchema),
|
||||
@@ -466,47 +249,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/brands/{id}/logo:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Upload brand logo
|
||||
* description: Upload or update a brand's logo image. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Brand ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - logoImage
|
||||
* properties:
|
||||
* logoImage:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Logo image file (max 2MB)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Brand logo updated successfully
|
||||
* 400:
|
||||
* description: Invalid file or missing logo image
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Brand not found
|
||||
*/
|
||||
router.post(
|
||||
'/brands/:id/logo',
|
||||
adminUploadLimiter,
|
||||
@@ -539,23 +281,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/unmatched-items:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get unmatched flyer items
|
||||
* description: Retrieve flyer items that couldn't be matched to master items. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of unmatched flyer items
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get(
|
||||
'/unmatched-items',
|
||||
validateRequest(emptySchema),
|
||||
@@ -570,32 +295,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/recipes/{recipeId}:
|
||||
* delete:
|
||||
* tags: [Admin]
|
||||
* summary: Delete a recipe
|
||||
* description: Admin endpoint to delete any recipe. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: recipeId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Recipe ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Recipe deleted successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Recipe not found
|
||||
*/
|
||||
router.delete(
|
||||
'/recipes/:recipeId',
|
||||
validateRequest(numericIdParam('recipeId')),
|
||||
@@ -614,32 +313,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/flyers/{flyerId}:
|
||||
* delete:
|
||||
* tags: [Admin]
|
||||
* summary: Delete a flyer
|
||||
* description: Admin endpoint to delete a flyer and its items. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: flyerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Flyer ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Flyer deleted successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Flyer not found
|
||||
*/
|
||||
router.delete(
|
||||
'/flyers/:flyerId',
|
||||
validateRequest(numericIdParam('flyerId')),
|
||||
@@ -656,44 +329,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/comments/{id}/status:
|
||||
* put:
|
||||
* tags: [Admin]
|
||||
* summary: Update comment status
|
||||
* description: Update a recipe comment's visibility status. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Comment ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [visible, hidden, reported]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Comment status updated successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Comment not found
|
||||
*/
|
||||
router.put(
|
||||
'/comments/:id/status',
|
||||
validateRequest(updateCommentStatusSchema),
|
||||
@@ -714,36 +349,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/users:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get all users
|
||||
* description: Retrieve a list of all users with optional pagination. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* maximum: 100
|
||||
* description: Maximum number of users to return. If omitted, returns all users.
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* description: Number of users to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of users with total count
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get('/users', validateRequest(usersListSchema), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const { limit, offset } = usersListSchema.shape.query.parse(req.query);
|
||||
@@ -755,36 +360,6 @@ router.get('/users', validateRequest(usersListSchema), async (req, res, next: Ne
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/activity-log:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get activity log
|
||||
* description: Retrieve system activity log with pagination. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* description: Maximum number of entries to return
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* description: Number of entries to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Activity log entries
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get(
|
||||
'/activity-log',
|
||||
validateRequest(activityLogSchema),
|
||||
@@ -803,33 +378,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/users/{id}:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get user by ID
|
||||
* description: Retrieve a specific user's profile. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: User ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User profile
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: User not found
|
||||
*/
|
||||
router.get(
|
||||
'/users/:id',
|
||||
validateRequest(uuidParamSchema('id', 'A valid user ID is required.')),
|
||||
@@ -846,45 +394,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/users/{id}:
|
||||
* put:
|
||||
* tags: [Admin]
|
||||
* summary: Update user role
|
||||
* description: Update a user's role. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: User ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - role
|
||||
* properties:
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [user, admin]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User role updated successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: User not found
|
||||
*/
|
||||
router.put(
|
||||
'/users/:id',
|
||||
validateRequest(updateUserRoleSchema),
|
||||
@@ -901,33 +410,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/users/{id}:
|
||||
* delete:
|
||||
* tags: [Admin]
|
||||
* summary: Delete a user
|
||||
* description: Delete a user account. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: User ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: User deleted successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: User not found
|
||||
*/
|
||||
router.delete(
|
||||
'/users/:id',
|
||||
validateRequest(uuidParamSchema('id', 'A valid user ID is required.')),
|
||||
@@ -945,23 +427,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/trigger/daily-deal-check:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Trigger daily deal check
|
||||
* description: Manually trigger the daily deal check job. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Job triggered successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.post(
|
||||
'/trigger/daily-deal-check',
|
||||
adminTriggerLimiter,
|
||||
@@ -991,23 +456,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/trigger/analytics-report:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Trigger analytics report
|
||||
* description: Manually enqueue a job to generate the daily analytics report. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Job enqueued successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.post(
|
||||
'/trigger/analytics-report',
|
||||
adminTriggerLimiter,
|
||||
@@ -1034,32 +482,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/flyers/{flyerId}/cleanup:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Trigger flyer file cleanup
|
||||
* description: Enqueue a job to clean up a flyer's files. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: flyerId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Flyer ID
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Cleanup job enqueued successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Flyer not found
|
||||
*/
|
||||
router.post(
|
||||
'/flyers/:flyerId/cleanup',
|
||||
adminTriggerLimiter,
|
||||
@@ -1087,23 +509,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/trigger/failing-job:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Trigger failing test job
|
||||
* description: Enqueue a test job designed to fail for testing retry mechanisms. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Failing test job enqueued successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.post(
|
||||
'/trigger/failing-job',
|
||||
adminTriggerLimiter,
|
||||
@@ -1129,23 +534,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/system/clear-geocode-cache:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Clear geocode cache
|
||||
* description: Clears the Redis cache for geocoded addresses. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Cache cleared successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.post(
|
||||
'/system/clear-geocode-cache',
|
||||
adminTriggerLimiter,
|
||||
@@ -1168,23 +556,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/workers/status:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get worker statuses
|
||||
* description: Get the current running status of all BullMQ workers. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Worker status information
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get(
|
||||
'/workers/status',
|
||||
validateRequest(emptySchema),
|
||||
@@ -1199,23 +570,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/queues/status:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get queue statuses
|
||||
* description: Get job counts for all BullMQ queues. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Queue status information
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get(
|
||||
'/queues/status',
|
||||
validateRequest(emptySchema),
|
||||
@@ -1230,45 +584,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/feature-flags:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get feature flags status
|
||||
* description: Get the current state of all feature flags. Requires admin role. (ADR-024)
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Feature flags and their current states
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* flags:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: boolean
|
||||
* example:
|
||||
* bugsinkSync: false
|
||||
* advancedRbac: false
|
||||
* newDashboard: true
|
||||
* betaRecipes: false
|
||||
* experimentalAi: false
|
||||
* debugMode: false
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get(
|
||||
'/feature-flags',
|
||||
validateRequest(emptySchema),
|
||||
@@ -1283,39 +598,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/websocket/stats:
|
||||
* get:
|
||||
* tags: [Admin]
|
||||
* summary: Get WebSocket connection statistics
|
||||
* description: Get real-time WebSocket connection stats including total users and connections. Requires admin role. (ADR-022)
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: WebSocket connection statistics
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* totalUsers:
|
||||
* type: number
|
||||
* description: Number of unique users with active connections
|
||||
* totalConnections:
|
||||
* type: number
|
||||
* description: Total number of active WebSocket connections
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.get(
|
||||
'/websocket/stats',
|
||||
validateRequest(emptySchema),
|
||||
@@ -1331,39 +613,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/jobs/{queueName}/{jobId}/retry:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Retry a failed job
|
||||
* description: Retries a specific failed job in a queue. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: queueName
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [flyer-processing, email-sending, analytics-reporting, file-cleanup, weekly-analytics-reporting]
|
||||
* description: Queue name
|
||||
* - in: path
|
||||
* name: jobId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Job ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Job marked for retry successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
* 404:
|
||||
* description: Job not found
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:queueName/:jobId/retry',
|
||||
adminTriggerLimiter,
|
||||
@@ -1384,23 +633,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/trigger/weekly-analytics:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Trigger weekly analytics
|
||||
* description: Manually trigger the weekly analytics report job. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Job enqueued successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.post(
|
||||
'/trigger/weekly-analytics',
|
||||
adminTriggerLimiter,
|
||||
@@ -1421,23 +653,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/trigger/token-cleanup:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Trigger token cleanup
|
||||
* description: Manually trigger the expired token cleanup job. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Job enqueued successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.post(
|
||||
'/trigger/token-cleanup',
|
||||
adminTriggerLimiter,
|
||||
@@ -1458,23 +673,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /admin/system/clear-cache:
|
||||
* post:
|
||||
* tags: [Admin]
|
||||
* summary: Clear application cache
|
||||
* description: Clears cached flyers, brands, and stats data from Redis. Requires admin role.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Cache cleared successfully with details
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden - admin role required
|
||||
*/
|
||||
router.post(
|
||||
'/system/clear-cache',
|
||||
adminTriggerLimiter,
|
||||
|
||||
@@ -180,43 +180,6 @@ router.use((req: Request, res: Response, next: NextFunction) => {
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/upload-and-process:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Upload and process flyer
|
||||
* description: Accepts a single flyer file (PDF or image), enqueues it for background processing, and immediately returns a job ID.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - flyerFile
|
||||
* - checksum
|
||||
* properties:
|
||||
* flyerFile:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Flyer file (PDF or image)
|
||||
* checksum:
|
||||
* type: string
|
||||
* pattern: ^[a-f0-9]{64}$
|
||||
* description: SHA-256 checksum of the file
|
||||
* baseUrl:
|
||||
* type: string
|
||||
* format: uri
|
||||
* description: Optional base URL
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Flyer accepted for processing
|
||||
* 400:
|
||||
* description: Missing file or invalid checksum
|
||||
* 409:
|
||||
* description: Duplicate flyer detected
|
||||
*/
|
||||
router.post(
|
||||
'/upload-and-process',
|
||||
aiUploadLimiter,
|
||||
@@ -282,39 +245,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/upload-legacy:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Legacy flyer upload (deprecated)
|
||||
* description: Process a flyer upload synchronously. Deprecated - use /upload-and-process instead.
|
||||
* deprecated: true
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - flyerFile
|
||||
* properties:
|
||||
* flyerFile:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Flyer file (PDF or image)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Flyer processed successfully
|
||||
* 400:
|
||||
* description: No flyer file uploaded
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 409:
|
||||
* description: Duplicate flyer detected
|
||||
*/
|
||||
router.post(
|
||||
'/upload-legacy',
|
||||
aiUploadLimiter,
|
||||
@@ -344,26 +274,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/jobs/{jobId}/status:
|
||||
* get:
|
||||
* tags: [AI]
|
||||
* summary: Check job status
|
||||
* description: Checks the status of a background flyer processing job.
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: jobId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Job ID returned from upload-and-process
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Job status information
|
||||
* 404:
|
||||
* description: Job not found
|
||||
*/
|
||||
router.get(
|
||||
'/jobs/:jobId/status',
|
||||
validateRequest(jobIdParamSchema),
|
||||
@@ -383,35 +293,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/flyers/process:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Process flyer data (deprecated)
|
||||
* description: Saves processed flyer data to the database. Deprecated - use /upload-and-process instead.
|
||||
* deprecated: true
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - flyerImage
|
||||
* properties:
|
||||
* flyerImage:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Flyer image file
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Flyer processed and saved successfully
|
||||
* 400:
|
||||
* description: Flyer image file is required
|
||||
* 409:
|
||||
* description: Duplicate flyer detected
|
||||
*/
|
||||
router.post(
|
||||
'/flyers/process',
|
||||
aiUploadLimiter,
|
||||
@@ -448,32 +329,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/check-flyer:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Check if image is a flyer
|
||||
* description: Analyzes an image to determine if it's a grocery store flyer.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - image
|
||||
* properties:
|
||||
* image:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Image file to check
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Flyer check result
|
||||
* 400:
|
||||
* description: Image file is required
|
||||
*/
|
||||
router.post(
|
||||
'/check-flyer',
|
||||
aiUploadLimiter,
|
||||
@@ -494,32 +349,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/extract-address:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Extract address from image
|
||||
* description: Extracts store address information from a flyer image.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - image
|
||||
* properties:
|
||||
* image:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Image file to extract address from
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Extracted address information
|
||||
* 400:
|
||||
* description: Image file is required
|
||||
*/
|
||||
router.post(
|
||||
'/extract-address',
|
||||
aiUploadLimiter,
|
||||
@@ -540,34 +369,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/extract-logo:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Extract store logo
|
||||
* description: Extracts store logo from flyer images.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - images
|
||||
* properties:
|
||||
* images:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Image files to extract logo from
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Extracted logo as base64
|
||||
* 400:
|
||||
* description: Image files are required
|
||||
*/
|
||||
router.post(
|
||||
'/extract-logo',
|
||||
aiUploadLimiter,
|
||||
@@ -588,36 +389,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/quick-insights:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Get quick insights
|
||||
* description: Get AI-generated quick insights about flyer items.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - items
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* minItems: 1
|
||||
* description: List of flyer items to analyze
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AI-generated quick insights
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/quick-insights',
|
||||
aiGenerationLimiter,
|
||||
@@ -633,36 +404,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/deep-dive:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Get deep dive analysis
|
||||
* description: Get detailed AI-generated analysis about flyer items.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - items
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* minItems: 1
|
||||
* description: List of flyer items to analyze
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Detailed AI analysis
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/deep-dive',
|
||||
aiGenerationLimiter,
|
||||
@@ -680,33 +421,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/search-web:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Search web for information
|
||||
* description: Search the web for product or deal information.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - query
|
||||
* properties:
|
||||
* query:
|
||||
* type: string
|
||||
* description: Search query
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Search results with sources
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/search-web',
|
||||
aiGenerationLimiter,
|
||||
@@ -722,36 +436,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/compare-prices:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Compare prices across stores
|
||||
* description: Compare prices for items across different stores.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - items
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* minItems: 1
|
||||
* description: List of items to compare
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Price comparison results
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/compare-prices',
|
||||
aiGenerationLimiter,
|
||||
@@ -771,59 +455,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/plan-trip:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Plan shopping trip
|
||||
* description: Plan an optimized shopping trip to a store based on items and location.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - items
|
||||
* - store
|
||||
* - userLocation
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* description: List of items to buy
|
||||
* store:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Store name
|
||||
* userLocation:
|
||||
* type: object
|
||||
* required:
|
||||
* - latitude
|
||||
* - longitude
|
||||
* properties:
|
||||
* latitude:
|
||||
* type: number
|
||||
* minimum: -90
|
||||
* maximum: 90
|
||||
* longitude:
|
||||
* type: number
|
||||
* minimum: -180
|
||||
* maximum: 180
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Trip plan with directions
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/plan-trip',
|
||||
aiGenerationLimiter,
|
||||
@@ -844,33 +475,6 @@ router.post(
|
||||
|
||||
// --- STUBBED AI Routes for Future Features ---
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/generate-image:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Generate image (not implemented)
|
||||
* description: Generate an image from a prompt. Currently not implemented.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - prompt
|
||||
* properties:
|
||||
* prompt:
|
||||
* type: string
|
||||
* description: Image generation prompt
|
||||
* responses:
|
||||
* 501:
|
||||
* description: Not implemented
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/generate-image',
|
||||
aiGenerationLimiter,
|
||||
@@ -884,33 +488,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/generate-speech:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Generate speech (not implemented)
|
||||
* description: Generate speech from text. Currently not implemented.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - text
|
||||
* properties:
|
||||
* text:
|
||||
* type: string
|
||||
* description: Text to convert to speech
|
||||
* responses:
|
||||
* 501:
|
||||
* description: Not implemented
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/generate-speech',
|
||||
aiGenerationLimiter,
|
||||
@@ -924,45 +501,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/rescan-area:
|
||||
* post:
|
||||
* tags: [AI]
|
||||
* summary: Rescan area of image
|
||||
* description: Performs a targeted AI scan on a specific area of an image.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - image
|
||||
* - cropArea
|
||||
* - extractionType
|
||||
* properties:
|
||||
* image:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Image file to scan
|
||||
* cropArea:
|
||||
* type: string
|
||||
* description: JSON string with x, y, width, height
|
||||
* extractionType:
|
||||
* type: string
|
||||
* enum: [store_name, dates, item_details]
|
||||
* description: Type of data to extract
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Extracted data from image area
|
||||
* 400:
|
||||
* description: Image file is required
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/rescan-area',
|
||||
aiUploadLimiter,
|
||||
|
||||
@@ -79,68 +79,6 @@ const resetPasswordSchema = z.object({
|
||||
|
||||
// --- Authentication Routes ---
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /auth/register:
|
||||
* post:
|
||||
* summary: Register a new user
|
||||
* description: Creates a new user account and returns authentication tokens.
|
||||
* tags:
|
||||
* - Auth
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: user@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* minLength: 8
|
||||
* description: Must be at least 8 characters with good entropy
|
||||
* full_name:
|
||||
* type: string
|
||||
* example: John Doe
|
||||
* avatar_url:
|
||||
* type: string
|
||||
* format: uri
|
||||
* responses:
|
||||
* 201:
|
||||
* description: User registered successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: User registered successfully!
|
||||
* userprofile:
|
||||
* type: object
|
||||
* token:
|
||||
* type: string
|
||||
* description: JWT access token
|
||||
* 409:
|
||||
* description: Email already registered
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.post(
|
||||
'/register',
|
||||
registerLimiter,
|
||||
@@ -186,60 +124,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /auth/login:
|
||||
* post:
|
||||
* summary: Login with email and password
|
||||
* description: Authenticates user credentials and returns JWT tokens.
|
||||
* tags:
|
||||
* - Auth
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: user@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* rememberMe:
|
||||
* type: boolean
|
||||
* description: If true, refresh token lasts 30 days
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* userprofile:
|
||||
* type: object
|
||||
* token:
|
||||
* type: string
|
||||
* description: JWT access token
|
||||
* 401:
|
||||
* description: Invalid credentials
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.post(
|
||||
'/login',
|
||||
loginLimiter,
|
||||
@@ -295,45 +179,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /auth/forgot-password:
|
||||
* post:
|
||||
* summary: Request password reset
|
||||
* description: Sends a password reset email if the account exists. Always returns success to prevent email enumeration.
|
||||
* tags:
|
||||
* - Auth
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: user@example.com
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Request processed (email sent if account exists)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: If an account with that email exists, a password reset link has been sent.
|
||||
*/
|
||||
router.post(
|
||||
'/forgot-password',
|
||||
forgotPasswordLimiter,
|
||||
@@ -361,41 +206,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /auth/reset-password:
|
||||
* post:
|
||||
* summary: Reset password with token
|
||||
* description: Resets the user's password using a valid reset token from the forgot-password email.
|
||||
* tags:
|
||||
* - Auth
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - token
|
||||
* - newPassword
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: Password reset token from email
|
||||
* newPassword:
|
||||
* type: string
|
||||
* format: password
|
||||
* minLength: 8
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Password reset successful
|
||||
* 400:
|
||||
* description: Invalid or expired token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.post(
|
||||
'/reset-password',
|
||||
resetPasswordLimiter,
|
||||
@@ -426,36 +236,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /auth/refresh-token:
|
||||
* post:
|
||||
* summary: Refresh access token
|
||||
* description: Uses the refresh token cookie to issue a new access token.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New access token issued
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: New JWT access token
|
||||
* 401:
|
||||
* description: Refresh token not found
|
||||
* 403:
|
||||
* description: Invalid or expired refresh token
|
||||
*/
|
||||
router.post(
|
||||
'/refresh-token',
|
||||
refreshTokenLimiter,
|
||||
@@ -478,32 +258,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /auth/logout:
|
||||
* post:
|
||||
* summary: Logout user
|
||||
* description: Invalidates the refresh token and clears the cookie.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Logged out successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Logged out successfully.
|
||||
*/
|
||||
router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (refreshToken) {
|
||||
@@ -524,29 +278,6 @@ router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
|
||||
|
||||
// --- OAuth Routes ---
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /auth/google:
|
||||
* get:
|
||||
* summary: Initiate Google OAuth
|
||||
* description: Redirects to Google for authentication. After success, redirects back to the app with a token.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Redirects to Google OAuth consent screen
|
||||
*
|
||||
* /auth/github:
|
||||
* get:
|
||||
* summary: Initiate GitHub OAuth
|
||||
* description: Redirects to GitHub for authentication. After success, redirects back to the app with a token.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Redirects to GitHub OAuth consent screen
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles the OAuth callback after successful authentication.
|
||||
* Generates tokens and redirects to the frontend with the access token.
|
||||
|
||||
@@ -45,25 +45,6 @@ router.use(passport.authenticate('jwt', { session: false }));
|
||||
// Apply rate limiting to all subsequent budget routes
|
||||
router.use(budgetUpdateLimiter);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /budgets:
|
||||
* get:
|
||||
* tags: [Budgets]
|
||||
* summary: Get all budgets
|
||||
* description: Retrieve all budgets for the authenticated user.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of user budgets
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
@@ -75,54 +56,6 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /budgets:
|
||||
* post:
|
||||
* tags: [Budgets]
|
||||
* summary: Create budget
|
||||
* description: Create a new budget for the authenticated user.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - amount_cents
|
||||
* - period
|
||||
* - start_date
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Budget name
|
||||
* amount_cents:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: Budget amount in cents
|
||||
* period:
|
||||
* type: string
|
||||
* enum: [weekly, monthly]
|
||||
* description: Budget period
|
||||
* start_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: Budget start date (YYYY-MM-DD)
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Budget created
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest(createBudgetSchema),
|
||||
@@ -140,58 +73,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /budgets/{id}:
|
||||
* put:
|
||||
* tags: [Budgets]
|
||||
* summary: Update budget
|
||||
* description: Update an existing budget.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Budget ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Budget name
|
||||
* amount_cents:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: Budget amount in cents
|
||||
* period:
|
||||
* type: string
|
||||
* enum: [weekly, monthly]
|
||||
* description: Budget period
|
||||
* start_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: Budget start date (YYYY-MM-DD)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Budget updated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Validation error - at least one field required
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
* 404:
|
||||
* description: Budget not found
|
||||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
validateRequest(updateBudgetSchema),
|
||||
@@ -217,30 +98,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /budgets/{id}:
|
||||
* delete:
|
||||
* tags: [Budgets]
|
||||
* summary: Delete budget
|
||||
* description: Delete a budget by ID.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Budget ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Budget deleted
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
* 404:
|
||||
* description: Budget not found
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateRequest(budgetIdParamSchema),
|
||||
@@ -261,42 +118,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /budgets/spending-analysis:
|
||||
* get:
|
||||
* tags: [Budgets]
|
||||
* summary: Get spending analysis
|
||||
* description: Get spending breakdown by category for a date range.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: startDate
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: Start date (YYYY-MM-DD)
|
||||
* - in: query
|
||||
* name: endDate
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: End date (YYYY-MM-DD)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Spending breakdown by category
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Invalid date format
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get(
|
||||
'/spending-analysis',
|
||||
validateRequest(spendingAnalysisSchema),
|
||||
|
||||
@@ -165,7 +165,8 @@ router.get('/lookup', async (req: Request, res: Response, next: NextFunction) =>
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const categoryId = parseInt(req.params.id, 10);
|
||||
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const categoryId = parseInt(idParam, 10);
|
||||
|
||||
if (isNaN(categoryId) || categoryId <= 0) {
|
||||
return res.status(400).json({
|
||||
|
||||
@@ -22,25 +22,6 @@ const bestWatchedPricesSchema = z.object({
|
||||
// We apply the requireAuth middleware which returns standardized 401 responses per ADR-028.
|
||||
router.use(requireAuth);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /deals/best-watched-prices:
|
||||
* get:
|
||||
* tags: [Deals]
|
||||
* summary: Get best prices for watched items
|
||||
* description: Fetches the best current sale price for each of the authenticated user's watched items.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of best prices for watched items
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get(
|
||||
'/best-watched-prices',
|
||||
userReadLimiter,
|
||||
|
||||
@@ -47,56 +47,6 @@ const trackItemSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /flyers:
|
||||
* get:
|
||||
* summary: Get all flyers
|
||||
* description: Returns a paginated list of all flyers.
|
||||
* tags:
|
||||
* - Flyers
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* description: Maximum number of flyers to return
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* description: Number of flyers to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of flyers
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* flyer_id:
|
||||
* type: integer
|
||||
* store_id:
|
||||
* type: integer
|
||||
* flyer_name:
|
||||
* type: string
|
||||
* start_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* end_date:
|
||||
* type: string
|
||||
* format: date
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
publicReadLimiter,
|
||||
@@ -116,27 +66,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /flyers/{id}:
|
||||
* get:
|
||||
* summary: Get flyer by ID
|
||||
* description: Returns a single flyer by its ID.
|
||||
* tags:
|
||||
* - Flyers
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The flyer ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Flyer details
|
||||
* 404:
|
||||
* description: Flyer not found
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
publicReadLimiter,
|
||||
@@ -154,46 +83,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /flyers/{id}/items:
|
||||
* get:
|
||||
* summary: Get flyer items
|
||||
* description: Returns all items (deals) for a specific flyer.
|
||||
* tags:
|
||||
* - Flyers
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The flyer ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of flyer items
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* item_id:
|
||||
* type: integer
|
||||
* item_name:
|
||||
* type: string
|
||||
* price:
|
||||
* type: number
|
||||
* unit:
|
||||
* type: string
|
||||
*/
|
||||
router.get(
|
||||
'/:id/items',
|
||||
publicReadLimiter,
|
||||
@@ -214,33 +103,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /flyers/items/batch-fetch:
|
||||
* post:
|
||||
* summary: Batch fetch flyer items
|
||||
* description: Returns all items for multiple flyers in a single request.
|
||||
* tags:
|
||||
* - Flyers
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - flyerIds
|
||||
* properties:
|
||||
* flyerIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* minItems: 1
|
||||
* example: [1, 2, 3]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Items for all requested flyers
|
||||
*/
|
||||
type BatchFetchRequest = z.infer<typeof batchFetchSchema>;
|
||||
router.post(
|
||||
'/items/batch-fetch',
|
||||
@@ -260,46 +122,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /flyers/items/batch-count:
|
||||
* post:
|
||||
* summary: Batch count flyer items
|
||||
* description: Returns the total item count for multiple flyers.
|
||||
* tags:
|
||||
* - Flyers
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - flyerIds
|
||||
* properties:
|
||||
* flyerIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* example: [1, 2, 3]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Total item count
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* count:
|
||||
* type: integer
|
||||
* example: 42
|
||||
*/
|
||||
type BatchCountRequest = z.infer<typeof batchCountSchema>;
|
||||
router.post(
|
||||
'/items/batch-count',
|
||||
@@ -319,52 +141,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /flyers/items/{itemId}/track:
|
||||
* post:
|
||||
* summary: Track item interaction
|
||||
* description: Records a view or click interaction with a flyer item for analytics.
|
||||
* tags:
|
||||
* - Flyers
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: itemId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The flyer item ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - type
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [view, click]
|
||||
* description: Type of interaction
|
||||
* responses:
|
||||
* 202:
|
||||
* description: Tracking accepted (fire-and-forget)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Tracking accepted
|
||||
*/
|
||||
router.post(
|
||||
'/items/:itemId/track',
|
||||
trackingLimiter,
|
||||
|
||||
@@ -38,30 +38,6 @@ const awardAchievementSchema = z.object({
|
||||
|
||||
// --- Public Routes ---
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /achievements:
|
||||
* get:
|
||||
* summary: Get all achievements
|
||||
* description: Returns the master list of all available achievements in the system. This is a public endpoint.
|
||||
* tags:
|
||||
* - Achievements
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of all achievements
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Achievement'
|
||||
*/
|
||||
router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const achievements = await gamificationService.getAllAchievements(req.log);
|
||||
@@ -72,39 +48,6 @@ router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /achievements/leaderboard:
|
||||
* get:
|
||||
* summary: Get leaderboard
|
||||
* description: Returns the top users ranked by total points earned from achievements. This is a public endpoint.
|
||||
* tags:
|
||||
* - Achievements
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 50
|
||||
* default: 10
|
||||
* description: Maximum number of users to return
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Leaderboard entries
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/LeaderboardUser'
|
||||
*/
|
||||
router.get(
|
||||
'/leaderboard',
|
||||
publicReadLimiter,
|
||||
@@ -125,38 +68,6 @@ router.get(
|
||||
|
||||
// --- Authenticated User Routes ---
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /achievements/me:
|
||||
* get:
|
||||
* summary: Get my achievements
|
||||
* description: Returns all achievements earned by the authenticated user.
|
||||
* tags:
|
||||
* - Achievements
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of user's earned achievements
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/UserAchievement'
|
||||
* 401:
|
||||
* description: Unauthorized - JWT token missing or invalid
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/me',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
@@ -184,57 +95,6 @@ router.get(
|
||||
// Apply authentication and admin-check middleware to the entire admin sub-router.
|
||||
adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), isAdmin);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /achievements/award:
|
||||
* post:
|
||||
* summary: Award achievement to user (Admin only)
|
||||
* description: Manually award an achievement to a specific user. Requires admin role.
|
||||
* tags:
|
||||
* - Achievements
|
||||
* - Admin
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - userId
|
||||
* - achievementName
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: The user ID to award the achievement to
|
||||
* achievementName:
|
||||
* type: string
|
||||
* description: The name of the achievement to award
|
||||
* example: First-Upload
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Achievement awarded successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: Successfully awarded 'First-Upload' to user abc123.
|
||||
* 401:
|
||||
* description: Unauthorized - JWT token missing or invalid
|
||||
* 403:
|
||||
* description: Forbidden - User is not an admin
|
||||
*/
|
||||
adminGamificationRouter.post(
|
||||
'/award',
|
||||
adminTriggerLimiter,
|
||||
|
||||
@@ -137,32 +137,6 @@ async function checkStorage(): Promise<ServiceHealth> {
|
||||
// to maintain a consistent validation pattern across the application.
|
||||
const emptySchema = z.object({});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* description: Returns a pong response to verify server is responsive. Use this for basic connectivity checks.
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: pong
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
return sendSuccess(res, { message: 'pong' });
|
||||
});
|
||||
@@ -171,38 +145,6 @@ router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response)
|
||||
// KUBERNETES PROBES (ADR-020)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /health/live:
|
||||
* get:
|
||||
* summary: Liveness probe
|
||||
* description: |
|
||||
* Returns 200 OK if the server process is running.
|
||||
* If this fails, the orchestrator should restart the container.
|
||||
* This endpoint is intentionally simple and has no external dependencies.
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server process is alive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
*/
|
||||
router.get('/live', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
return sendSuccess(res, {
|
||||
status: 'ok',
|
||||
@@ -210,56 +152,6 @@ router.get('/live', validateRequest(emptySchema), (_req: Request, res: Response)
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ready:
|
||||
* get:
|
||||
* summary: Readiness probe
|
||||
* description: |
|
||||
* Returns 200 OK if the server is ready to accept traffic.
|
||||
* Checks all critical dependencies (database, Redis, storage).
|
||||
* If this fails, the orchestrator should remove the container from the load balancer.
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is ready to accept traffic
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [healthy, degraded, unhealthy]
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* uptime:
|
||||
* type: number
|
||||
* description: Server uptime in seconds
|
||||
* services:
|
||||
* type: object
|
||||
* properties:
|
||||
* database:
|
||||
* $ref: '#/components/schemas/ServiceHealth'
|
||||
* redis:
|
||||
* $ref: '#/components/schemas/ServiceHealth'
|
||||
* storage:
|
||||
* $ref: '#/components/schemas/ServiceHealth'
|
||||
* 503:
|
||||
* description: Service is unhealthy and should not receive traffic
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.get('/ready', validateRequest(emptySchema), async (req: Request, res: Response) => {
|
||||
// Check all services in parallel for speed
|
||||
const [database, redis, storage] = await Promise.all([
|
||||
@@ -457,71 +349,6 @@ router.get(
|
||||
// QUEUE HEALTH MONITORING (ADR-053)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /health/queues:
|
||||
* get:
|
||||
* summary: Queue health and metrics with worker heartbeats
|
||||
* description: |
|
||||
* Returns job counts for all BullMQ queues and worker heartbeat status.
|
||||
* Use this endpoint to monitor queue depths and detect stuck/frozen workers.
|
||||
* Implements ADR-053: Worker Health Checks and Stalled Job Monitoring.
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Queue metrics and worker heartbeats retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [healthy, unhealthy]
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* queues:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: object
|
||||
* properties:
|
||||
* waiting:
|
||||
* type: integer
|
||||
* active:
|
||||
* type: integer
|
||||
* failed:
|
||||
* type: integer
|
||||
* delayed:
|
||||
* type: integer
|
||||
* workers:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: object
|
||||
* properties:
|
||||
* alive:
|
||||
* type: boolean
|
||||
* lastSeen:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* pid:
|
||||
* type: integer
|
||||
* host:
|
||||
* type: string
|
||||
* 503:
|
||||
* description: Redis unavailable or workers not responding
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/queues',
|
||||
validateRequest(emptySchema),
|
||||
|
||||
@@ -137,68 +137,6 @@ router.use(passport.authenticate('jwt', { session: false }));
|
||||
// INVENTORY ITEM ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory:
|
||||
* get:
|
||||
* tags: [Inventory]
|
||||
* summary: Get inventory items
|
||||
* description: Retrieve the user's pantry inventory with optional filtering and pagination.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 50
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* - in: query
|
||||
* name: location
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [fridge, freezer, pantry, room_temp]
|
||||
* - in: query
|
||||
* name: is_consumed
|
||||
* schema:
|
||||
* type: boolean
|
||||
* - in: query
|
||||
* name: expiring_within_days
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* - in: query
|
||||
* name: category_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: search
|
||||
* schema:
|
||||
* type: string
|
||||
* maxLength: 100
|
||||
* - in: query
|
||||
* name: sort_by
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [expiry_date, purchase_date, item_name, created_at]
|
||||
* - in: query
|
||||
* name: sort_order
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Inventory items retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
validateRequest(inventoryQuerySchema),
|
||||
@@ -231,62 +169,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory:
|
||||
* post:
|
||||
* tags: [Inventory]
|
||||
* summary: Add inventory item
|
||||
* description: Add a new item to the user's pantry inventory.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - item_name
|
||||
* - source
|
||||
* properties:
|
||||
* product_id:
|
||||
* type: integer
|
||||
* master_item_id:
|
||||
* type: integer
|
||||
* item_name:
|
||||
* type: string
|
||||
* maxLength: 255
|
||||
* quantity:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* default: 1
|
||||
* unit:
|
||||
* type: string
|
||||
* maxLength: 50
|
||||
* purchase_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* expiry_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* source:
|
||||
* type: string
|
||||
* enum: [manual, receipt_scan, upc_scan]
|
||||
* location:
|
||||
* type: string
|
||||
* enum: [fridge, freezer, pantry, room_temp]
|
||||
* notes:
|
||||
* type: string
|
||||
* maxLength: 500
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Item added to inventory
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest(addInventoryItemSchema),
|
||||
@@ -318,47 +200,6 @@ router.post(
|
||||
// NOTE: These routes MUST be defined BEFORE /:inventoryId to avoid path conflicts
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/expiring/summary:
|
||||
* get:
|
||||
* tags: [Inventory]
|
||||
* summary: Get expiring items summary
|
||||
* description: Get items grouped by expiry urgency (today, this week, this month, expired).
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Expiring items grouped by urgency
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* expiring_today:
|
||||
* type: array
|
||||
* expiring_this_week:
|
||||
* type: array
|
||||
* expiring_this_month:
|
||||
* type: array
|
||||
* already_expired:
|
||||
* type: array
|
||||
* counts:
|
||||
* type: object
|
||||
* properties:
|
||||
* today:
|
||||
* type: integer
|
||||
* this_week:
|
||||
* type: integer
|
||||
* this_month:
|
||||
* type: integer
|
||||
* expired:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get('/expiring/summary', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
@@ -374,30 +215,6 @@ router.get('/expiring/summary', async (req: Request, res: Response, next: NextFu
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/expiring:
|
||||
* get:
|
||||
* tags: [Inventory]
|
||||
* summary: Get expiring items
|
||||
* description: Get items expiring within a specified number of days.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: days
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 90
|
||||
* default: 7
|
||||
* description: Number of days to look ahead
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Expiring items retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get(
|
||||
'/expiring',
|
||||
validateRequest(daysAheadQuerySchema),
|
||||
@@ -420,21 +237,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/expired:
|
||||
* get:
|
||||
* tags: [Inventory]
|
||||
* summary: Get expired items
|
||||
* description: Get all items that have already expired.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Expired items retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get('/expired', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
@@ -452,21 +254,6 @@ router.get('/expired', async (req: Request, res: Response, next: NextFunction) =
|
||||
// NOTE: These routes MUST be defined BEFORE /:inventoryId to avoid path conflicts
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/alerts:
|
||||
* get:
|
||||
* tags: [Inventory]
|
||||
* summary: Get alert settings
|
||||
* description: Get the user's expiry alert settings.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert settings retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get('/alerts', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
@@ -479,43 +266,6 @@ router.get('/alerts', async (req: Request, res: Response, next: NextFunction) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/alerts/{alertMethod}:
|
||||
* put:
|
||||
* tags: [Inventory]
|
||||
* summary: Update alert settings
|
||||
* description: Update alert settings for a specific notification method.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: alertMethod
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [email, push, in_app]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* days_before_expiry:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 30
|
||||
* is_enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert settings updated
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.put(
|
||||
'/alerts/:alertMethod',
|
||||
validateRequest(updateAlertSettingsSchema),
|
||||
@@ -547,43 +297,6 @@ router.put(
|
||||
// NOTE: This route MUST be defined BEFORE /:inventoryId to avoid path conflicts
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/recipes/suggestions:
|
||||
* get:
|
||||
* tags: [Inventory]
|
||||
* summary: Get recipe suggestions for expiring items
|
||||
* description: Get recipes that use items expiring soon to reduce food waste.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: days
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 90
|
||||
* default: 7
|
||||
* description: Consider items expiring within this many days
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 50
|
||||
* default: 10
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recipe suggestions retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get(
|
||||
'/recipes/suggestions',
|
||||
validateRequest(
|
||||
@@ -629,29 +342,6 @@ router.get(
|
||||
// NOTE: These routes with /:inventoryId MUST come AFTER specific path routes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/{inventoryId}:
|
||||
* get:
|
||||
* tags: [Inventory]
|
||||
* summary: Get inventory item by ID
|
||||
* description: Retrieve a specific inventory item.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: inventoryId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Inventory item retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Item not found
|
||||
*/
|
||||
router.get(
|
||||
'/:inventoryId',
|
||||
validateRequest(inventoryIdParamSchema),
|
||||
@@ -677,55 +367,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/{inventoryId}:
|
||||
* put:
|
||||
* tags: [Inventory]
|
||||
* summary: Update inventory item
|
||||
* description: Update an existing inventory item.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: inventoryId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* quantity:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* unit:
|
||||
* type: string
|
||||
* maxLength: 50
|
||||
* expiry_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* location:
|
||||
* type: string
|
||||
* enum: [fridge, freezer, pantry, room_temp]
|
||||
* notes:
|
||||
* type: string
|
||||
* maxLength: 500
|
||||
* is_consumed:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item updated
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Item not found
|
||||
*/
|
||||
router.put(
|
||||
'/:inventoryId',
|
||||
validateRequest(updateInventoryItemSchema),
|
||||
@@ -752,29 +393,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/{inventoryId}:
|
||||
* delete:
|
||||
* tags: [Inventory]
|
||||
* summary: Delete inventory item
|
||||
* description: Remove an item from the user's inventory.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: inventoryId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Item deleted
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Item not found
|
||||
*/
|
||||
router.delete(
|
||||
'/:inventoryId',
|
||||
validateRequest(inventoryIdParamSchema),
|
||||
@@ -800,29 +418,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /inventory/{inventoryId}/consume:
|
||||
* post:
|
||||
* tags: [Inventory]
|
||||
* summary: Mark item as consumed
|
||||
* description: Mark an inventory item as consumed.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: inventoryId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Item marked as consumed
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Item not found
|
||||
*/
|
||||
router.post(
|
||||
'/:inventoryId/consume',
|
||||
validateRequest(inventoryIdParamSchema),
|
||||
|
||||
@@ -22,34 +22,6 @@ const masterItemsSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /personalization/master-items:
|
||||
* get:
|
||||
* tags: [Personalization]
|
||||
* summary: Get master items list
|
||||
* description: Get the master list of all grocery items with optional pagination. Response is cached for 1 hour.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* maximum: 500
|
||||
* description: Maximum number of items to return. If omitted, returns all items.
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* description: Number of items to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of master grocery items with total count
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/master-items',
|
||||
publicReadLimiter,
|
||||
@@ -74,21 +46,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /personalization/dietary-restrictions:
|
||||
* get:
|
||||
* tags: [Personalization]
|
||||
* summary: Get dietary restrictions
|
||||
* description: Get the master list of all available dietary restrictions.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of all dietary restrictions
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/dietary-restrictions',
|
||||
publicReadLimiter,
|
||||
@@ -107,21 +64,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /personalization/appliances:
|
||||
* get:
|
||||
* tags: [Personalization]
|
||||
* summary: Get kitchen appliances
|
||||
* description: Get the master list of all available kitchen appliances.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of all kitchen appliances
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/appliances',
|
||||
publicReadLimiter,
|
||||
|
||||
@@ -23,50 +23,6 @@ const priceHistorySchema = z.object({
|
||||
// Infer the type from the schema for local use, as per ADR-003.
|
||||
type PriceHistoryRequest = z.infer<typeof priceHistorySchema>;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /price-history:
|
||||
* post:
|
||||
* tags: [Price]
|
||||
* summary: Get price history
|
||||
* description: Fetches historical price data for a given list of master item IDs.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - masterItemIds
|
||||
* properties:
|
||||
* masterItemIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* minItems: 1
|
||||
* description: Array of master item IDs to get price history for
|
||||
* limit:
|
||||
* type: integer
|
||||
* default: 1000
|
||||
* description: Maximum number of price points to return
|
||||
* offset:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* description: Number of price points to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Historical price data for specified items
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Validation error - masterItemIds must be a non-empty array
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
|
||||
@@ -37,38 +37,6 @@ const getReactionSummarySchema = z.object({
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /reactions:
|
||||
* get:
|
||||
* tags: [Reactions]
|
||||
* summary: Get reactions
|
||||
* description: Fetches user reactions based on query filters. Supports filtering by userId, entityType, and entityId.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: userId
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: Filter by user ID
|
||||
* - in: query
|
||||
* name: entityType
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Filter by entity type (e.g., recipe, comment)
|
||||
* - in: query
|
||||
* name: entityId
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Filter by entity ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of reactions matching filters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
publicReadLimiter,
|
||||
@@ -85,36 +53,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /reactions/summary:
|
||||
* get:
|
||||
* tags: [Reactions]
|
||||
* summary: Get reaction summary
|
||||
* description: Fetches a summary of reactions for a specific entity.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: entityType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Entity type (e.g., recipe, comment)
|
||||
* - in: query
|
||||
* name: entityId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Entity ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Reaction summary with counts by type
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Missing required query parameters
|
||||
*/
|
||||
router.get(
|
||||
'/summary',
|
||||
publicReadLimiter,
|
||||
@@ -135,43 +73,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /reactions/toggle:
|
||||
* post:
|
||||
* tags: [Reactions]
|
||||
* summary: Toggle reaction
|
||||
* description: Toggles a user's reaction to an entity. If the reaction exists, it's removed; otherwise, it's added.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - entity_type
|
||||
* - entity_id
|
||||
* - reaction_type
|
||||
* properties:
|
||||
* entity_type:
|
||||
* type: string
|
||||
* description: Entity type (e.g., recipe, comment)
|
||||
* entity_id:
|
||||
* type: string
|
||||
* description: Entity ID
|
||||
* reaction_type:
|
||||
* type: string
|
||||
* description: Type of reaction (e.g., like, love)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Reaction removed
|
||||
* 201:
|
||||
* description: Reaction added
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.post(
|
||||
'/toggle',
|
||||
reactionToggleLimiter,
|
||||
|
||||
@@ -138,54 +138,6 @@ router.use(passport.authenticate('jwt', { session: false }));
|
||||
// RECEIPT MANAGEMENT ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts:
|
||||
* get:
|
||||
* tags: [Receipts]
|
||||
* summary: Get user's receipts
|
||||
* description: Retrieve the user's scanned receipts with optional filtering.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 50
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, processing, completed, failed]
|
||||
* - in: query
|
||||
* name: store_location_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: from_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* - in: query
|
||||
* name: to_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Receipts retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
validateRequest(receiptQuerySchema),
|
||||
@@ -215,43 +167,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts:
|
||||
* post:
|
||||
* tags: [Receipts]
|
||||
* summary: Upload a receipt
|
||||
* description: Upload a receipt image for processing and item extraction.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - receipt
|
||||
* properties:
|
||||
* receipt:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Receipt image file
|
||||
* store_location_id:
|
||||
* type: integer
|
||||
* description: Store location ID if known
|
||||
* transaction_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: Transaction date if known (YYYY-MM-DD)
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Receipt uploaded and queued for processing
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
receiptUpload.single('receipt'),
|
||||
@@ -312,29 +227,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}:
|
||||
* get:
|
||||
* tags: [Receipts]
|
||||
* summary: Get receipt by ID
|
||||
* description: Retrieve a specific receipt with its extracted items.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Receipt retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt not found
|
||||
*/
|
||||
router.get(
|
||||
'/:receiptId',
|
||||
validateRequest(receiptIdParamSchema),
|
||||
@@ -364,29 +256,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}:
|
||||
* delete:
|
||||
* tags: [Receipts]
|
||||
* summary: Delete receipt
|
||||
* description: Delete a receipt and all associated data.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Receipt deleted
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt not found
|
||||
*/
|
||||
router.delete(
|
||||
'/:receiptId',
|
||||
validateRequest(receiptIdParamSchema),
|
||||
@@ -408,29 +277,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}/reprocess:
|
||||
* post:
|
||||
* tags: [Receipts]
|
||||
* summary: Reprocess receipt
|
||||
* description: Queue a failed receipt for reprocessing.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Receipt queued for reprocessing
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt not found
|
||||
*/
|
||||
router.post(
|
||||
'/:receiptId/reprocess',
|
||||
validateRequest(receiptIdParamSchema),
|
||||
@@ -490,29 +336,6 @@ router.post(
|
||||
// RECEIPT ITEMS ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}/items:
|
||||
* get:
|
||||
* tags: [Receipts]
|
||||
* summary: Get receipt items
|
||||
* description: Get all extracted items from a receipt.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Receipt items retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt not found
|
||||
*/
|
||||
router.get(
|
||||
'/:receiptId/items',
|
||||
validateRequest(receiptIdParamSchema),
|
||||
@@ -537,56 +360,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}/items/{itemId}:
|
||||
* put:
|
||||
* tags: [Receipts]
|
||||
* summary: Update receipt item
|
||||
* description: Update a receipt item's matching status or linked product.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: path
|
||||
* name: itemId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [unmatched, matched, needs_review, ignored]
|
||||
* master_item_id:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* product_id:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* match_confidence:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* maximum: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item updated
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt or item not found
|
||||
*/
|
||||
router.put(
|
||||
'/:receiptId/items/:itemId',
|
||||
validateRequest(updateReceiptItemSchema),
|
||||
@@ -616,29 +389,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}/items/unadded:
|
||||
* get:
|
||||
* tags: [Receipts]
|
||||
* summary: Get unadded items
|
||||
* description: Get receipt items that haven't been added to inventory yet.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Unadded items retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt not found
|
||||
*/
|
||||
router.get(
|
||||
'/:receiptId/items/unadded',
|
||||
validateRequest(receiptIdParamSchema),
|
||||
@@ -663,64 +413,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}/confirm:
|
||||
* post:
|
||||
* tags: [Receipts]
|
||||
* summary: Confirm items for inventory
|
||||
* description: Confirm selected receipt items to add to user's inventory.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - items
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required:
|
||||
* - receipt_item_id
|
||||
* - include
|
||||
* properties:
|
||||
* receipt_item_id:
|
||||
* type: integer
|
||||
* item_name:
|
||||
* type: string
|
||||
* maxLength: 255
|
||||
* quantity:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* location:
|
||||
* type: string
|
||||
* enum: [fridge, freezer, pantry, room_temp]
|
||||
* expiry_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* include:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Items added to inventory
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt not found
|
||||
*/
|
||||
router.post(
|
||||
'/:receiptId/confirm',
|
||||
validateRequest(confirmItemsSchema),
|
||||
@@ -761,29 +453,6 @@ router.post(
|
||||
// PROCESSING LOGS ENDPOINT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /receipts/{receiptId}/logs:
|
||||
* get:
|
||||
* tags: [Receipts]
|
||||
* summary: Get processing logs
|
||||
* description: Get the processing log history for a receipt.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: receiptId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Processing logs retrieved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Receipt not found
|
||||
*/
|
||||
router.get(
|
||||
'/:receiptId/logs',
|
||||
validateRequest(receiptIdParamSchema),
|
||||
|
||||
@@ -46,30 +46,6 @@ const addCommentSchema = recipeIdParamsSchema.extend({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/by-sale-percentage:
|
||||
* get:
|
||||
* tags: [Recipes]
|
||||
* summary: Get recipes by sale percentage
|
||||
* description: Get recipes based on the percentage of their ingredients currently on sale.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: minPercentage
|
||||
* schema:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* maximum: 100
|
||||
* default: 50
|
||||
* description: Minimum percentage of ingredients on sale
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of recipes matching criteria
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/by-sale-percentage',
|
||||
publicReadLimiter,
|
||||
@@ -87,29 +63,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/by-sale-ingredients:
|
||||
* get:
|
||||
* tags: [Recipes]
|
||||
* summary: Get recipes by sale ingredients count
|
||||
* description: Get recipes with at least a specified number of ingredients currently on sale.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: minIngredients
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 3
|
||||
* description: Minimum number of sale ingredients required
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of recipes matching criteria
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/by-sale-ingredients',
|
||||
publicReadLimiter,
|
||||
@@ -130,36 +83,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/by-ingredient-and-tag:
|
||||
* get:
|
||||
* tags: [Recipes]
|
||||
* summary: Find recipes by ingredient and tag
|
||||
* description: Find recipes that contain a specific ingredient and have a specific tag.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: ingredient
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Ingredient name to search for
|
||||
* - in: query
|
||||
* name: tag
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Tag to filter by
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of matching recipes
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Missing required query parameters
|
||||
*/
|
||||
router.get(
|
||||
'/by-ingredient-and-tag',
|
||||
publicReadLimiter,
|
||||
@@ -180,30 +103,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/{recipeId}/comments:
|
||||
* get:
|
||||
* tags: [Recipes]
|
||||
* summary: Get recipe comments
|
||||
* description: Get all comments for a specific recipe.
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: recipeId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Recipe ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of comments
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 404:
|
||||
* description: Recipe not found
|
||||
*/
|
||||
router.get(
|
||||
'/:recipeId/comments',
|
||||
publicReadLimiter,
|
||||
@@ -221,30 +120,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/{recipeId}:
|
||||
* get:
|
||||
* tags: [Recipes]
|
||||
* summary: Get recipe by ID
|
||||
* description: Get a single recipe by its ID, including ingredients and tags.
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: recipeId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Recipe ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recipe details with ingredients and tags
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 404:
|
||||
* description: Recipe not found
|
||||
*/
|
||||
router.get(
|
||||
'/:recipeId',
|
||||
publicReadLimiter,
|
||||
@@ -262,42 +137,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/suggest:
|
||||
* post:
|
||||
* tags: [Recipes]
|
||||
* summary: Get AI recipe suggestion
|
||||
* description: Generate a recipe suggestion based on provided ingredients using AI.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - ingredients
|
||||
* properties:
|
||||
* ingredients:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* minItems: 1
|
||||
* description: List of ingredients to use
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AI-generated recipe suggestion
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
* 503:
|
||||
* description: AI service unavailable
|
||||
*/
|
||||
router.post(
|
||||
'/suggest',
|
||||
suggestionLimiter,
|
||||
@@ -325,49 +164,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/{recipeId}/comments:
|
||||
* post:
|
||||
* tags: [Recipes]
|
||||
* summary: Add comment to recipe
|
||||
* description: Add a comment to a recipe. Supports nested replies via parentCommentId.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: recipeId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Recipe ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - content
|
||||
* properties:
|
||||
* content:
|
||||
* type: string
|
||||
* description: Comment content
|
||||
* parentCommentId:
|
||||
* type: integer
|
||||
* description: Parent comment ID for replies (optional)
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Comment added
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
* 404:
|
||||
* description: Recipe or parent comment not found
|
||||
*/
|
||||
router.post(
|
||||
'/:recipeId/comments',
|
||||
userUpdateLimiter,
|
||||
@@ -394,34 +190,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /recipes/{recipeId}/fork:
|
||||
* post:
|
||||
* tags: [Recipes]
|
||||
* summary: Fork recipe
|
||||
* description: Create a personal copy of a recipe that you can modify.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: recipeId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Recipe ID to fork
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Recipe forked successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
* 404:
|
||||
* description: Recipe not found
|
||||
*/
|
||||
router.post(
|
||||
'/:recipeId/fork',
|
||||
userUpdateLimiter,
|
||||
|
||||
@@ -21,38 +21,6 @@ const mostFrequentSalesSchema = z.object({
|
||||
query: statsQuerySchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stats/most-frequent-sales:
|
||||
* get:
|
||||
* tags: [Stats]
|
||||
* summary: Get most frequent sale items
|
||||
* description: Get a list of items that have been on sale most frequently. Public endpoint for data analysis.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: days
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 365
|
||||
* default: 30
|
||||
* description: Number of days to look back
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 50
|
||||
* default: 10
|
||||
* description: Maximum number of items to return
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of most frequently on-sale items
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/most-frequent-sales',
|
||||
publicReadLimiter,
|
||||
|
||||
@@ -76,25 +76,6 @@ const deleteLocationSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stores:
|
||||
* get:
|
||||
* summary: Get all stores
|
||||
* description: Returns a list of all stores, optionally including their locations and addresses.
|
||||
* tags:
|
||||
* - Stores
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: includeLocations
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: Include store locations and addresses in response
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of stores
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
publicReadLimiter,
|
||||
@@ -115,27 +96,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stores/{id}:
|
||||
* get:
|
||||
* summary: Get store by ID
|
||||
* description: Returns a single store with all its locations and addresses.
|
||||
* tags:
|
||||
* - Stores
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The store ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Store details with locations
|
||||
* 404:
|
||||
* description: Store not found
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
publicReadLimiter,
|
||||
@@ -155,46 +115,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stores:
|
||||
* post:
|
||||
* summary: Create a new store
|
||||
* description: Creates a new store, optionally with an initial address/location.
|
||||
* tags:
|
||||
* - Stores
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* logo_url:
|
||||
* type: string
|
||||
* address:
|
||||
* type: object
|
||||
* properties:
|
||||
* address_line_1:
|
||||
* type: string
|
||||
* city:
|
||||
* type: string
|
||||
* province_state:
|
||||
* type: string
|
||||
* postal_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Store created successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
@@ -268,41 +188,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stores/{id}:
|
||||
* put:
|
||||
* summary: Update a store
|
||||
* description: Updates a store's name and/or logo URL.
|
||||
* tags:
|
||||
* - Stores
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* logo_url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Store updated successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Store not found
|
||||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
@@ -330,30 +215,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stores/{id}:
|
||||
* delete:
|
||||
* summary: Delete a store
|
||||
* description: Deletes a store and all its associated locations (admin only).
|
||||
* tags:
|
||||
* - Stores
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Store deleted successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Store not found
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
@@ -379,52 +240,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stores/{id}/locations:
|
||||
* post:
|
||||
* summary: Add a location to a store
|
||||
* description: Creates a new address and links it to the store.
|
||||
* tags:
|
||||
* - Stores
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - address_line_1
|
||||
* - city
|
||||
* - province_state
|
||||
* - postal_code
|
||||
* properties:
|
||||
* address_line_1:
|
||||
* type: string
|
||||
* address_line_2:
|
||||
* type: string
|
||||
* city:
|
||||
* type: string
|
||||
* province_state:
|
||||
* type: string
|
||||
* postal_code:
|
||||
* type: string
|
||||
* country:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Location added successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post(
|
||||
'/:id/locations',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
@@ -487,35 +302,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /stores/{id}/locations/{locationId}:
|
||||
* delete:
|
||||
* summary: Remove a location from a store
|
||||
* description: Deletes the link between a store and an address (admin only).
|
||||
* tags:
|
||||
* - Stores
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: path
|
||||
* name: locationId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Location removed successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Location not found
|
||||
*/
|
||||
router.delete(
|
||||
'/:id/locations/:locationId',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
|
||||
@@ -27,21 +27,6 @@ const geocodeSchema = z.object({
|
||||
// An empty schema for routes that do not expect any input, to maintain a consistent validation pattern.
|
||||
const emptySchema = z.object({});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /system/pm2-status:
|
||||
* get:
|
||||
* tags: [System]
|
||||
* summary: Get PM2 process status
|
||||
* description: Checks the status of the 'flyer-crawler-api' process managed by PM2. For development and diagnostic purposes.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: PM2 process status information
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
*/
|
||||
router.get(
|
||||
'/pm2-status',
|
||||
validateRequest(emptySchema),
|
||||
@@ -55,35 +40,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /system/geocode:
|
||||
* post:
|
||||
* tags: [System]
|
||||
* summary: Geocode an address
|
||||
* description: Geocodes a given address string. Acts as a secure proxy to the Google Maps Geocoding API.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - address
|
||||
* properties:
|
||||
* address:
|
||||
* type: string
|
||||
* description: Address string to geocode
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Geocoded coordinates
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 404:
|
||||
* description: Could not geocode the provided address
|
||||
*/
|
||||
router.post(
|
||||
'/geocode',
|
||||
geocodeLimiter,
|
||||
|
||||
@@ -96,49 +96,6 @@ const scanHistoryQuerySchema = z.object({
|
||||
// Middleware to ensure user is authenticated for all UPC routes
|
||||
router.use(passport.authenticate('jwt', { session: false }));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /upc/scan:
|
||||
* post:
|
||||
* tags: [UPC Scanning]
|
||||
* summary: Scan a UPC barcode
|
||||
* description: >
|
||||
* Scans a UPC barcode either from a manually entered code or from an image.
|
||||
* Records the scan in history and returns product information if found.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - scan_source
|
||||
* properties:
|
||||
* upc_code:
|
||||
* type: string
|
||||
* pattern: '^[0-9]{8,14}$'
|
||||
* description: UPC code (8-14 digits). Required if image_base64 is not provided.
|
||||
* image_base64:
|
||||
* type: string
|
||||
* description: Base64-encoded image containing a barcode. Required if upc_code is not provided.
|
||||
* scan_source:
|
||||
* type: string
|
||||
* enum: [image_upload, manual_entry, phone_app, camera_scan]
|
||||
* description: How the scan was initiated.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Scan completed successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Validation error - invalid UPC code or missing data
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.post(
|
||||
'/scan',
|
||||
validateRequest(scanUpcSchema),
|
||||
@@ -165,49 +122,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /upc/lookup:
|
||||
* get:
|
||||
* tags: [UPC Scanning]
|
||||
* summary: Look up a UPC code
|
||||
* description: >
|
||||
* Looks up product information for a UPC code without recording in scan history.
|
||||
* Useful for verification or quick lookups.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: upc_code
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* pattern: '^[0-9]{8,14}$'
|
||||
* description: UPC code to look up (8-14 digits)
|
||||
* - in: query
|
||||
* name: include_external
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* description: Whether to check external APIs if not found locally
|
||||
* - in: query
|
||||
* name: force_refresh
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: Skip cache and perform fresh external lookup
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Lookup completed
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 400:
|
||||
* description: Invalid UPC code format
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get(
|
||||
'/lookup',
|
||||
validateRequest(lookupUpcSchema),
|
||||
@@ -233,64 +147,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /upc/history:
|
||||
* get:
|
||||
* tags: [UPC Scanning]
|
||||
* summary: Get scan history
|
||||
* description: Retrieve the authenticated user's UPC scan history with optional filtering.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 50
|
||||
* description: Maximum number of results
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* description: Number of results to skip
|
||||
* - in: query
|
||||
* name: lookup_successful
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: Filter by lookup success status
|
||||
* - in: query
|
||||
* name: scan_source
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [image_upload, manual_entry, phone_app, camera_scan]
|
||||
* description: Filter by scan source
|
||||
* - in: query
|
||||
* name: from_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: Filter scans from this date (YYYY-MM-DD)
|
||||
* - in: query
|
||||
* name: to_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: Filter scans until this date (YYYY-MM-DD)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Scan history retrieved
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get(
|
||||
'/history',
|
||||
validateRequest(scanHistoryQuerySchema),
|
||||
@@ -320,34 +176,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /upc/history/{scanId}:
|
||||
* get:
|
||||
* tags: [UPC Scanning]
|
||||
* summary: Get scan by ID
|
||||
* description: Retrieve a specific scan record by its ID.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: scanId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Scan ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Scan record retrieved
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SuccessResponse'
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
* 404:
|
||||
* description: Scan record not found
|
||||
*/
|
||||
router.get(
|
||||
'/history/:scanId',
|
||||
validateRequest(scanIdParamSchema),
|
||||
@@ -369,41 +197,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /upc/stats:
|
||||
* get:
|
||||
* tags: [UPC Scanning]
|
||||
* summary: Get scan statistics
|
||||
* description: Get scanning statistics for the authenticated user.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Scan statistics retrieved
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_scans:
|
||||
* type: integer
|
||||
* successful_lookups:
|
||||
* type: integer
|
||||
* unique_products:
|
||||
* type: integer
|
||||
* scans_today:
|
||||
* type: integer
|
||||
* scans_this_week:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
*/
|
||||
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
@@ -416,48 +209,6 @@ router.get('/stats', async (req: Request, res: Response, next: NextFunction) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /upc/link:
|
||||
* post:
|
||||
* tags: [UPC Scanning]
|
||||
* summary: Link UPC to product (Admin)
|
||||
* description: >
|
||||
* Links a UPC code to an existing product in the database.
|
||||
* This is an admin-only operation.
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - upc_code
|
||||
* - product_id
|
||||
* properties:
|
||||
* upc_code:
|
||||
* type: string
|
||||
* pattern: '^[0-9]{8,14}$'
|
||||
* description: UPC code to link (8-14 digits)
|
||||
* product_id:
|
||||
* type: integer
|
||||
* description: Product ID to link the UPC to
|
||||
* responses:
|
||||
* 204:
|
||||
* description: UPC linked successfully
|
||||
* 400:
|
||||
* description: Invalid UPC code or product ID
|
||||
* 401:
|
||||
* description: Unauthorized - invalid or missing token
|
||||
* 403:
|
||||
* description: Forbidden - user is not an admin
|
||||
* 404:
|
||||
* description: Product not found
|
||||
* 409:
|
||||
* description: UPC code already linked to another product
|
||||
*/
|
||||
router.post(
|
||||
'/link',
|
||||
isAdmin, // Admin role check - only admins can link UPC codes to products
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,19 @@ class MonitoringService {
|
||||
* Retrieves job counts for all registered BullMQ queues.
|
||||
* @returns A promise that resolves to an array of queue statuses.
|
||||
*/
|
||||
async getQueueStatuses() {
|
||||
async getQueueStatuses(): Promise<
|
||||
{
|
||||
name: string;
|
||||
counts: {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
paused: number;
|
||||
};
|
||||
}[]
|
||||
> {
|
||||
const queues = [
|
||||
flyerQueue,
|
||||
emailQueue,
|
||||
@@ -63,14 +75,21 @@ class MonitoringService {
|
||||
return Promise.all(
|
||||
queues.map(async (queue) => ({
|
||||
name: queue.name,
|
||||
counts: await queue.getJobCounts(
|
||||
counts: (await queue.getJobCounts(
|
||||
'waiting',
|
||||
'active',
|
||||
'completed',
|
||||
'failed',
|
||||
'delayed',
|
||||
'paused',
|
||||
),
|
||||
)) as {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
paused: number;
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { connection } from './redis.server';
|
||||
import { bullmqConnection as connection } from './redis.server';
|
||||
import type {
|
||||
FlyerJobData,
|
||||
EmailJobData,
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import IORedis from 'ioredis';
|
||||
import type { ConnectionOptions } from 'bullmq';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
export const connection = new IORedis(process.env.REDIS_URL!, {
|
||||
const redisClient = new IORedis(process.env.REDIS_URL!, {
|
||||
maxRetriesPerRequest: null, // Important for BullMQ
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
});
|
||||
|
||||
// Export the raw ioredis client for direct Redis operations (e.g., set, get)
|
||||
export const connection = redisClient;
|
||||
|
||||
// Export a properly typed connection for BullMQ queues and workers.
|
||||
// BullMQ expects its own ConnectionOptions type which is compatible with ioredis,
|
||||
// but TypeScript needs the explicit cast due to minor version differences.
|
||||
export const bullmqConnection = redisClient as unknown as ConnectionOptions;
|
||||
|
||||
// --- Redis Connection Event Listeners ---
|
||||
connection.on('connect', () => {
|
||||
logger.info('[Redis] Connection established successfully.');
|
||||
@@ -13,4 +22,4 @@ connection.on('connect', () => {
|
||||
|
||||
connection.on('error', (err) => {
|
||||
logger.error({ err }, '[Redis] Connection error.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
|
||||
import { logger } from './logger.server';
|
||||
import { connection } from './redis.server';
|
||||
import { connection, bullmqConnection } from './redis.server';
|
||||
import { aiService } from './aiService.server';
|
||||
import { analyticsService } from './analyticsService.server';
|
||||
import { userService } from './userService';
|
||||
@@ -152,7 +152,7 @@ export const flyerWorker = new Worker<FlyerJobData>(
|
||||
createWorkerProcessor((job) => flyerProcessingService.processJob(job)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '1', 10),
|
||||
// Increase lock duration to prevent jobs from being re-processed prematurely.
|
||||
// We use the env var if set, otherwise fallback to the defaultWorkerOptions value (30000)
|
||||
@@ -168,7 +168,7 @@ export const emailWorker = new Worker<EmailJobData>(
|
||||
createWorkerProcessor((job) => emailService.processEmailJob(job)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.EMAIL_WORKER_CONCURRENCY || '10', 10),
|
||||
},
|
||||
);
|
||||
@@ -178,7 +178,7 @@ export const analyticsWorker = new Worker<AnalyticsJobData>(
|
||||
createWorkerProcessor((job) => analyticsService.processDailyReportJob(job)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.ANALYTICS_WORKER_CONCURRENCY || '1', 10),
|
||||
},
|
||||
);
|
||||
@@ -188,7 +188,7 @@ export const cleanupWorker = new Worker<CleanupJobData>(
|
||||
createWorkerProcessor((job) => flyerProcessingService.processCleanupJob(job)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.CLEANUP_WORKER_CONCURRENCY || '10', 10),
|
||||
},
|
||||
);
|
||||
@@ -198,7 +198,7 @@ export const weeklyAnalyticsWorker = new Worker<WeeklyAnalyticsJobData>(
|
||||
createWorkerProcessor((job) => analyticsService.processWeeklyReportJob(job)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.WEEKLY_ANALYTICS_WORKER_CONCURRENCY || '1', 10),
|
||||
},
|
||||
);
|
||||
@@ -208,7 +208,7 @@ export const tokenCleanupWorker = new Worker<TokenCleanupJobData>(
|
||||
createWorkerProcessor((job) => userService.processTokenCleanupJob(job)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: 1,
|
||||
},
|
||||
);
|
||||
@@ -218,7 +218,7 @@ export const receiptWorker = new Worker<ReceiptJobData>(
|
||||
createWorkerProcessor((job) => receiptService.processReceiptJob(job, logger)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.RECEIPT_WORKER_CONCURRENCY || '2', 10),
|
||||
},
|
||||
);
|
||||
@@ -228,7 +228,7 @@ export const expiryAlertWorker = new Worker<ExpiryAlertJobData>(
|
||||
createWorkerProcessor((job) => expiryService.processExpiryAlertJob(job, logger)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.EXPIRY_ALERT_WORKER_CONCURRENCY || '1', 10),
|
||||
},
|
||||
);
|
||||
@@ -238,7 +238,7 @@ export const barcodeWorker = new Worker<BarcodeDetectionJobData>(
|
||||
createWorkerProcessor((job) => barcodeService.processBarcodeDetectionJob(job, logger)),
|
||||
{
|
||||
...defaultWorkerOptions,
|
||||
connection,
|
||||
connection: bullmqConnection,
|
||||
concurrency: parseInt(process.env.BARCODE_WORKER_CONCURRENCY || '2', 10),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { UserDataProvider } from '../../providers/UserDataProvider';
|
||||
*
|
||||
* @returns A new QueryClient instance for testing
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -54,6 +55,7 @@ interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
* @param options Additional render options
|
||||
* @returns The result of the render function
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const renderWithProviders = (ui: ReactElement, options?: ExtendedRenderOptions) => {
|
||||
const { initialEntries, ...renderOptions } = options || {};
|
||||
const testQueryClient = createTestQueryClient();
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
// Required for tsoa decorator-based controllers (ADR-055)
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
// This line makes Vitest's global APIs (describe, it, expect) available everywhere
|
||||
// without needing to import them.
|
||||
"types": ["vitest/globals"]
|
||||
|
||||
64
tsoa.json
Normal file
64
tsoa.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"entryFile": "server.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "src/config",
|
||||
"specVersion": 3,
|
||||
"securityDefinitions": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"basePath": "/api",
|
||||
"specFileBaseName": "tsoa-spec",
|
||||
"name": "Flyer Crawler API",
|
||||
"description": "API for the Flyer Crawler grocery deal extraction and analysis platform",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "Flyer Crawler Team"
|
||||
},
|
||||
"license": {
|
||||
"name": "Private"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Health",
|
||||
"description": "Health check endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Auth",
|
||||
"description": "Authentication and authorization endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Users",
|
||||
"description": "User management endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Flyers",
|
||||
"description": "Flyer management endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Deals",
|
||||
"description": "Deal search and management endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Stores",
|
||||
"description": "Store information endpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"routes": {
|
||||
"routesDir": "src/routes",
|
||||
"basePath": "/api",
|
||||
"middleware": "express",
|
||||
"routesFileName": "tsoa-generated.ts",
|
||||
"esm": true,
|
||||
"authenticationModule": "src/middleware/tsoaAuthentication.ts"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user