diff --git a/README.md b/README.md index 538fb13..8ed0132 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ pnpm exec prisma db push pnpm dev ``` +**Configuration:** See [Configuration Guide](./docs/configuration.md) for detailed information about environment variables, source precedence, and validation rules. + ## Database This repo includes a local PostgreSQL container for development. diff --git a/docs/CONFIG_SOURCE_PRECEDENCE.md b/docs/CONFIG_SOURCE_PRECEDENCE.md new file mode 100644 index 0000000..87f0402 --- /dev/null +++ b/docs/CONFIG_SOURCE_PRECEDENCE.md @@ -0,0 +1,399 @@ +# Configuration Source Precedence - Quick Reference + +## Source Priority (Highest to Lowest) + +``` +1. Environment Variables (.env or system) + ↓ +2. Schema Defaults (src/config.ts) + ↓ +3. Validation Failure (startup fails) +``` + +## Visual Flow + +``` +┌─────────────────────────────────────────┐ +│ .env File or System Environment │ +│ PORT=4000 │ +│ MODE=production │ +│ DATABASE_URL=postgresql://... │ +└─────────────────┬───────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ dotenv.config() │ +│ Loads .env into process.env │ +└─────────────────┬───────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ Zod Schema Validation │ +│ envSchema.parse(process.env) │ +│ │ +│ For each field: │ +│ 1. Check process.env[FIELD] │ +│ 2. Apply type coercion │ +│ 3. Use default if missing (optional) │ +│ 4. Fail if missing (required) │ +└─────────────────┬───────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ envConfig Object │ +│ Typed, validated configuration │ +│ Ready for application use │ +└─────────────────────────────────────────┘ +``` + +## Decision Tree + +``` +Is variable in .env or system environment? +│ +├─ YES → Use that value +│ ↓ +│ Apply type coercion +│ ↓ +│ Validate against schema +│ ↓ +│ ✓ Success +│ +└─ NO → Does schema have .default()? + │ + ├─ YES → Use default value + │ ↓ + │ ✓ Success + │ + └─ NO → Is field required? + │ + ├─ YES → ✗ Fail startup + │ (throw validation error) + │ + └─ NO → Use undefined + ↓ + ✓ Success +``` + +## Examples by Configuration Type + +### Example 1: Optional with Default + +**Schema:** +```typescript +PORT: z.coerce.number().default(3000) +``` + +**Scenarios:** + +| .env Value | Result | Source | +|------------|--------|--------| +| `PORT=4000` | `4000` | Environment | +| `PORT=` (empty) | `3000` | Default | +| (not set) | `3000` | Default | + +### Example 2: Required (No Default) + +**Schema:** +```typescript +DATABASE_URL: z.string().min(1, 'DATABASE_URL is required') +``` + +**Scenarios:** + +| .env Value | Result | Source | +|------------|--------|--------| +| `DATABASE_URL=postgresql://...` | `postgresql://...` | Environment | +| `DATABASE_URL=` (empty) | ❌ Startup fails | Validation error | +| (not set) | ❌ Startup fails | Validation error | + +### Example 3: Optional with Validation + +**Schema:** +```typescript +PAYSTACK_PUBLIC_KEY: z.string().min(1).optional() +``` + +**Scenarios:** + +| .env Value | Result | Source | +|------------|--------|--------| +| `PAYSTACK_PUBLIC_KEY=pk_test_123` | `pk_test_123` | Environment | +| `PAYSTACK_PUBLIC_KEY=` (empty) | `undefined` | Optional | +| (not set) | `undefined` | Optional | + +### Example 4: Enum with Default + +**Schema:** +```typescript +MODE: z.enum(['development', 'production', 'test']).default('development') +``` + +**Scenarios:** + +| .env Value | Result | Source | +|------------|--------|--------| +| `MODE=production` | `production` | Environment | +| `MODE=invalid` | ❌ Startup fails | Validation error | +| (not set) | `development` | Default | + +## Configuration Categories Summary + +### 🔴 Required (Must Provide) + +No defaults. Startup fails if missing. + +``` +DATABASE_URL +GMAIL_USER +GMAIL_APP_PASSWORD +GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET +BACKEND_URL +FRONTEND_URL +CLOUDINARY_CLOUD_NAME +CLOUDINARY_API_KEY +CLOUDINARY_API_SECRET +PAYSTACK_SECRET_KEY +``` + +**Source Precedence:** +``` +Environment → Validation Failure +``` + +### 🟡 Optional with Defaults + +Has defaults. Uses default if not provided. + +``` +PORT (default: 3000) +MODE (default: 'development') +APP_SECRET (default: dev key) +API_VERSION (default: '1.0.0') +ENABLE_RESPONSE_TIMING (default: true) +ENABLE_API_VERSION_HEADER (default: true) +ENABLE_SCHEMA_VERSION_HEADER (default: true) +ENABLE_REQUEST_LOGGING (default: true) +INDEXER_JITTER_FACTOR (default: 0.1) +BACKGROUND_JOB_LOCK_TTL_MS (default: 300000) +CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS (default: 500) +INDEXER_CURSOR_STALE_AGE_WARNING_MS (default: 300000) +``` + +**Source Precedence:** +``` +Environment → Schema Default +``` + +### 🟢 Optional (No Default) + +Optional. Uses `undefined` if not provided. + +``` +PAYSTACK_PUBLIC_KEY +``` + +**Source Precedence:** +``` +Environment → undefined +``` + +## Type Coercion Examples + +### String to Number + +**Schema:** `z.coerce.number()` + +| Input | Output | Notes | +|-------|--------|-------| +| `"3000"` | `3000` | String coerced to number | +| `3000` | `3000` | Already number | +| `"abc"` | ❌ Error | Invalid number | +| `""` | ❌ Error | Empty string | + +### String to Boolean + +**Schema:** `z.coerce.boolean()` + +| Input | Output | Notes | +|-------|--------|-------| +| `"true"` | `true` | String to boolean | +| `"false"` | `false` | String to boolean | +| `"1"` | `true` | Truthy coercion | +| `"0"` | `false` | Falsy coercion | +| `""` | `false` | Empty is falsy | +| `true` | `true` | Already boolean | + +## Override Hierarchy + +When the same variable is defined in multiple places: + +``` +System Environment Variables (highest priority) + ↓ +.env File + ↓ +Schema Defaults + ↓ +Validation Failure (lowest priority) +``` + +**Example:** + +```bash +# System environment +export PORT=5000 + +# .env file +PORT=4000 + +# Schema +PORT: z.coerce.number().default(3000) +``` + +**Result:** `PORT = 5000` (system environment wins) + +**Note:** `dotenv.config()` does NOT override existing environment variables. + +## Common Patterns + +### Pattern 1: Feature Flags + +```typescript +ENABLE_FEATURE: z.coerce.boolean().default(false) +``` + +**Usage:** +```typescript +if (envConfig.ENABLE_FEATURE) { + // Feature enabled +} +``` + +**Source:** Environment → Default (false) + +### Pattern 2: Timeouts/Thresholds + +```typescript +TIMEOUT_MS: z.coerce.number().int().positive().default(5000) +``` + +**Usage:** +```typescript +setTimeout(() => {}, envConfig.TIMEOUT_MS); +``` + +**Source:** Environment → Default (5000) + +### Pattern 3: Environment-Specific Behavior + +```typescript +MODE: z.enum(['development', 'production', 'test']).default('development') +``` + +**Usage:** +```typescript +if (envConfig.MODE === 'production') { + // Production-only behavior +} +``` + +**Source:** Environment → Default ('development') + +### Pattern 4: Secrets with Dev Defaults + +```typescript +APP_SECRET: z.string().min(32).default('dev_secret_32_chars_long_string') +``` + +**Usage:** +```typescript +const secret = envConfig.APP_SECRET; +``` + +**Source:** Environment → Default (dev key) + +**⚠️ Warning:** Always override in production! + +## Validation Failure Examples + +### Missing Required Field + +``` +Error: ZodError +Issues: + - DATABASE_URL is required in the environment variables +``` + +**Fix:** Add `DATABASE_URL=...` to `.env` + +### Invalid Type + +``` +Error: ZodError +Issues: + - PORT: Expected number, received string +``` + +**Fix:** Ensure `PORT=3000` (valid number) + +### Invalid Enum Value + +``` +Error: ZodError +Issues: + - MODE: Invalid enum value. Expected 'development' | 'production' | 'test', received 'staging' +``` + +**Fix:** Use valid enum value: `MODE=production` + +### Invalid URL Format + +``` +Error: ZodError +Issues: + - FRONTEND_URL must be a valid URL +``` + +**Fix:** Include protocol: `FRONTEND_URL=https://example.com` + +## Runtime Behavior + +### Startup Sequence + +1. **Load .env file** → `dotenv.config()` +2. **Parse environment** → `envSchema.parse(process.env)` +3. **Validate all fields** → Apply coercion, defaults, validation +4. **Export config** → `envConfig` available for import +5. **Start server** → Use `envConfig` values + +### Configuration is Immutable + +Once loaded at startup, configuration values do not change: + +```typescript +// ❌ This does NOT work +envConfig.PORT = 4000; // TypeError: Cannot assign to read only property + +// ✓ Configuration is read-only +const port = envConfig.PORT; // Always returns same value +``` + +**To change configuration:** Restart server with new environment values. + +## Quick Troubleshooting + +| Symptom | Likely Cause | Solution | +|---------|--------------|----------| +| Startup fails with "required" error | Missing required env var | Add to `.env` | +| Using default instead of .env value | Typo in variable name | Check spelling | +| Changes not reflected | Server not restarted | Restart server | +| Type validation error | Wrong value format | Check type (number, boolean, etc.) | +| URL validation error | Missing protocol | Add `https://` | + +## See Also + +- [Configuration Guide](./configuration.md) - Complete documentation +- [.env.example](../.env.example) - Template with all variables +- [src/config.ts](../src/config.ts) - Schema definitions diff --git a/docs/CREATOR_FEED_TEST_QUICKSTART.md b/docs/CREATOR_FEED_TEST_QUICKSTART.md new file mode 100644 index 0000000..b27dfc1 --- /dev/null +++ b/docs/CREATOR_FEED_TEST_QUICKSTART.md @@ -0,0 +1,128 @@ +# Creator Feed Empty Filters Test - Quick Start + +## Run the Tests + +### Run All Tests +```bash +npm test -- creator-feed-empty-filters.integration.test.ts +``` + +### Run with Coverage +```bash +npm test -- --coverage creator-feed-empty-filters.integration.test.ts +``` + +### Run in Watch Mode +```bash +npm test -- --watch creator-feed-empty-filters.integration.test.ts +``` + +### Run Specific Test +```bash +npm test -- -t "returns stable response envelope with items array" +``` + +## Expected Output + +``` +PASS src/modules/creators/creator-feed-empty-filters.integration.test.ts + GET /api/v1/creators — empty feed with filter combinations + ✓ returns stable response envelope with items array (Xms) + ✓ returns stable response envelope with meta object (Xms) + ✓ responds with status 200 for empty results (Xms) + ... (27 more tests) + +Test Suites: 1 passed, 1 total +Tests: 30 passed, 30 total +Snapshots: 0 total +Time: X.XXXs +``` + +## What's Being Tested + +### ✅ Response Envelope Structure +- Items array is always present +- Meta object contains all required fields +- Status 200 for empty results + +### ✅ Default Values +- Default limit applied +- Default offset (0) applied +- Default sort applied + +### ✅ Filter Combinations +- No filters (empty query) +- verified=true/false +- search parameter +- Combined filters + +### ✅ Pagination Metadata +- total is 0 +- hasMore is false +- offset/limit reflect request + +### ✅ Validation Errors +- Invalid parameters return 400 +- Error details included + +## Test Strategy + +**Minimal Fixtures:** +- Uses Jest mocks (no database) +- Always returns empty results `[[], 0]` +- Fast execution (< 1 second) +- Deterministic (never flakes) + +## Verify Locally + +### 1. Run Tests +```bash +npm test -- creator-feed-empty-filters.integration.test.ts +``` +All 30 tests should pass. + +### 2. Check Coverage +```bash +npm test -- --coverage creator-feed-empty-filters.integration.test.ts +``` +Should show high coverage. + +### 3. Test Against Real Server +```bash +# Start server +npm run dev + +# In another terminal, test empty results +curl "http://localhost:3000/api/v1/creators?verified=true&search=nonexistent" +``` + +Expected response: +```json +{ + "success": true, + "data": { + "items": [], + "meta": { + "limit": 20, + "offset": 0, + "total": 0, + "hasMore": false + } + } +} +``` + +## Troubleshooting + +### Tests Fail: "Cannot find module" +**Fix:** Run `npm install` to install dependencies + +### Tests Fail: "Expected property 'items'" +**Fix:** Check if response structure changed in controller + +### Tests Fail: Mock not called +**Fix:** Verify controller still calls `fetchCreatorList` + +## More Information + +See [docs/creator-feed-empty-filters-test.md](./creator-feed-empty-filters-test.md) for complete documentation. diff --git a/docs/QUERY_DEBUG_QUICKSTART.md b/docs/QUERY_DEBUG_QUICKSTART.md new file mode 100644 index 0000000..530fd08 --- /dev/null +++ b/docs/QUERY_DEBUG_QUICKSTART.md @@ -0,0 +1,143 @@ +# Query Normalization Debug - Quick Start Guide + +## Enable Debug Logging + +### Option 1: Environment Variable +```bash +LOG_LEVEL=debug npm run dev +``` + +### Option 2: Code Change +Edit `src/utils/logger.utils.ts`: +```typescript +export const logger = pino({ + level: 'debug', // Change from 'info' + // ... rest of config +}); +``` + +## Add Debug Context to Query Parsing + +### In Controllers +```typescript +import { parsePublicQuery } from '../../utils/public-query-parse.utils'; + +const parsed = parsePublicQuery( + YourQuerySchema, + req.query, + { debugContext: 'your-endpoint-name' } // Add this +); +``` + +### Direct Usage +```typescript +import { emitQueryNormalizationDebug } from '../../utils/query-normalization-debug.utils'; + +emitQueryNormalizationDebug({ + raw: req.query, + normalized: validatedQuery, + valid: true, + context: 'your-context', +}); +``` + +## View Debug Output + +Make a request to your endpoint: +```bash +curl "http://localhost:3000/api/v1/creators?limit=10&search=alice" +``` + +Look for log entries like: +```json +{ + "level": 20, + "msg": "Query normalization debug snapshot", + "queryNormalization": { + "raw": { "limit": "10", "search": "alice" }, + "normalized": { "limit": 10, "search": "alice" }, + "valid": true, + "context": "creator-list-query" + } +} +``` + +## Disable Debug Logging + +### Option 1: Environment Variable +```bash +LOG_LEVEL=info npm start +``` + +### Option 2: Code Change +Edit `src/utils/logger.utils.ts`: +```typescript +export const logger = pino({ + level: 'info', // Change back from 'debug' + // ... rest of config +}); +``` + +## Common Use Cases + +### Debugging Invalid Queries +```bash +# Make invalid request +curl "http://localhost:3000/api/v1/creators?limit=invalid" + +# Check logs for validation errors +{ + "queryNormalization": { + "valid": false, + "errors": [ + { "field": "limit", "message": "Expected number, received string" } + ] + } +} +``` + +### Verifying Normalization +```bash +# Request with whitespace +curl "http://localhost:3000/api/v1/creators?search=%20%20alice%20%20" + +# Check logs to see trimmed value +{ + "queryNormalization": { + "raw": { "search": " alice " }, + "normalized": { "search": "alice" } + } +} +``` + +### Testing Edge Cases +```bash +# Empty values +curl "http://localhost:3000/api/v1/creators?search=&limit=" + +# Special characters +curl "http://localhost:3000/api/v1/creators?search=alice%20%26%20bob" + +# Check logs for normalization behavior +``` + +## Security Note + +Sensitive fields are automatically redacted: +```json +{ + "raw": { + "username": "alice", + "password": "[REDACTED]", + "email": "[REDACTED]" + } +} +``` + +## Performance Note + +Debug logging has **zero overhead** when disabled (default production setting). + +## More Information + +See [docs/query-normalization-debug.md](./query-normalization-debug.md) for complete documentation. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c88338e --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,455 @@ +# Configuration Guide + +## Overview + +The Access Layer server uses a layered configuration system with clear source precedence. Configuration values are loaded from environment variables, with schema validation and default values applied at startup. + +## Configuration Source Precedence + +Configuration values are resolved in the following order (highest to lowest priority): + +1. **Environment Variables** (`.env` file or system environment) +2. **Schema Defaults** (defined in `src/config.ts`) +3. **Validation Failure** (startup fails if required values are missing) + +### Precedence Rules + +``` +Environment Variable → Schema Default → Required (fail if missing) +``` + +**Example:** +```typescript +PORT: z.coerce.number().default(3000) +``` +- If `PORT=4000` in `.env` → uses `4000` +- If `PORT` not set → uses default `3000` + +```typescript +DATABASE_URL: z.string().min(1, 'DATABASE_URL is required') +``` +- If `DATABASE_URL` in `.env` → uses that value +- If `DATABASE_URL` not set → **startup fails** with error message + +## Configuration Loading Process + +### 1. Environment File Loading + +The server loads environment variables from `.env` file at startup: + +```typescript +import dotenv from 'dotenv'; +dotenv.config(); +``` + +**File Location:** `.env` in project root + +**Loading Behavior:** +- Reads `.env` file if it exists +- Does not override existing environment variables +- Silent if `.env` file is missing (uses system environment) + +### 2. Schema Validation + +All configuration values are validated using Zod schemas: + +```typescript +export const envSchema = z.object({ + PORT: z.coerce.number().default(3000), + MODE: z.enum(['development', 'production', 'test']).default('development'), + DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), + // ... more fields +}); + +export const envConfig = envSchema.parse(process.env); +``` + +**Validation Process:** +1. Reads `process.env` (includes `.env` values) +2. Applies type coercion (e.g., string → number) +3. Validates against schema rules +4. Applies default values for missing optional fields +5. Fails startup if required fields are missing + +### 3. Runtime Access + +Configuration is accessed via exported constants: + +```typescript +import { envConfig, appConfig } from './config'; + +// Use configuration values +const port = envConfig.PORT; +const origins = appConfig.allowedOrigins; +``` + +## Configuration Categories + +### Required Configuration + +These values **must** be provided via environment variables: + +| Variable | Type | Description | +|----------|------|-------------| +| `DATABASE_URL` | string | PostgreSQL connection string | +| `GMAIL_USER` | string | Gmail account for email sending | +| `GMAIL_APP_PASSWORD` | string | Gmail app-specific password | +| `GOOGLE_CLIENT_ID` | string | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | string | Google OAuth client secret | +| `BACKEND_URL` | URL | Backend server URL | +| `FRONTEND_URL` | URL | Frontend application URL | +| `CLOUDINARY_CLOUD_NAME` | string | Cloudinary cloud name | +| `CLOUDINARY_API_KEY` | string | Cloudinary API key | +| `CLOUDINARY_API_SECRET` | string | Cloudinary API secret | +| `PAYSTACK_SECRET_KEY` | string | Paystack secret key | + +**Startup Behavior:** Server fails to start if any required value is missing. + +### Optional Configuration with Defaults + +These values have defaults and can be overridden: + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `PORT` | number | `3000` | Server port | +| `MODE` | enum | `development` | Environment mode | +| `APP_SECRET` | string | (dev key) | Secret for signing/encryption | +| `API_VERSION` | string | `1.0.0` | API version string | +| `ENABLE_RESPONSE_TIMING` | boolean | `true` | Enable timing headers | +| `ENABLE_API_VERSION_HEADER` | boolean | `true` | Enable version header | +| `ENABLE_SCHEMA_VERSION_HEADER` | boolean | `true` | Enable schema header | +| `ENABLE_REQUEST_LOGGING` | boolean | `true` | Enable request logging | +| `INDEXER_JITTER_FACTOR` | number | `0.1` | Jitter factor (0-1) | +| `BACKGROUND_JOB_LOCK_TTL_MS` | number | `300000` | Job lock TTL (5 min) | +| `CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS` | number | `500` | Slow query threshold | +| `INDEXER_CURSOR_STALE_AGE_WARNING_MS` | number | `300000` | Stale cursor warning (5 min) | +| `PAYSTACK_PUBLIC_KEY` | string | (optional) | Paystack public key | + +**Startup Behavior:** Uses default if not provided in environment. + +### Derived Configuration + +Some configuration values are computed from other values: + +```typescript +export const appConfig = { + allowedOrigins: [ + 'http://localhost:5173', + 'http://localhost:3000', + envConfig.FRONTEND_URL, + ].filter(Boolean), +}; +``` + +**Source:** Computed at startup from `envConfig` values. + +## Configuration by Environment + +### Development + +**Recommended `.env` values:** +```bash +MODE=development +PORT=3000 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/accesslayer +BACKEND_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:5173 +APP_SECRET=your_32_character_long_secret_string_here +``` + +**Behavior:** +- Verbose logging (includes query logs) +- Detailed error messages with stack traces +- Higher rate limits +- Development-specific CORS origins + +### Production + +**Required environment variables:** +```bash +MODE=production +PORT=3000 +DATABASE_URL=postgresql://user:pass@host:5432/db +BACKEND_URL=https://api.yourdomain.com +FRONTEND_URL=https://yourdomain.com +APP_SECRET= +# ... all other required variables +``` + +**Behavior:** +- Minimal logging (errors only) +- Generic error messages (no stack traces) +- Stricter rate limits +- Production CORS origins only + +### Test + +**Recommended `.env.test` values:** +```bash +MODE=test +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/accesslayer_test +# ... minimal required values for tests +``` + +**Behavior:** +- Test-specific database +- Minimal logging +- Fast timeouts + +## Type Coercion + +The configuration system automatically coerces string values to appropriate types: + +### Number Coercion + +```typescript +PORT: z.coerce.number().default(3000) +``` + +**Examples:** +- `PORT=4000` → `4000` (number) +- `PORT="4000"` → `4000` (number) +- `PORT=invalid` → validation error + +### Boolean Coercion + +```typescript +ENABLE_RESPONSE_TIMING: z.coerce.boolean().default(true) +``` + +**Examples:** +- `ENABLE_RESPONSE_TIMING=true` → `true` +- `ENABLE_RESPONSE_TIMING=false` → `false` +- `ENABLE_RESPONSE_TIMING=1` → `true` +- `ENABLE_RESPONSE_TIMING=0` → `false` +- `ENABLE_RESPONSE_TIMING=""` → `false` + +### Enum Validation + +```typescript +MODE: z.enum(['development', 'production', 'test']).default('development') +``` + +**Examples:** +- `MODE=production` → `"production"` +- `MODE=invalid` → validation error + +## Validation Rules + +### String Validation + +```typescript +DATABASE_URL: z.string().min(1, 'DATABASE_URL is required') +``` +- Must be non-empty string +- Fails with custom error message if missing + +### URL Validation + +```typescript +FRONTEND_URL: z.string().url('FRONTEND_URL must be a valid URL') +``` +- Must be valid URL format +- Includes protocol (http:// or https://) + +### Number Range Validation + +```typescript +INDEXER_JITTER_FACTOR: z.coerce.number().min(0).max(1).default(0.1) +``` +- Must be between 0 and 1 +- Coerced from string to number + +### Positive Integer Validation + +```typescript +BACKGROUND_JOB_LOCK_TTL_MS: z.coerce.number().int().positive().default(300000) +``` +- Must be positive integer +- No decimals allowed + +## Configuration Access Patterns + +### Direct Access + +```typescript +import { envConfig } from './config'; + +const port = envConfig.PORT; +const mode = envConfig.MODE; +``` + +### Conditional Logic + +```typescript +if (envConfig.MODE === 'development') { + // Development-specific behavior +} + +if (envConfig.ENABLE_REQUEST_LOGGING) { + // Enable logging middleware +} +``` + +### Default Parameters + +```typescript +function warnIfStale( + lastUpdated: Date, + thresholdMs: number = envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS +) { + // Use config value as default +} +``` + +## Troubleshooting + +### Startup Fails with "Required" Error + +**Error:** +``` +ZodError: DATABASE_URL is required in the environment variables +``` + +**Solution:** +1. Check `.env` file exists in project root +2. Verify variable is defined: `DATABASE_URL=...` +3. Ensure no typos in variable name +4. Restart server after changing `.env` + +### Type Validation Errors + +**Error:** +``` +ZodError: Expected number, received string +``` + +**Solution:** +1. Check value format matches expected type +2. For numbers: use digits only (e.g., `PORT=3000`) +3. For booleans: use `true`/`false` or `1`/`0` +4. For enums: use exact allowed values + +### URL Validation Errors + +**Error:** +``` +ZodError: FRONTEND_URL must be a valid URL +``` + +**Solution:** +1. Include protocol: `https://example.com` not `example.com` +2. Check for typos in URL +3. Ensure no trailing spaces + +### Environment Variables Not Loading + +**Symptoms:** +- Defaults used instead of `.env` values +- Changes to `.env` not reflected + +**Solution:** +1. Verify `.env` file is in project root (not `src/`) +2. Restart server after changing `.env` +3. Check file is named exactly `.env` (not `.env.txt`) +4. Verify no syntax errors in `.env` file + +## Security Best Practices + +### Secrets Management + +**Development:** +- Use `.env` file (gitignored) +- Never commit `.env` to version control +- Use `.env.example` as template + +**Production:** +- Use environment variables from hosting platform +- Use secrets management service (AWS Secrets Manager, etc.) +- Rotate secrets regularly + +### APP_SECRET + +**Requirements:** +- Minimum 32 characters +- Use cryptographically random string +- Different value per environment + +**Generate secure secret:** +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +### Database Credentials + +**Best Practices:** +- Use strong passwords +- Limit database user permissions +- Use SSL/TLS for connections +- Rotate credentials regularly + +## Configuration File Reference + +### Primary Files + +| File | Purpose | Version Control | +|------|---------|-----------------| +| `.env` | Local environment variables | ❌ Gitignored | +| `.env.example` | Template with defaults | ✅ Committed | +| `src/config.ts` | Schema and validation | ✅ Committed | + +### Configuration Flow + +``` +.env file + ↓ +process.env (merged with system env) + ↓ +dotenv.config() (loads .env) + ↓ +envSchema.parse(process.env) (validates) + ↓ +envConfig (typed, validated config) + ↓ +Application code +``` + +## Adding New Configuration + +### 1. Add to Schema + +Edit `src/config.ts`: + +```typescript +export const envSchema = z.object({ + // ... existing fields + NEW_CONFIG_VALUE: z.string().default('default-value'), +}); +``` + +### 2. Add to .env.example + +Edit `.env.example`: + +```bash +# Description of what this does +NEW_CONFIG_VALUE=example-value +``` + +### 3. Document in This File + +Add to appropriate table in this documentation. + +### 4. Use in Code + +```typescript +import { envConfig } from './config'; + +const value = envConfig.NEW_CONFIG_VALUE; +``` + +## Related Documentation + +- [API Versioning](./api-versioning.md) - API version configuration +- [Query Debug](./query-normalization-debug.md) - Debug logging configuration +- [README](../README.md) - Local setup instructions diff --git a/docs/creator-feed-empty-filters-test.md b/docs/creator-feed-empty-filters-test.md new file mode 100644 index 0000000..9e9b785 --- /dev/null +++ b/docs/creator-feed-empty-filters-test.md @@ -0,0 +1,357 @@ +# Creator Feed Empty Filters Integration Test + +## Overview + +Integration test suite for the creator feed endpoint (`GET /api/v1/creators`) that verifies response envelope stability and default value handling when various filter combinations return empty results. + +## Purpose + +This test suite ensures that: +1. The response envelope structure remains consistent across all filter combinations +2. Default values are correctly applied when parameters are omitted +3. Empty results are handled gracefully with proper metadata +4. Validation errors are returned for invalid parameters + +## Test File + +**Location:** `src/modules/creators/creator-feed-empty-filters.integration.test.ts` + +## Test Strategy + +### Minimal Fixtures + +The test uses Jest mocks to simulate empty database results, eliminating the need for: +- Database setup/teardown +- Test data seeding +- Complex fixture management + +**Mock Strategy:** +```typescript +jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); +``` + +This ensures: +- **Deterministic results** - Always returns empty array and 0 count +- **Fast execution** - No database I/O +- **Isolated testing** - Tests controller/serialization logic only + +### Response Envelope Structure + +All tests verify the consistent response shape: + +```typescript +{ + success: true, + data: { + items: [], + meta: { + limit: number, + offset: number, + total: 0, + hasMore: false + } + } +} +``` + +## Test Categories + +### 1. Response Envelope Structure (3 tests) + +Verifies the basic response structure is stable: + +- **items array** - Always present and empty +- **meta object** - Contains all required pagination fields +- **status 200** - Success status even for empty results + +**Why:** Ensures clients can rely on consistent response shape. + +### 2. Default Values (3 tests) + +Verifies default values are applied when parameters are omitted: + +- **Default limit** - Applied when not specified +- **Default offset** - Always 0 when not specified +- **Default sort** - Applied when not specified + +**Why:** Ensures predictable behavior for clients that don't specify all parameters. + +### 3. Empty Filter Combinations (7 tests) + +Tests various filter combinations that return empty results: + +- **No filters** - Empty query object +- **verified=true** - Only verified creators +- **verified=false** - Only unverified creators +- **search** - Text search filter +- **Whitespace search** - Normalized to undefined +- **Empty string search** - Normalized to undefined +- **Combined filters** - verified + search together + +**Why:** Ensures all filter combinations work correctly and return consistent empty responses. + +### 4. Pagination Metadata Consistency (4 tests) + +Verifies pagination metadata is correct for empty results: + +- **total is 0** - No results found +- **hasMore is false** - No additional pages +- **offset reflects request** - Even when empty +- **limit reflects request** - Even when empty + +**Why:** Ensures pagination metadata is accurate for empty result sets. + +### 5. Sort and Order Parameters (3 tests) + +Tests sorting parameters with empty results: + +- **sort parameter** - Accepted but no effect on empty results +- **order parameter** - Accepted but no effect on empty results +- **Combined sort + order** - Both parameters work together + +**Why:** Ensures sorting parameters don't cause errors with empty results. + +### 6. Complex Filter Combinations (1 test) + +Tests all parameters combined: + +```typescript +{ + limit: 15, + offset: 30, + sort: 'displayName', + order: 'asc', + verified: true, + search: 'test' +} +``` + +**Why:** Ensures the system handles complex queries correctly. + +### 7. Response Envelope Stability (1 test) + +Iterates through multiple filter combinations and verifies consistent structure: + +```typescript +const testCases = [ + {}, + { verified: 'true' }, + { search: 'test' }, + { verified: 'false', search: 'alice' }, + { limit: '5', offset: '10' }, +]; +``` + +**Why:** Proves response shape is stable across all filter variations. + +### 8. Validation Error Handling (4 tests) + +Tests invalid parameter handling: + +- **Invalid limit** - Returns 400 error +- **Invalid offset** - Returns 400 error +- **Invalid sort** - Returns 400 error +- **Invalid order** - Returns 400 error + +**Why:** Ensures proper error responses for malformed requests. + +## Running the Tests + +### Run All Creator Tests + +```bash +npm test -- creators +``` + +### Run This Specific Test File + +```bash +npm test -- creator-feed-empty-filters.integration.test.ts +``` + +### Run with Coverage + +```bash +npm test -- --coverage creator-feed-empty-filters.integration.test.ts +``` + +### Run in Watch Mode + +```bash +npm test -- --watch creator-feed-empty-filters.integration.test.ts +``` + +## Test Coverage + +### Lines Covered + +- Controller: `httpListCreators` +- Serializers: `serializeCreatorListResponse` +- Validators: `CreatorListQuerySchema` +- Utilities: `buildCreatorListRequestContext` + +### Not Covered (By Design) + +- Database queries (mocked) +- Actual filter logic (unit tested separately) +- Non-empty result scenarios (separate tests) + +## Expected Behavior + +### Successful Empty Response + +**Request:** +```bash +GET /api/v1/creators?verified=true&search=nonexistent +``` + +**Response:** +```json +{ + "success": true, + "data": { + "items": [], + "meta": { + "limit": 20, + "offset": 0, + "total": 0, + "hasMore": false + } + } +} +``` + +### Validation Error Response + +**Request:** +```bash +GET /api/v1/creators?limit=invalid +``` + +**Response:** +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid query parameters", + "details": [ + { + "field": "limit", + "message": "Expected number, received string" + } + ] + } +} +``` + +## Filter Normalization + +The test verifies that certain inputs are normalized: + +| Input | Normalized To | Reason | +|-------|---------------|--------| +| `search=""` | `undefined` | Empty string has no search value | +| `search=" "` | `undefined` | Whitespace-only is meaningless | +| `search=" alice "` | `"alice"` | Trimmed whitespace | +| `verified="true"` | `true` (boolean) | String to boolean coercion | +| `verified="false"` | `false` (boolean) | String to boolean coercion | + +## Deterministic Behavior + +All tests are deterministic because: + +1. **Mocked data source** - Always returns `[[], 0]` +2. **No external dependencies** - No database, network, or file I/O +3. **Controlled inputs** - Explicit query parameters +4. **Predictable outputs** - Known response structure + +This ensures: +- Tests never flake +- Results are reproducible +- Fast execution (no I/O wait) + +## Integration with Other Tests + +This test complements: + +- **Unit tests** - `creator-feed-filter-combinator.utils.test.ts` tests filter logic +- **Service tests** - `creators.utils.test.ts` tests database queries +- **Controller tests** - This file tests end-to-end controller behavior + +## Maintenance + +### Adding New Filter Parameters + +When adding a new filter parameter: + +1. Add test for the new parameter alone +2. Add test for the new parameter combined with existing filters +3. Add validation error test for invalid values +4. Update the "all filters combined" test + +### Modifying Response Structure + +If the response envelope changes: + +1. Update all assertions checking response structure +2. Verify backward compatibility +3. Update documentation + +### Changing Default Values + +If default values change: + +1. Update assertions in "Default Values" tests +2. Document the change +3. Consider backward compatibility + +## Troubleshooting + +### Test Fails: "Expected property 'items'" + +**Cause:** Response structure changed + +**Fix:** Verify `sendSuccess` wrapper and serializer output + +### Test Fails: "Expected status 200, received 400" + +**Cause:** Query validation changed + +**Fix:** Check `CreatorListQuerySchema` for new validation rules + +### Test Fails: Mock not called + +**Cause:** Controller logic changed + +**Fix:** Verify `httpListCreators` still calls `fetchCreatorList` + +## Related Files + +- `src/modules/creators/creators.controllers.ts` - Controller under test +- `src/modules/creators/creators.schemas.ts` - Query validation +- `src/modules/creators/creators.serializers.ts` - Response serialization +- `src/modules/creators/creators.utils.ts` - Mocked utility functions +- `src/modules/creators/creator-feed-filter-combinator.utils.ts` - Filter logic +- `src/modules/activity/activity-feed-empty.integration.test.ts` - Similar test pattern + +## Best Practices Demonstrated + +1. **Minimal fixtures** - Mock instead of seeding database +2. **Deterministic tests** - No random data or external dependencies +3. **Comprehensive coverage** - Tests all filter combinations +4. **Clear assertions** - Each test has a single, clear purpose +5. **Descriptive names** - Test names explain what they verify +6. **Grouped tests** - Related tests organized with comments +7. **Stable mocks** - Mocks restored after each test +8. **Response validation** - Verifies both structure and values + +## Future Enhancements + +Potential additions: + +1. **Performance tests** - Verify response time for empty results +2. **Concurrent requests** - Test multiple simultaneous requests +3. **Edge cases** - Extreme values (very large offset, etc.) +4. **Include parameter** - Test optional include fields +5. **Rate limiting** - Verify rate limits apply to empty results diff --git a/docs/creator-list-projection-constants.md b/docs/creator-list-projection-constants.md new file mode 100644 index 0000000..f393a0b --- /dev/null +++ b/docs/creator-list-projection-constants.md @@ -0,0 +1,83 @@ +# Creator List Projection Constants + +## Overview + +This document describes the centralized default field projection constants for creator list reads, implemented to eliminate duplication and improve maintainability. + +## Problem + +Previously, creator list queries had duplicated or missing field projections: +- `src/modules/creators/creators.utils.ts` - No `select` clause (fetched all fields) +- `src/modules/creator/creator.service.ts` - Had an `include` clause with user relation + +This led to: +- Inconsistent data fetching across endpoints +- Potential performance issues from over-fetching +- Maintenance burden when field requirements change + +## Solution + +Created a centralized constant `CREATOR_LIST_DEFAULT_SELECT` in `src/constants/creator-list-projection.constants.ts` that defines the minimal fields needed for creator list responses: + +```typescript +export const CREATOR_LIST_DEFAULT_SELECT = { + id: true, + handle: true, + displayName: true, + avatarUrl: true, + isVerified: true, +} as const; +``` + +## Benefits + +1. **Single Source of Truth**: All creator list queries use the same field projection +2. **Performance**: Only fetches necessary fields, reducing database load and network payload +3. **Maintainability**: Changes to required fields only need to be made in one place +4. **Type Safety**: TypeScript ensures consistent usage across the codebase +5. **Consistency**: All creator list endpoints return the same data shape + +## Files Modified + +### Created +- `src/constants/creator-list-projection.constants.ts` - New constant definition + +### Updated +- `src/modules/creators/creators.utils.ts` - Added `select` clause using the constant +- `src/modules/creator/creator.service.ts` - Replaced `include` with `select` using the constant +- `src/modules/creator/creator.service.test.ts` - Updated test expectations and mock data + +## Response Shape + +The response shape remains unchanged. The serializers (`creator-list-item.mapper.ts` and `creators.serializers.ts`) continue to work as before, mapping the selected fields to the public API response format. + +## Usage Example + +```typescript +import { CREATOR_LIST_DEFAULT_SELECT } from '../../constants/creator-list-projection.constants'; + +const creators = await prisma.creatorProfile.findMany({ + where: { isVerified: true }, + select: CREATOR_LIST_DEFAULT_SELECT, +}); +``` + +## Testing + +The test suite has been updated to verify that: +- The correct fields are selected in database queries +- Mock data matches the projection shape +- All existing functionality continues to work as expected + +## Future Considerations + +If additional fields are needed for creator list responses: +1. Add the field to `CREATOR_LIST_DEFAULT_SELECT` +2. Update the corresponding TypeScript types if needed +3. Update serializers if the field should be exposed in the API response +4. Run tests to ensure no regressions + +## Related Constants + +This follows the same pattern as: +- `CREATOR_DETAIL_DEFAULT_SELECT` in `src/constants/creator-detail-include.constants.ts` diff --git a/docs/query-normalization-debug.md b/docs/query-normalization-debug.md new file mode 100644 index 0000000..f82273a --- /dev/null +++ b/docs/query-normalization-debug.md @@ -0,0 +1,322 @@ +# Query Normalization Debug Helper + +## Overview + +The query normalization debug helper provides diagnostic snapshots of query parsing and normalization for troubleshooting and validation purposes. It's designed to be optional, secure, and zero-overhead when not in use. + +## Features + +### 1. Optional Debug Logging +- Only active when logger is set to `debug` level +- Zero performance impact in production (info/warn/error levels) +- Controlled via environment or logger configuration + +### 2. Automatic Sanitization +- Prevents sensitive data leakage in debug logs +- Redacts fields matching sensitive patterns: + - `password`, `token`, `secret`, `key` + - `auth`, `credential`, `email`, `phone` + - `ssn`, `credit`, `card` +- Case-insensitive pattern matching +- Recursive sanitization for nested objects and arrays + +### 3. Comprehensive Snapshots +Each debug snapshot includes: +- **raw**: Original query before normalization +- **normalized**: Parsed and transformed query +- **valid**: Whether validation passed +- **errors**: Validation error details (if any) +- **timestamp**: ISO 8601 timestamp +- **context**: Optional label for identifying query source + +## Usage + +### Basic Usage + +```typescript +import { parsePublicQuery } from '../utils/public-query-parse.utils'; +import { CreatorListQuerySchema } from './creators.schemas'; + +// Add debugContext option to enable debug snapshots +const parsed = parsePublicQuery( + CreatorListQuerySchema, + req.query, + { debugContext: 'creator-list-query' } +); +``` + +### Direct Usage + +```typescript +import { emitQueryNormalizationDebug } from '../utils/query-normalization-debug.utils'; + +// Manually emit a debug snapshot +emitQueryNormalizationDebug({ + raw: req.query, + normalized: validatedQuery, + valid: true, + context: 'custom-query', +}); +``` + +### Reusable Emitter + +```typescript +import { createQueryDebugEmitter } from '../utils/query-normalization-debug.utils'; + +// Create a reusable emitter with fixed context +const debugCreatorQuery = createQueryDebugEmitter('creator-list'); + +// Use it multiple times +debugCreatorQuery({ + raw: req.query, + normalized: validatedQuery, + valid: true, +}); +``` + +## Configuration + +### Enable Debug Logging + +Set the logger level to `debug` in your environment: + +```typescript +// In logger.utils.ts or config +export const logger = pino({ + level: process.env.LOG_LEVEL || 'info', // Change to 'debug' + // ... other config +}); +``` + +Or via environment variable: +```bash +LOG_LEVEL=debug npm run dev +``` + +### Disable Debug Logging (Default) + +Keep logger at `info` level or higher: +```bash +LOG_LEVEL=info npm start +``` + +## Debug Output Example + +When debug logging is enabled, you'll see output like: + +```json +{ + "level": 20, + "time": "2026-04-28T16:30:00.000Z", + "msg": "Query normalization debug snapshot", + "queryNormalization": { + "raw": { + "limit": "20", + "offset": "0", + "sort": "createdAt", + "order": "desc", + "search": " alice " + }, + "normalized": { + "limit": 20, + "offset": 0, + "sort": "createdAt", + "order": "desc", + "search": "alice" + }, + "valid": true, + "timestamp": "2026-04-28T16:30:00.123Z", + "context": "creator-list-query" + } +} +``` + +### With Validation Errors + +```json +{ + "level": 20, + "time": "2026-04-28T16:30:00.000Z", + "msg": "Query normalization debug snapshot", + "queryNormalization": { + "raw": { + "limit": "invalid", + "offset": "0" + }, + "normalized": null, + "valid": false, + "errors": [ + { + "field": "limit", + "message": "Expected number, received string" + } + ], + "timestamp": "2026-04-28T16:30:00.123Z", + "context": "creator-list-query" + } +} +``` + +### With Sensitive Data (Sanitized) + +```json +{ + "level": 20, + "time": "2026-04-28T16:30:00.000Z", + "msg": "Query normalization debug snapshot", + "queryNormalization": { + "raw": { + "username": "alice", + "password": "[REDACTED]", + "email": "[REDACTED]", + "token": "[REDACTED]" + }, + "normalized": null, + "valid": false, + "timestamp": "2026-04-28T16:30:00.123Z", + "context": "auth-query" + } +} +``` + +## Security Considerations + +### Sensitive Field Patterns + +The helper automatically redacts fields matching these patterns: +- `password` - Passwords, passphrases +- `token` - Auth tokens, API tokens, JWT tokens +- `secret` - API secrets, client secrets +- `key` - API keys, encryption keys +- `auth` - Authorization headers, auth codes +- `credential` - User credentials +- `email` - Email addresses (PII) +- `phone` - Phone numbers (PII) +- `ssn` - Social security numbers (PII) +- `credit` - Credit card info +- `card` - Card numbers + +### Adding Custom Patterns + +To add more sensitive patterns, edit `SENSITIVE_FIELD_PATTERNS` in `query-normalization-debug.utils.ts`: + +```typescript +const SENSITIVE_FIELD_PATTERNS = [ + 'password', + 'token', + // ... existing patterns + 'custom_sensitive_field', // Add your pattern +] as const; +``` + +### Best Practices + +1. **Never log in production** - Keep logger at `info` or higher +2. **Review logs before sharing** - Even with sanitization, review debug logs +3. **Limit debug sessions** - Only enable debug logging when actively troubleshooting +4. **Rotate logs** - Ensure debug logs are rotated and not persisted long-term + +## Use Cases + +### 1. Diagnosing Query Parsing Issues + +When users report unexpected query behavior: +```typescript +// Enable debug logging +// Reproduce the issue +// Check logs for normalization snapshots +``` + +### 2. Validating Normalization Logic + +When implementing new query transformations: +```typescript +const parsed = parsePublicQuery( + NewQuerySchema, + testQuery, + { debugContext: 'new-query-test' } +); +// Check debug output to verify transformations +``` + +### 3. Understanding Query Flow + +When onboarding new developers: +```typescript +// Enable debug logging +// Make API requests +// Review debug snapshots to understand query processing +``` + +### 4. Testing Edge Cases + +When testing unusual query inputs: +```typescript +const edgeCases = [ + { limit: ' 10 ', offset: '' }, + { search: ' multiple spaces ' }, + { sort: 'UPPERCASE' }, +]; + +edgeCases.forEach(query => { + parsePublicQuery(schema, query, { debugContext: 'edge-case-test' }); +}); +// Review debug output for each case +``` + +## Performance Impact + +### When Debug Logging is Disabled (Default) +- **Zero overhead** - Early return if debug level not enabled +- **No sanitization** - Sanitization only runs when logging +- **No object cloning** - No memory allocation for snapshots + +### When Debug Logging is Enabled +- **Minimal overhead** - Only sanitization and JSON serialization +- **Async logging** - Pino handles logging asynchronously +- **Bounded memory** - Snapshots are logged and released immediately + +## Testing + +Run the test suite to verify functionality: + +```bash +npx ts-node src/utils/test/query-normalization-debug.utils.test.ts +``` + +Tests cover: +- Debug log emission when enabled/disabled +- Sensitive field sanitization +- Nested object sanitization +- Array sanitization +- Validation error inclusion +- Timestamp generation +- Reusable emitter creation +- Case-insensitive pattern matching +- Null/undefined handling + +## Integration Points + +The debug helper is integrated into: + +1. **`parsePublicQuery`** - Optional `debugContext` parameter +2. **Creator list controller** - Example usage with `creator-list-query` context +3. **Any custom query parser** - Direct usage via `emitQueryNormalizationDebug` + +## Related Files + +- `src/utils/query-normalization-debug.utils.ts` - Main implementation +- `src/utils/test/query-normalization-debug.utils.test.ts` - Test suite +- `src/utils/public-query-parse.utils.ts` - Integration point +- `src/modules/creators/creators.controllers.ts` - Example usage +- `src/utils/logger.utils.ts` - Logger configuration + +## Future Enhancements + +Potential improvements: +- Configurable sanitization patterns via environment +- Query performance metrics in snapshots +- Diff view for before/after normalization +- Export snapshots to external monitoring tools +- Query replay functionality for debugging diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..fe2929a --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,240 @@ +// src/config.test.ts +// Tests for configuration source precedence and validation behavior. + +import { z } from 'zod'; + +/** + * Test configuration schema behavior without affecting actual config. + * These tests validate the documented source precedence rules. + */ + +function assertEqual(actual: any, expected: any, message: string) { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +} + +function assertThrows(fn: () => void, message: string) { + try { + fn(); + throw new Error(`${message}: expected function to throw`); + } catch (_error) { + // Expected to throw + } +} + +function run() { + console.log('Running config source precedence tests...'); + + // Test 1: Environment variable takes precedence over default + { + const schema = z.object({ + PORT: z.coerce.number().default(3000), + }); + + const result = schema.parse({ PORT: '4000' }); + assertEqual(result.PORT, 4000, 'Environment value should override default'); + } + + // Test 2: Default used when environment variable not provided + { + const schema = z.object({ + PORT: z.coerce.number().default(3000), + }); + + const result = schema.parse({}); + assertEqual(result.PORT, 3000, 'Should use default when env var missing'); + } + + // Test 3: Required field fails when not provided + { + const schema = z.object({ + DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), + }); + + assertThrows( + () => schema.parse({}), + 'Should throw when required field missing' + ); + } + + // Test 4: Required field succeeds when provided + { + const schema = z.object({ + DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), + }); + + const result = schema.parse({ DATABASE_URL: 'postgresql://...' }); + assertEqual( + result.DATABASE_URL, + 'postgresql://...', + 'Should use provided required value' + ); + } + + // Test 5: Type coercion for numbers + { + const schema = z.object({ + PORT: z.coerce.number(), + }); + + const result = schema.parse({ PORT: '4000' }); + assertEqual(result.PORT, 4000, 'Should coerce string to number'); + assertEqual(typeof result.PORT, 'number', 'Result should be number type'); + } + + // Test 6: Type coercion for booleans + { + const schema = z.object({ + ENABLED: z.coerce.boolean(), + }); + + const result1 = schema.parse({ ENABLED: 'true' }); + assertEqual(result1.ENABLED, true, 'Should coerce "true" to boolean'); + + const result2 = schema.parse({ ENABLED: 'false' }); + assertEqual(result2.ENABLED, false, 'Should coerce "false" to boolean'); + + const result3 = schema.parse({ ENABLED: '1' }); + assertEqual(result3.ENABLED, true, 'Should coerce "1" to true'); + + const result4 = schema.parse({ ENABLED: '0' }); + assertEqual(result4.ENABLED, false, 'Should coerce "0" to false'); + } + + // Test 7: Enum validation with default + { + const schema = z.object({ + MODE: z.enum(['development', 'production', 'test']).default('development'), + }); + + const result1 = schema.parse({ MODE: 'production' }); + assertEqual(result1.MODE, 'production', 'Should use valid enum value'); + + const result2 = schema.parse({}); + assertEqual(result2.MODE, 'development', 'Should use default enum value'); + + assertThrows( + () => schema.parse({ MODE: 'invalid' }), + 'Should throw for invalid enum value' + ); + } + + // Test 8: Optional field without default + { + const schema = z.object({ + OPTIONAL_KEY: z.string().optional(), + }); + + const result1 = schema.parse({ OPTIONAL_KEY: 'value' }); + assertEqual(result1.OPTIONAL_KEY, 'value', 'Should use provided optional value'); + + const result2 = schema.parse({}); + assertEqual( + result2.OPTIONAL_KEY, + undefined, + 'Should be undefined when optional not provided' + ); + } + + // Test 9: URL validation + { + const schema = z.object({ + FRONTEND_URL: z.string().url('Must be valid URL'), + }); + + const result = schema.parse({ FRONTEND_URL: 'https://example.com' }); + assertEqual( + result.FRONTEND_URL, + 'https://example.com', + 'Should accept valid URL' + ); + + assertThrows( + () => schema.parse({ FRONTEND_URL: 'not-a-url' }), + 'Should throw for invalid URL' + ); + } + + // Test 10: Number range validation + { + const schema = z.object({ + JITTER: z.coerce.number().min(0).max(1).default(0.1), + }); + + const result1 = schema.parse({ JITTER: '0.5' }); + assertEqual(result1.JITTER, 0.5, 'Should accept value in range'); + + const result2 = schema.parse({}); + assertEqual(result2.JITTER, 0.1, 'Should use default'); + + assertThrows( + () => schema.parse({ JITTER: '2' }), + 'Should throw for value above max' + ); + + assertThrows( + () => schema.parse({ JITTER: '-1' }), + 'Should throw for value below min' + ); + } + + // Test 11: Positive integer validation + { + const schema = z.object({ + TTL_MS: z.coerce.number().int().positive().default(300000), + }); + + const result1 = schema.parse({ TTL_MS: '500000' }); + assertEqual(result1.TTL_MS, 500000, 'Should accept positive integer'); + + const result2 = schema.parse({}); + assertEqual(result2.TTL_MS, 300000, 'Should use default'); + + assertThrows( + () => schema.parse({ TTL_MS: '-100' }), + 'Should throw for negative value' + ); + + assertThrows( + () => schema.parse({ TTL_MS: '100.5' }), + 'Should throw for non-integer' + ); + } + + // Test 12: Empty string handling + { + const schema = z.object({ + VALUE: z.string().min(1, 'Must not be empty'), + }); + + assertThrows( + () => schema.parse({ VALUE: '' }), + 'Should throw for empty string when min(1)' + ); + } + + // Test 13: Precedence - environment over default + { + const schema = z.object({ + PORT: z.coerce.number().default(3000), + MODE: z.enum(['development', 'production']).default('development'), + }); + + const result = schema.parse({ + PORT: '5000', + MODE: 'production', + }); + + assertEqual(result.PORT, 5000, 'Environment PORT should override default'); + assertEqual( + result.MODE, + 'production', + 'Environment MODE should override default' + ); + } + + console.log('✓ All config source precedence tests passed'); +} + +run(); diff --git a/src/config.ts b/src/config.ts index c1755cc..c8ae43e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,22 +1,42 @@ import { z } from 'zod'; import dotenv from 'dotenv'; +// Load environment variables from .env file +// Note: Does not override existing environment variables dotenv.config(); +/** + * Environment configuration schema with validation and defaults. + * + * Configuration Source Precedence (highest to lowest): + * 1. Environment Variables (.env file or system environment) + * 2. Schema Defaults (defined below with .default()) + * 3. Validation Failure (startup fails if required field missing) + * + * See docs/configuration.md for complete documentation. + * See docs/CONFIG_SOURCE_PRECEDENCE.md for visual reference. + */ export const envSchema = z.object({ + // Server Configuration PORT: z.coerce.number().default(3000), MODE: z.enum(['development', 'production', 'test']).default('development'), + + // Database (Required) DATABASE_URL: z .string() .min(1, 'DATABASE_URL is required in the environment variables'), + + // Security (Optional with dev default - MUST override in production) APP_SECRET: z .string() .min(32, 'APP_SECRET should be at least 32 characters') .default('accesslayer_default_development_secret_key_32_bytes_long'), + // Email Configuration (Required) GMAIL_USER: z.string(), GMAIL_APP_PASSWORD: z.string(), - // Google OAuth + + // Google OAuth (Required) GOOGLE_CLIENT_ID: z .string() .min(1, 'GOOGLE_CLIENT_ID is required for Google OAuth'), @@ -24,14 +44,14 @@ export const envSchema = z.object({ .string() .min(1, 'GOOGLE_CLIENT_SECRET is required for Google OAuth'), - // URLs + // URLs (Required) BACKEND_URL: z.string().url(), FRONTEND_URL: z .string() .url('FRONTEND_URL must be a valid URL') .min(1, 'FRONTEND_URL is required'), - // Cloudinary + // Cloudinary (Required) CLOUDINARY_CLOUD_NAME: z .string() .min(1, 'CLOUDINARY_CLOUD_NAME is required for image uploads'), @@ -42,6 +62,7 @@ export const envSchema = z.object({ .string() .min(1, 'CLOUDINARY_API_SECRET is required for image uploads'), + // Payment Processing (Required) PAYSTACK_SECRET_KEY: z .string() .min(1, 'PAYSTACK_SECRET_KEY is required for payment processing'), @@ -49,19 +70,40 @@ export const envSchema = z.object({ .string() .min(1, 'PAYSTACK_PUBLIC_KEY is required for payment processing') .optional(), + + // API Configuration (Optional with defaults) ENABLE_RESPONSE_TIMING: z.coerce.boolean().default(true), API_VERSION: z.string().default('1.0.0'), ENABLE_API_VERSION_HEADER: z.coerce.boolean().default(true), ENABLE_SCHEMA_VERSION_HEADER: z.coerce.boolean().default(true), ENABLE_REQUEST_LOGGING: z.coerce.boolean().default(true), + + // Indexer Configuration (Optional with defaults) INDEXER_JITTER_FACTOR: z.coerce.number().min(0).max(1).default(0.1), BACKGROUND_JOB_LOCK_TTL_MS: z.coerce.number().int().positive().default(300000), CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), INDEXER_CURSOR_STALE_AGE_WARNING_MS: z.coerce.number().int().positive().default(300000), }); +/** + * Validated and typed environment configuration. + * + * This object is immutable and available for import throughout the application. + * Configuration values are resolved at startup and do not change at runtime. + * + * @example + * import { envConfig } from './config'; + * + * const port = envConfig.PORT; + * const isProduction = envConfig.MODE === 'production'; + */ export const envConfig = envSchema.parse(process.env); +/** + * Derived application configuration. + * + * These values are computed from envConfig at startup. + */ export const appConfig = { allowedOrigins: [ 'http://localhost:5173', diff --git a/src/constants/creator-list-projection.constants.ts b/src/constants/creator-list-projection.constants.ts new file mode 100644 index 0000000..63858a1 --- /dev/null +++ b/src/constants/creator-list-projection.constants.ts @@ -0,0 +1,29 @@ +// src/constants/creator-list-projection.constants.ts +// Centralized default field projection constants for creator list reads. +// Route and service layers should reference these instead of inlining field lists. + +/** + * Prisma select fields returned for every creator list read. + * + * This projection includes only the minimal fields needed for list responses, + * reducing payload size and improving query performance. + * + * Keeping this centralized ensures route, service, and test layers + * stay in sync without duplicating field lists. + * + * Fields included: + * - id: Unique identifier for the creator + * - handle: Creator's unique handle/username + * - displayName: Creator's display name + * - avatarUrl: URL to creator's avatar image + * - isVerified: Verification status badge + */ +export const CREATOR_LIST_DEFAULT_SELECT = { + id: true, + handle: true, + displayName: true, + avatarUrl: true, + isVerified: true, +} as const; + +export type CreatorListSelectKeys = keyof typeof CREATOR_LIST_DEFAULT_SELECT; diff --git a/src/modules/creator/creator.controller.ts b/src/modules/creator/creator.controller.ts index 5ff8d50..c540c41 100644 --- a/src/modules/creator/creator.controller.ts +++ b/src/modules/creator/creator.controller.ts @@ -73,7 +73,11 @@ export const listCreators: RequestHandler = async (req, res) => { const ctx = buildCreatorListRequestContext(req); // Parse query using legacy schema - const parsed = parsePublicQuery(LegacyCreatorQuerySchema, ctx.query); + const parsed = parsePublicQuery( + LegacyCreatorQuerySchema, + ctx.query, + { debugContext: 'legacy-creator-list-query' } + ); if (!parsed.ok) { return sendValidationError(res, 'Invalid query parameters', parsed.details); diff --git a/src/modules/creator/creator.service.test.ts b/src/modules/creator/creator.service.test.ts index 95016a6..d4c1a61 100644 --- a/src/modules/creator/creator.service.test.ts +++ b/src/modules/creator/creator.service.test.ts @@ -1,6 +1,7 @@ import { getPaginatedCreators } from './creator.service'; import { prisma } from '../../utils/prisma.utils'; import { CreatorSortOptions } from './creator.utils'; +import { CREATOR_LIST_DEFAULT_SELECT } from '../../constants/creator-list-projection.constants'; jest.mock('../../utils/prisma.utils', () => ({ prisma: { @@ -19,16 +20,10 @@ const baseSort: CreatorSortOptions = { field: 'createdAt', order: 'desc' }; function makeCreator(overrides: Record = {}) { return { id: 'creator-1', - userId: 'user-1', handle: 'alice', displayName: 'Alice', - bio: null, avatarUrl: null, - perkSummary: null, isVerified: false, - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), - user: { avatar: null, firstName: 'Alice', lastName: 'A' }, ...overrides, }; } @@ -50,6 +45,7 @@ describe('getPaginatedCreators', () => { skip: 40, // (3 - 1) * 20 take: 20, orderBy: { createdAt: 'desc' }, + select: CREATOR_LIST_DEFAULT_SELECT, }) ); }); @@ -150,7 +146,10 @@ describe('getPaginatedCreators', () => { }); expect(findMany).toHaveBeenCalledWith( - expect.objectContaining({ orderBy: { displayName: 'asc' } }) + expect.objectContaining({ + orderBy: { displayName: 'asc' }, + select: CREATOR_LIST_DEFAULT_SELECT, + }) ); }); }); diff --git a/src/modules/creator/creator.service.ts b/src/modules/creator/creator.service.ts index b90bb6e..04c8218 100644 --- a/src/modules/creator/creator.service.ts +++ b/src/modules/creator/creator.service.ts @@ -1,6 +1,7 @@ import { CreatorSortOptions, toPrismaOrderBy } from './creator.utils'; import { PaginationMetadata } from '../../utils/api-response.utils'; import { prisma } from '../../utils/prisma.utils'; +import { CREATOR_LIST_DEFAULT_SELECT } from '../../constants/creator-list-projection.constants'; export interface GetCreatorsParams { page: number; @@ -17,15 +18,7 @@ export async function getPaginatedCreators(params: GetCreatorsParams) { skip, take: limit, orderBy: toPrismaOrderBy(sort), - include: { - user: { - select: { - avatar: true, - firstName: true, - lastName: true, - }, - }, - }, + select: CREATOR_LIST_DEFAULT_SELECT, }), prisma.creatorProfile.count(), ]); diff --git a/src/modules/creators/creator-feed-empty-filters.integration.test.ts b/src/modules/creators/creator-feed-empty-filters.integration.test.ts new file mode 100644 index 0000000..7faff39 --- /dev/null +++ b/src/modules/creators/creator-feed-empty-filters.integration.test.ts @@ -0,0 +1,437 @@ +// Integration test: creator feed empty filter combinations +// +// Verifies the complete response envelope and pagination metadata shape +// when various filter combinations are applied to an empty creator feed. +// Uses Jest mocks to keep fixtures minimal and deterministic — no database required. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(query: Record = {}): any { + return { query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — empty feed with filter combinations', () => { + beforeEach(() => { + // Mock fetchCreatorList to return empty results + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Response Envelope Structure ──────────────────────────────────────────── + + it('returns stable response envelope with items array', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + expect(body.data).toHaveProperty('items'); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + }); + + it('returns stable response envelope with meta object', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('meta'); + expect(typeof body.data.meta).toBe('object'); + expect(body.data.meta).toHaveProperty('limit'); + expect(body.data.meta).toHaveProperty('offset'); + expect(body.data.meta).toHaveProperty('total'); + expect(body.data.meta).toHaveProperty('hasMore'); + }); + + it('responds with status 200 for empty results', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + // ── Default Values ────────────────────────────────────────────────────────── + + it('applies default limit when not specified', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + limit: expect.any(Number), + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(typeof body.data.meta.limit).toBe('number'); + expect(body.data.meta.limit).toBeGreaterThan(0); + }); + + it('applies default offset of 0 when not specified', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + offset: 0, + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.offset).toBe(0); + }); + + it('applies default sort when not specified', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + sort: expect.any(String), + order: expect.any(String), + }) + ); + }); + + // ── Empty Filter Combinations ─────────────────────────────────────────────── + + it('handles empty query (no filters)', async () => { + const req = makeReq({}); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: undefined, + search: undefined, + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); + + it('handles verified=true filter with empty results', async () => { + const req = makeReq({ verified: 'true' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: true, + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + expect(body.data.meta.total).toBe(0); + }); + + it('handles verified=false filter with empty results', async () => { + const req = makeReq({ verified: 'false' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: false, + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + expect(body.data.meta.total).toBe(0); + }); + + it('handles search filter with empty results', async () => { + const req = makeReq({ search: 'nonexistent' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + search: 'nonexistent', + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + expect(body.data.meta.total).toBe(0); + }); + + it('handles whitespace-only search (normalized to undefined)', async () => { + const req = makeReq({ search: ' ' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + search: undefined, + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + }); + + it('handles empty string search (normalized to undefined)', async () => { + const req = makeReq({ search: '' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + search: undefined, + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + }); + + it('handles combined verified + search filters with empty results', async () => { + const req = makeReq({ verified: 'true', search: 'alice' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + verified: true, + search: 'alice', + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + expect(body.data.meta.total).toBe(0); + }); + + // ── Pagination Metadata Consistency ───────────────────────────────────────── + + it('meta.total is 0 for empty results', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.total).toBe(0); + }); + + it('meta.hasMore is false for empty results', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.hasMore).toBe(false); + }); + + it('meta.offset reflects requested offset even when empty', async () => { + const req = makeReq({ offset: '20' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.offset).toBe(20); + }); + + it('meta.limit reflects requested limit even when empty', async () => { + const req = makeReq({ limit: '10' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.limit).toBe(10); + }); + + // ── Sort and Order Parameters ─────────────────────────────────────────────── + + it('handles sort parameter with empty results', async () => { + const req = makeReq({ sort: 'displayName' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + sort: 'displayName', + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + }); + + it('handles order parameter with empty results', async () => { + const req = makeReq({ order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + order: 'asc', + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + }); + + it('handles combined sort + order with empty results', async () => { + const req = makeReq({ sort: 'createdAt', order: 'desc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + sort: 'createdAt', + order: 'desc', + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + }); + + // ── Complex Filter Combinations ───────────────────────────────────────────── + + it('handles all filters combined with empty results', async () => { + const req = makeReq({ + limit: '15', + offset: '30', + sort: 'displayName', + order: 'asc', + verified: 'true', + search: 'test', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 15, + offset: 30, + sort: 'displayName', + order: 'asc', + verified: true, + search: 'test', + }) + ); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(0); + expect(body.data.meta).toMatchObject({ + limit: 15, + offset: 30, + total: 0, + hasMore: false, + }); + }); + + // ── Response Envelope Stability ───────────────────────────────────────────── + + it('maintains consistent envelope shape across different filter combinations', async () => { + const testCases: Array> = [ + {}, + { verified: 'true' }, + { search: 'test' }, + { verified: 'false', search: 'alice' }, + { limit: '5', offset: '10' }, + ]; + + for (const query of testCases) { + const req = makeReq(query); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + + // Verify consistent structure + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + expect(body.data).toHaveProperty('items'); + expect(body.data).toHaveProperty('meta'); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + expect(body.data.meta).toHaveProperty('limit'); + expect(body.data.meta).toHaveProperty('offset'); + expect(body.data.meta).toHaveProperty('total', 0); + expect(body.data.meta).toHaveProperty('hasMore', false); + + // Reset mocks for next iteration + jest.clearAllMocks(); + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); + } + }); + + // ── Validation Error Handling ─────────────────────────────────────────────── + + it('returns 400 for invalid limit parameter', async () => { + const req = makeReq({ limit: 'invalid' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + // Should call sendValidationError which sets status 400 + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('returns 400 for invalid offset parameter', async () => { + const req = makeReq({ offset: 'invalid' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('returns 400 for invalid sort parameter', async () => { + const req = makeReq({ sort: 'invalidField' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('returns 400 for invalid order parameter', async () => { + const req = makeReq({ order: 'invalid' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index a2a3133..e4c5d4f 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -26,7 +26,11 @@ export const httpListCreators: AsyncController = async (req, res, next) => { const ctx = buildCreatorListRequestContext(req); // Validate query parameters - const parsed = parsePublicQuery(CreatorListQuerySchema, ctx.query); + const parsed = parsePublicQuery( + CreatorListQuerySchema, + ctx.query, + { debugContext: 'creator-list-query' } + ); if (!parsed.ok) { return sendValidationError(res, 'Invalid query parameters', parsed.details); } diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index 31a2b42..3ceb7c3 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -7,6 +7,7 @@ import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; import { logger } from '../../utils/logger.utils'; import { envConfig } from '../../config'; import { buildCreatorFeedWhere } from './creator-feed-filter-combinator.utils'; +import { CREATOR_LIST_DEFAULT_SELECT } from '../../constants/creator-list-projection.constants'; /** * Fetch paginated list of creators from the database. @@ -30,6 +31,7 @@ export async function fetchCreatorList( orderBy, skip: offset, take: limit, + select: CREATOR_LIST_DEFAULT_SELECT, }), prisma.creatorProfile.count({ where }), ]); diff --git a/src/utils/public-query-parse.utils.ts b/src/utils/public-query-parse.utils.ts index 164689c..59ac771 100644 --- a/src/utils/public-query-parse.utils.ts +++ b/src/utils/public-query-parse.utils.ts @@ -1,4 +1,5 @@ import { z, ZodError, ZodTypeAny } from 'zod'; +import { emitQueryNormalizationDebug } from './query-normalization-debug.utils'; export type PublicQueryValidationDetail = { field: string; @@ -9,25 +10,60 @@ export type PublicQueryParseResult = | { ok: true; data: T } | { ok: false; details: PublicQueryValidationDetail[] }; +export interface ParsePublicQueryOptions { + /** + * Optional context label for debug logging. + * When provided, emits a debug snapshot of the query normalization. + * Only active when logger is set to debug level. + */ + debugContext?: string; +} + /** * Parse and validate public endpoint query params with a predictable output shape. * * This helper is intentionally small and focused: * - maps `ZodError` into `{ field, message }[]` for API validation responses * - does not add runtime behavior beyond schema parsing and error shaping + * - optionally emits debug snapshots when debugContext is provided (debug level only) */ export function parsePublicQuery( schema: S, - rawQuery: unknown + rawQuery: unknown, + options?: ParsePublicQueryOptions ): PublicQueryParseResult> { try { - return { ok: true, data: schema.parse(rawQuery) }; + const data = schema.parse(rawQuery); + + // Emit debug snapshot if context is provided + if (options?.debugContext) { + emitQueryNormalizationDebug({ + raw: rawQuery, + normalized: data, + valid: true, + context: options.debugContext, + }); + } + + return { ok: true, data }; } catch (error) { if (error instanceof ZodError) { const details: PublicQueryValidationDetail[] = error.errors.map(err => ({ field: err.path.join('.'), message: err.message, })); + + // Emit debug snapshot if context is provided + if (options?.debugContext) { + emitQueryNormalizationDebug({ + raw: rawQuery, + normalized: null, + valid: false, + errors: details, + context: options.debugContext, + }); + } + return { ok: false, details }; } throw error; diff --git a/src/utils/query-normalization-debug.utils.ts b/src/utils/query-normalization-debug.utils.ts new file mode 100644 index 0000000..409dceb --- /dev/null +++ b/src/utils/query-normalization-debug.utils.ts @@ -0,0 +1,155 @@ +// src/utils/query-normalization-debug.utils.ts +// Debug helper for emitting normalized query snapshots for diagnostics. +// Only active when logger is set to debug level to avoid performance impact. + +import { logger } from './logger.utils'; + +/** + * Fields that should be sanitized to avoid leaking sensitive data in debug logs. + * Add field names here that might contain PII, tokens, or other sensitive values. + */ +const SENSITIVE_FIELD_PATTERNS = [ + 'password', + 'token', + 'secret', + 'key', + 'auth', + 'credential', + 'email', + 'phone', + 'ssn', + 'credit', + 'card', +] as const; + +/** + * Sanitization marker for sensitive values in debug output. + */ +const SANITIZED_VALUE = '[REDACTED]'; + +/** + * Check if a field name matches any sensitive pattern. + * Case-insensitive matching to catch variations like 'Password', 'AUTH_TOKEN', etc. + */ +function isSensitiveField(fieldName: string): boolean { + const lowerField = fieldName.toLowerCase(); + return SENSITIVE_FIELD_PATTERNS.some(pattern => lowerField.includes(pattern)); +} + +/** + * Recursively sanitize an object by replacing sensitive field values. + * + * @param obj - Object to sanitize + * @returns Sanitized copy of the object + */ +function sanitizeObject(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => sanitizeObject(item)); + } + + if (typeof obj === 'object') { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (isSensitiveField(key)) { + sanitized[key] = SANITIZED_VALUE; + } else { + sanitized[key] = sanitizeObject(value); + } + } + return sanitized; + } + + return obj; +} + +/** + * Query normalization debug snapshot data. + */ +export interface QueryNormalizationSnapshot { + /** Original raw query before normalization */ + raw: unknown; + /** Normalized query after parsing and transformation */ + normalized: unknown; + /** Whether the query passed validation */ + valid: boolean; + /** Validation errors if any */ + errors?: Array<{ field: string; message: string }>; + /** Timestamp of the snapshot */ + timestamp: string; + /** Optional context label for identifying the query source */ + context?: string; +} + +/** + * Emit a debug snapshot of query normalization results. + * + * This helper is only active when the logger level is set to 'debug' or lower. + * It sanitizes sensitive fields before logging to prevent data leakage. + * + * Use this to diagnose query parsing issues, understand normalization behavior, + * or validate that query transformations are working as expected. + * + * @param snapshot - Query normalization snapshot data + * + * @example + * // In a query parser + * const parsed = parsePublicQuery(schema, rawQuery); + * emitQueryNormalizationDebug({ + * raw: rawQuery, + * normalized: parsed.ok ? parsed.data : null, + * valid: parsed.ok, + * errors: parsed.ok ? undefined : parsed.details, + * context: 'creator-list-query', + * }); + */ +export function emitQueryNormalizationDebug( + snapshot: Omit +): void { + // Only emit debug logs if logger is at debug level + if (!logger.isLevelEnabled('debug')) { + return; + } + + const sanitizedSnapshot: QueryNormalizationSnapshot = { + raw: sanitizeObject(snapshot.raw), + normalized: sanitizeObject(snapshot.normalized), + valid: snapshot.valid, + errors: snapshot.errors, + timestamp: new Date().toISOString(), + context: snapshot.context, + }; + + logger.debug({ + msg: 'Query normalization debug snapshot', + queryNormalization: sanitizedSnapshot, + }); +} + +/** + * Create a query normalization debug emitter with a fixed context label. + * + * This is useful for creating a reusable debug helper for a specific endpoint + * or query type without repeating the context label. + * + * @param context - Context label for all snapshots from this emitter + * @returns Function that emits debug snapshots with the fixed context + * + * @example + * const debugCreatorQuery = createQueryDebugEmitter('creator-list'); + * + * // Later in code + * debugCreatorQuery({ + * raw: req.query, + * normalized: validatedQuery, + * valid: true, + * }); + */ +export function createQueryDebugEmitter(context: string) { + return (snapshot: Omit) => { + emitQueryNormalizationDebug({ ...snapshot, context }); + }; +} diff --git a/src/utils/test/query-normalization-debug.utils.test.ts b/src/utils/test/query-normalization-debug.utils.test.ts new file mode 100644 index 0000000..6a6782e --- /dev/null +++ b/src/utils/test/query-normalization-debug.utils.test.ts @@ -0,0 +1,340 @@ +// src/utils/test/query-normalization-debug.utils.test.ts +// Unit tests for query normalization debug helper. + +import { + emitQueryNormalizationDebug, + createQueryDebugEmitter, +} from '../query-normalization-debug.utils'; +import { logger } from '../logger.utils'; + +// Mock logger to capture debug calls +const originalDebug = logger.debug; +const originalIsLevelEnabled = logger.isLevelEnabled; +let debugCalls: any[] = []; + +function mockLogger(debugEnabled: boolean) { + debugCalls = []; + logger.isLevelEnabled = (level: string) => { + if (level === 'debug') return debugEnabled; + return originalIsLevelEnabled.call(logger, level); + }; + logger.debug = (obj: any) => { + debugCalls.push(obj); + }; +} + +function restoreLogger() { + logger.debug = originalDebug; + logger.isLevelEnabled = originalIsLevelEnabled; +} + +function assertEqual(actual: any, expected: any, message: string) { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +} + +function assertOk(value: any, message: string) { + if (!value) { + throw new Error(message); + } +} + +function run() { + console.log('Running query-normalization-debug.utils tests...'); + + // Test 1: Debug logs are emitted when debug level is enabled + { + mockLogger(true); + emitQueryNormalizationDebug({ + raw: { limit: '10', offset: '0' }, + normalized: { limit: 10, offset: 0 }, + valid: true, + context: 'test-query', + }); + + assertEqual(debugCalls.length, 1, 'Should emit one debug log'); + assertEqual( + debugCalls[0].msg, + 'Query normalization debug snapshot', + 'Should have correct message' + ); + assertOk( + debugCalls[0].queryNormalization, + 'Should include queryNormalization data' + ); + assertEqual( + debugCalls[0].queryNormalization.context, + 'test-query', + 'Should include context' + ); + assertEqual( + debugCalls[0].queryNormalization.valid, + true, + 'Should include valid flag' + ); + restoreLogger(); + } + + // Test 2: Debug logs are NOT emitted when debug level is disabled + { + mockLogger(false); + emitQueryNormalizationDebug({ + raw: { limit: '10' }, + normalized: { limit: 10 }, + valid: true, + }); + + assertEqual( + debugCalls.length, + 0, + 'Should not emit debug logs when debug level is disabled' + ); + restoreLogger(); + } + + // Test 3: Sensitive fields are sanitized + { + mockLogger(true); + emitQueryNormalizationDebug({ + raw: { + username: 'alice', + password: 'secret123', + email: 'alice@example.com', + token: 'abc123', + }, + normalized: { + username: 'alice', + password: 'secret123', + email: 'alice@example.com', + }, + valid: true, + context: 'auth-query', + }); + + const snapshot = debugCalls[0].queryNormalization; + assertEqual( + snapshot.raw.password, + '[REDACTED]', + 'Password should be sanitized in raw' + ); + assertEqual( + snapshot.raw.email, + '[REDACTED]', + 'Email should be sanitized in raw' + ); + assertEqual( + snapshot.raw.token, + '[REDACTED]', + 'Token should be sanitized in raw' + ); + assertEqual( + snapshot.raw.username, + 'alice', + 'Non-sensitive field should not be sanitized' + ); + assertEqual( + snapshot.normalized.password, + '[REDACTED]', + 'Password should be sanitized in normalized' + ); + restoreLogger(); + } + + // Test 4: Nested objects are sanitized recursively + { + mockLogger(true); + emitQueryNormalizationDebug({ + raw: { + user: { + name: 'alice', + password: 'secret', + settings: { + apiKey: 'key123', + theme: 'dark', + }, + }, + }, + normalized: null, + valid: false, + }); + + const snapshot = debugCalls[0].queryNormalization; + assertEqual( + snapshot.raw.user.password, + '[REDACTED]', + 'Nested password should be sanitized' + ); + assertEqual( + snapshot.raw.user.settings.apiKey, + '[REDACTED]', + 'Nested API key should be sanitized' + ); + assertEqual( + snapshot.raw.user.name, + 'alice', + 'Nested non-sensitive field should not be sanitized' + ); + assertEqual( + snapshot.raw.user.settings.theme, + 'dark', + 'Nested non-sensitive field should not be sanitized' + ); + restoreLogger(); + } + + // Test 5: Arrays are sanitized recursively + { + mockLogger(true); + emitQueryNormalizationDebug({ + raw: { + users: [ + { name: 'alice', password: 'secret1' }, + { name: 'bob', password: 'secret2' }, + ], + }, + normalized: null, + valid: false, + }); + + const snapshot = debugCalls[0].queryNormalization; + assertEqual( + snapshot.raw.users[0].password, + '[REDACTED]', + 'Array item password should be sanitized' + ); + assertEqual( + snapshot.raw.users[1].password, + '[REDACTED]', + 'Array item password should be sanitized' + ); + assertEqual( + snapshot.raw.users[0].name, + 'alice', + 'Array item non-sensitive field should not be sanitized' + ); + restoreLogger(); + } + + // Test 6: Validation errors are included + { + mockLogger(true); + emitQueryNormalizationDebug({ + raw: { limit: 'invalid' }, + normalized: null, + valid: false, + errors: [ + { field: 'limit', message: 'Expected number, received string' }, + ], + context: 'invalid-query', + }); + + const snapshot = debugCalls[0].queryNormalization; + assertEqual(snapshot.valid, false, 'Should mark as invalid'); + assertOk(snapshot.errors, 'Should include errors'); + assertEqual(snapshot.errors.length, 1, 'Should have one error'); + assertEqual(snapshot.errors[0].field, 'limit', 'Should include error field'); + assertEqual( + snapshot.errors[0].message, + 'Expected number, received string', + 'Should include error message' + ); + restoreLogger(); + } + + // Test 7: Timestamp is added automatically + { + mockLogger(true); + const beforeTime = new Date().toISOString(); + emitQueryNormalizationDebug({ + raw: { test: 'value' }, + normalized: { test: 'value' }, + valid: true, + }); + const afterTime = new Date().toISOString(); + + const snapshot = debugCalls[0].queryNormalization; + assertOk(snapshot.timestamp, 'Should include timestamp'); + assertOk( + snapshot.timestamp >= beforeTime && snapshot.timestamp <= afterTime, + 'Timestamp should be within test execution time' + ); + restoreLogger(); + } + + // Test 8: createQueryDebugEmitter creates a reusable emitter with fixed context + { + mockLogger(true); + const debugCreatorQuery = createQueryDebugEmitter('creator-list'); + + debugCreatorQuery({ + raw: { limit: '20' }, + normalized: { limit: 20 }, + valid: true, + }); + + assertEqual(debugCalls.length, 1, 'Should emit one debug log'); + assertEqual( + debugCalls[0].queryNormalization.context, + 'creator-list', + 'Should use fixed context' + ); + restoreLogger(); + } + + // Test 9: Case-insensitive sensitive field detection + { + mockLogger(true); + emitQueryNormalizationDebug({ + raw: { + PASSWORD: 'secret', + Auth_Token: 'token123', + user_email: 'test@example.com', + }, + normalized: null, + valid: false, + }); + + const snapshot = debugCalls[0].queryNormalization; + assertEqual( + snapshot.raw.PASSWORD, + '[REDACTED]', + 'Uppercase PASSWORD should be sanitized' + ); + assertEqual( + snapshot.raw.Auth_Token, + '[REDACTED]', + 'Mixed case Auth_Token should be sanitized' + ); + assertEqual( + snapshot.raw.user_email, + '[REDACTED]', + 'Field containing email should be sanitized' + ); + restoreLogger(); + } + + // Test 10: Null and undefined values are handled correctly + { + mockLogger(true); + emitQueryNormalizationDebug({ + raw: { field1: null, field2: undefined, field3: 'value' }, + normalized: { field1: null, field2: undefined }, + valid: true, + }); + + const snapshot = debugCalls[0].queryNormalization; + assertEqual(snapshot.raw.field1, null, 'Null should be preserved'); + assertEqual( + snapshot.raw.field2, + undefined, + 'Undefined should be preserved' + ); + assertEqual(snapshot.raw.field3, 'value', 'String value should be preserved'); + restoreLogger(); + } + + console.log('✓ All query-normalization-debug.utils tests passed'); +} + +run();