From d211b641a8acb365ea933136216293d6b7a8a89d Mon Sep 17 00:00:00 2001 From: Eric Date: Sat, 26 Sep 2026 11:57:41 +0100 Subject: [PATCH] Fix issues 376-379 --- src/middleware/requestLogger.js | 26 ++- src/middleware/validate.js | 17 +- src/services/dbHealth.js | 43 ++-- src/utils/circuitBreaker.js | 57 ++--- test/circuitBreaker.test.js | 56 +++-- test/dbHealth.test.js | 63 ++++++ test/health.test.js | 387 ++++++++++++++++++++------------ test/requestLogger.test.js | 115 ++++++---- test/validate.test.js | 66 +++--- 9 files changed, 530 insertions(+), 300 deletions(-) create mode 100644 test/dbHealth.test.js diff --git a/src/middleware/requestLogger.js b/src/middleware/requestLogger.js index 19c5091..0fd711d 100644 --- a/src/middleware/requestLogger.js +++ b/src/middleware/requestLogger.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * Structured logging for every HTTP request/response cycle (issue #148). @@ -10,9 +10,9 @@ * attached to every log line automatically by `logger.js`'s AsyncLocalStorage * context (see requestId.js), so it isn't repeated here explicitly. * - * Uses `req.path` (not `req.originalUrl`) so query strings — which can carry - * an API key on some endpoints — are never logged, consistent with - * logger.js's redaction of sensitive fields elsewhere. + * Uses the mount path plus `req.path` (not `req.originalUrl`) so mounted + * routers are logged accurately without exposing query strings, which can + * carry API keys on some endpoints. * * Also emits a distinct "Slow request detected" warn-level line for any * request over SLOW_REQUEST_THRESHOLD_MS (default 1s, issue #244), so slow @@ -20,20 +20,22 @@ * every other normal-speed request at 'info' level. */ -const logger = require('../logger'); -const config = require('../config'); +const logger = require("../logger"); +const config = require("../config"); function requestLoggerMiddleware(req, res, next) { const startedAt = process.hrtime.bigint(); + const requestPath = req.baseUrl + req.path; - res.on('finish', () => { + res.on("finish", () => { const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6; const roundedDurationMs = Math.round(durationMs * 100) / 100; - const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'; + const level = + res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info"; - logger[level]('HTTP request', { + logger[level]("HTTP request", { method: req.method, - path: req.path, + path: requestPath, statusCode: res.statusCode, durationMs: roundedDurationMs, }); @@ -43,9 +45,9 @@ function requestLoggerMiddleware(req, res, next) { // (2xx) request would otherwise only ever appear at 'info' level mixed // in with every other normal request. if (durationMs > config.slowRequestThresholdMs) { - logger.warn('Slow request detected', { + logger.warn("Slow request detected", { method: req.method, - path: req.path, + path: requestPath, statusCode: res.statusCode, durationMs: roundedDurationMs, thresholdMs: config.slowRequestThresholdMs, diff --git a/src/middleware/validate.js b/src/middleware/validate.js index 4e5f0e4..e233f9d 100644 --- a/src/middleware/validate.js +++ b/src/middleware/validate.js @@ -1,30 +1,31 @@ -'use strict'; +"use strict"; -const AppError = require('../errors/AppError'); +const AppError = require("../errors/AppError"); function flattenZodIssues(error) { return error.issues.reduce((fields, issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : '_root'; + const path = issue.path.length > 0 ? issue.path.join(".") : "_root"; fields[path] = fields[path] || []; fields[path].push(issue.message); return fields; }, {}); } -function validate(schema, source = 'body') { +function validate(schema, source = "body") { return (req, _res, next) => { const result = schema.safeParse(req[source] ?? {}); if (!result.success) { - return next(new AppError('VALIDATION_ERROR', 'Validation failed', 400, { - fields: flattenZodIssues(result.error), - })); + return next( + new AppError("VALIDATION_ERROR", "Validation failed", 400, { + fields: flattenZodIssues(result.error), + }), + ); } req.validated = { ...(req.validated || {}), [source]: result.data, }; - req[source] = result.data; return next(); }; } diff --git a/src/services/dbHealth.js b/src/services/dbHealth.js index d36cde9..580f2ef 100644 --- a/src/services/dbHealth.js +++ b/src/services/dbHealth.js @@ -1,25 +1,34 @@ -'use strict'; +"use strict"; -/** - * Lightweight database configuration check. - * - * The database (added for api_key_audit_logs, see migrations) is not yet on - * any live request path — nothing in the app queries it at runtime. So - * `/health` only reports whether a connection string is *configured* - * (via the same resolved config the migration CLI uses, including its - * dev/test defaults) rather than actually opening a connection: attempting - * a real ping here would make `/health` depend on a dependency the app - * doesn't actually use yet, and could flap the endpoint on a DB blip that - * doesn't affect anything real. - */ +const knexFactory = require("knex"); +const config = require("../config"); -const config = require('../config'); +let db = null; -function checkDatabase() { +async function checkDatabase() { if (!config.databaseUrl) { - return { configured: false, checked: false, status: 'unavailable' }; + return { configured: false, checked: false, status: "unavailable" }; + } + + try { + if (!db) { + db = knexFactory({ + client: "pg", + connection: { + connectionString: config.databaseUrl, + connectionTimeoutMillis: 1000, + query_timeout: 1000, + }, + acquireConnectionTimeout: 1000, + pool: { min: 0, max: 1 }, + }); + } + + await db.raw("SELECT 1"); + return { configured: true, checked: true, status: "ok" }; + } catch (_err) { + return { configured: true, checked: true, status: "error" }; } - return { configured: true, checked: false, status: 'unused' }; } module.exports = { checkDatabase }; diff --git a/src/utils/circuitBreaker.js b/src/utils/circuitBreaker.js index 7dc9786..1712ee0 100644 --- a/src/utils/circuitBreaker.js +++ b/src/utils/circuitBreaker.js @@ -1,11 +1,11 @@ -'use strict'; +"use strict"; -const logger = require('../logger'); +const logger = require("../logger"); const STATES = Object.freeze({ - CLOSED: 'closed', - OPEN: 'open', - HALF_OPEN: 'half-open', + CLOSED: "closed", + OPEN: "open", + HALF_OPEN: "half-open", }); class CircuitBreaker { @@ -34,25 +34,15 @@ class CircuitBreaker { } /** - * Wraps a call in the breaker's failure accounting. `fn` returning - * `null`/`undefined` is treated exactly like a thrown error — it counts - * as a failure and can trip the breaker OPEN. - * - * Contract for callers: only pass `fn` here for a call the wrapped source - * is actually expected to be able to answer. If a source can never serve - * a given request (e.g. an asset it doesn't track at all), that's a - * permanent, per-request condition, not a signal about the source's - * health — decide that *before* calling `call()`, and skip it entirely - * rather than letting a "not supported" response reach here as a `null`. - * priceOracle.js's `fetchFromAllSources` does this via each source's - * `isSupported(assetCode, issuer)` (see #130); any future caller wrapping - * a new per-item resource in a shared breaker should do the same. + * Wraps a call in the breaker's failure accounting. Any resolved value, + * including `null` or `undefined`, is a successful call; only thrown + * errors count as failures. */ async call(fn) { this._moveToHalfOpenIfReady(); if (this.state === STATES.OPEN) { - this._logger.info('Circuit breaker open, skipping source call', { + this._logger.info("Circuit breaker open, skipping source call", { source: this.name, state: this.state, }); @@ -60,10 +50,13 @@ class CircuitBreaker { } if (this.state === STATES.HALF_OPEN && this.halfOpenInFlight) { - this._logger.info('Circuit breaker half-open probe already in flight, skipping source call', { - source: this.name, - state: this.state, - }); + this._logger.info( + "Circuit breaker half-open probe already in flight, skipping source call", + { + source: this.name, + state: this.state, + }, + ); return null; } @@ -74,11 +67,7 @@ class CircuitBreaker { try { const result = await fn(); - if (result === null || result === undefined) { - this.recordFailure(); - } else { - this.recordSuccess(); - } + this.recordSuccess(); return result ?? null; } catch (err) { this.recordFailure(); @@ -94,7 +83,7 @@ class CircuitBreaker { if (this.state === STATES.HALF_OPEN) { this.successCount += 1; if (this.successCount >= this.successThreshold) { - this._transitionTo(STATES.CLOSED, { reason: 'success-threshold' }); + this._transitionTo(STATES.CLOSED, { reason: "success-threshold" }); } return; } @@ -106,20 +95,20 @@ class CircuitBreaker { recordFailure() { if (this.state === STATES.HALF_OPEN) { - this._transitionTo(STATES.OPEN, { reason: 'half-open-failure' }); + this._transitionTo(STATES.OPEN, { reason: "half-open-failure" }); return; } if (this.state === STATES.CLOSED) { this.failureCount += 1; if (this.failureCount >= this.failureThreshold) { - this._transitionTo(STATES.OPEN, { reason: 'failure-threshold' }); + this._transitionTo(STATES.OPEN, { reason: "failure-threshold" }); } } } reset() { - this._transitionTo(STATES.CLOSED, { reason: 'manual-reset' }); + this._transitionTo(STATES.CLOSED, { reason: "manual-reset" }); } _moveToHalfOpenIfReady() { @@ -128,7 +117,7 @@ class CircuitBreaker { } if (this._now() - this.openedAt >= this.timeoutMs) { - this._transitionTo(STATES.HALF_OPEN, { reason: 'cooldown-elapsed' }); + this._transitionTo(STATES.HALF_OPEN, { reason: "cooldown-elapsed" }); } } @@ -151,7 +140,7 @@ class CircuitBreaker { this.successCount = 0; this.openedAt = nextState === STATES.OPEN ? this._now() : null; - this._logger.info('Circuit breaker state changed', { + this._logger.info("Circuit breaker state changed", { source: this.name, from: previousState, to: nextState, diff --git a/test/circuitBreaker.test.js b/test/circuitBreaker.test.js index ce65938..c023d74 100644 --- a/test/circuitBreaker.test.js +++ b/test/circuitBreaker.test.js @@ -7,6 +7,14 @@ const mockLogger = { debug: jest.fn(), }; +async function expectFailure(breaker) { + await expect( + breaker.call(async () => { + throw new Error("source unavailable"); + }), + ).rejects.toThrow("source unavailable"); +} + jest.mock("../src/logger", () => mockLogger); const { CircuitBreaker, STATES } = require("../src/utils/circuitBreaker"); @@ -39,8 +47,8 @@ describe("CircuitBreaker", () => { test("opens after repeated failures and skips calls while cooling down", async () => { const { breaker, logger } = buildBreaker(); - await expect(breaker.call(async () => null)).resolves.toBeNull(); - await expect(breaker.call(async () => null)).resolves.toBeNull(); + await expectFailure(breaker); + await expectFailure(breaker); expect(breaker.getState()).toBe(STATES.OPEN); @@ -65,8 +73,8 @@ describe("CircuitBreaker", () => { test("logs the failure count that tripped the breaker, not the post-reset zero", async () => { const { breaker, logger } = buildBreaker({ failureThreshold: 2 }); - await breaker.call(async () => null); // failureCount: 1, still closed - await breaker.call(async () => null); // failureCount: 2 -> trips open + await expectFailure(breaker); // failureCount: 1, still closed + await expectFailure(breaker); // failureCount: 2 -> trips open expect(logger.info).toHaveBeenCalledWith( "Circuit breaker state changed", @@ -84,8 +92,8 @@ describe("CircuitBreaker", () => { test("moves to half-open after cooldown and closes on a successful probe", async () => { const { breaker, advance } = buildBreaker(); - await breaker.call(async () => null); - await breaker.call(async () => null); + await expectFailure(breaker); + await expectFailure(breaker); advance(100); expect(breaker.getState()).toBe(STATES.HALF_OPEN); @@ -98,11 +106,11 @@ describe("CircuitBreaker", () => { test("reopens when the half-open probe fails", async () => { const { breaker, advance } = buildBreaker(); - await breaker.call(async () => null); - await breaker.call(async () => null); + await expectFailure(breaker); + await expectFailure(breaker); advance(100); - await expect(breaker.call(async () => null)).resolves.toBeNull(); + await expectFailure(breaker); expect(breaker.getState()).toBe(STATES.OPEN); }); @@ -120,17 +128,19 @@ describe("CircuitBreaker", () => { expect(breaker.getState()).toBe(STATES.CLOSED); }); - test("treats undefined return as a failure", async () => { + test("treats null and undefined returns as successes", async () => { const { breaker } = buildBreaker(); - await breaker.call(async () => undefined); - expect(breaker.failureCount).toBe(1); + await expect(breaker.call(async () => null)).resolves.toBeNull(); + await expect(breaker.call(async () => undefined)).resolves.toBeNull(); + expect(breaker.failureCount).toBe(0); + expect(breaker.getState()).toBe(STATES.CLOSED); }); test("resets failure count on success in CLOSED state", async () => { const { breaker } = buildBreaker(); - await breaker.call(async () => null); + await expectFailure(breaker); expect(breaker.failureCount).toBe(1); await breaker.call(async () => "ok"); @@ -140,8 +150,8 @@ describe("CircuitBreaker", () => { test("reset() returns breaker to CLOSED immediately", async () => { const { breaker } = buildBreaker(); - await breaker.call(async () => null); - await breaker.call(async () => null); + await expectFailure(breaker); + await expectFailure(breaker); expect(breaker.getState()).toBe(STATES.OPEN); breaker.reset(); @@ -153,8 +163,8 @@ describe("CircuitBreaker", () => { test("skips calls when half-open probe is already in flight", async () => { const { breaker, advance } = buildBreaker(); - await breaker.call(async () => null); - await breaker.call(async () => null); + await expectFailure(breaker); + await expectFailure(breaker); advance(100); expect(breaker.getState()).toBe(STATES.HALF_OPEN); @@ -202,16 +212,16 @@ describe("CircuitBreaker", () => { test("isOpen returns true when open", async () => { const { breaker } = buildBreaker(); - await breaker.call(async () => null); - await breaker.call(async () => null); + await expectFailure(breaker); + await expectFailure(breaker); expect(breaker.isOpen()).toBe(true); }); test("multiple successes in half-open are needed when successThreshold > 1", async () => { const { breaker, advance } = buildBreaker({ successThreshold: 2 }); - await breaker.call(async () => null); - await breaker.call(async () => null); + await expectFailure(breaker); + await expectFailure(breaker); advance(100); expect(breaker.getState()).toBe(STATES.HALF_OPEN); @@ -227,11 +237,11 @@ describe("CircuitBreaker", () => { const { breaker } = buildBreaker({ failureThreshold: 5 }); for (let i = 0; i < 4; i++) { - await breaker.call(async () => null); + await expectFailure(breaker); } expect(breaker.getState()).toBe(STATES.CLOSED); - await breaker.call(async () => null); + await expectFailure(breaker); expect(breaker.getState()).toBe(STATES.OPEN); }); }); diff --git a/test/dbHealth.test.js b/test/dbHealth.test.js new file mode 100644 index 0000000..e075282 --- /dev/null +++ b/test/dbHealth.test.js @@ -0,0 +1,63 @@ +"use strict"; + +const mockConfig = { databaseUrl: "postgres://localhost/smartdrop" }; +const mockKnexFactory = jest.fn(); + +jest.mock("../src/config", () => mockConfig); +jest.mock("knex", () => mockKnexFactory); + +function loadDbHealth() { + jest.resetModules(); + return require("../src/services/dbHealth"); +} + +describe("database health check", () => { + beforeEach(() => { + mockConfig.databaseUrl = "postgres://localhost/smartdrop"; + mockKnexFactory.mockReset(); + }); + + test("pings a configured database and reports success", async () => { + const raw = jest.fn().mockResolvedValue([{ "?column?": 1 }]); + mockKnexFactory.mockReturnValue({ raw }); + const { checkDatabase } = loadDbHealth(); + + await expect(checkDatabase()).resolves.toEqual({ + configured: true, + checked: true, + status: "ok", + }); + expect(mockKnexFactory).toHaveBeenCalledWith( + expect.objectContaining({ + client: "pg", + acquireConnectionTimeout: 1000, + }), + ); + expect(raw).toHaveBeenCalledWith("SELECT 1"); + }); + + test("reports an error when the database ping fails", async () => { + mockKnexFactory.mockReturnValue({ + raw: jest.fn().mockRejectedValue(new Error("offline")), + }); + const { checkDatabase } = loadDbHealth(); + + await expect(checkDatabase()).resolves.toEqual({ + configured: true, + checked: true, + status: "error", + }); + }); + + test("does not create a client when the database is not configured", async () => { + mockConfig.databaseUrl = null; + const { checkDatabase } = loadDbHealth(); + + await expect(checkDatabase()).resolves.toEqual({ + configured: false, + checked: false, + status: "unavailable", + }); + expect(mockKnexFactory).not.toHaveBeenCalled(); + }); +}); diff --git a/test/health.test.js b/test/health.test.js index 8c64339..87f4f80 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -1,12 +1,17 @@ -'use strict'; +"use strict"; -const request = require('supertest'); -const { createCacheMock } = require('./helpers/cacheMock'); +const request = require("supertest"); +const { createCacheMock } = require("./helpers/cacheMock"); -jest.mock('../src/services/cache', () => ({ +jest.mock("../src/services/cache", () => ({ isConnected: jest.fn(() => false), disconnect: jest.fn(), - getConcurrencyStats: jest.fn(() => ({ active: 0, waiting: 0, available: 50, max: 50 })), + getConcurrencyStats: jest.fn(() => ({ + active: 0, + waiting: 0, + available: 50, + max: 50, + })), getCommandQueueLength: jest.fn(() => 0), getClient: () => ({ incr: async () => 1, @@ -14,33 +19,51 @@ jest.mock('../src/services/cache', () => ({ }), })); -jest.mock('../src/services/priceOracle', () => ({ +jest.mock("../src/services/dbHealth", () => ({ + checkDatabase: jest.fn(async () => ({ + configured: true, + checked: true, + status: "ok", + })), +})); + +jest.mock("../src/services/priceOracle", () => ({ getCircuitStates: jest.fn(() => ({ - coingecko: 'closed', - coinmarketcap: 'open', - stellar_dex: 'half-open', + coingecko: "closed", + coinmarketcap: "open", + stellar_dex: "half-open", })), getSourceCircuitStates: jest.fn(() => [ - { source: 'coingecko', open: false, openUntil: null }, - { source: 'coinmarketcap', open: false, openUntil: null }, + { source: "coingecko", open: false, openUntil: null }, + { source: "coinmarketcap", open: false, openUntil: null }, ]), refreshAllCachedPrices: jest.fn(), })); -jest.mock('../src/jobs/priceRefresh', () => ({ +jest.mock("../src/jobs/priceRefresh", () => ({ start: jest.fn(), stop: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); -jest.mock('../src/jobs/webhookRetryWorker', () => ({ +jest.mock("../src/jobs/webhookRetryWorker", () => ({ start: jest.fn(), stop: jest.fn(), tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); -jest.mock('../src/ws/priceWebSocket', () => ({ +jest.mock("../src/ws/priceWebSocket", () => ({ attach: jest.fn(), })); @@ -49,33 +72,35 @@ jest.mock('../src/ws/priceWebSocket', () => ({ // --------------------------------------------------------------------------- function loadApp() { - return require('../src/index').app; + return require("../src/index").app; } // --------------------------------------------------------------------------- // GET /health – price_source_circuits (pre-existing behaviour) // --------------------------------------------------------------------------- -describe('GET /health – price_source_circuits', () => { - test('includes an entry per source that has a circuit breaker', async () => { +describe("GET /health – price_source_circuits", () => { + test("includes an entry per source that has a circuit breaker", async () => { jest.resetModules(); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(Array.isArray(res.body.price_source_circuits)).toBe(true); const sourceNames = res.body.price_source_circuits.map((c) => c.source); - expect(sourceNames).toEqual(expect.arrayContaining(['coingecko', 'coinmarketcap'])); - expect(sourceNames).not.toContain('stellar_dex'); + expect(sourceNames).toEqual( + expect.arrayContaining(["coingecko", "coinmarketcap"]), + ); + expect(sourceNames).not.toContain("stellar_dex"); }); - test('every circuit starts closed with a null openUntil', async () => { + test("every circuit starts closed with a null openUntil", async () => { jest.resetModules(); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); for (const circuit of res.body.price_source_circuits) { expect(circuit.open).toBe(false); @@ -88,55 +113,55 @@ describe('GET /health – price_source_circuits', () => { // GET /health – overall response shape // --------------------------------------------------------------------------- -describe('GET /health – response shape', () => { - test('returns expected top-level fields', async () => { +describe("GET /health – response shape", () => { + test("returns expected top-level fields", async () => { jest.resetModules(); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body.circuits).toEqual({ - coingecko: 'closed', - coinmarketcap: 'open', - stellar_dex: 'half-open', + coingecko: "closed", + coinmarketcap: "open", + stellar_dex: "half-open", }); - expect(res.body).toHaveProperty('status'); - expect(res.body).toHaveProperty('timestamp'); - expect(res.body).toHaveProperty('redis'); - expect(res.body).toHaveProperty('jobs'); - expect(res.body).toHaveProperty('database'); - expect(res.body).toHaveProperty('price_source_circuits'); + expect(res.body).toHaveProperty("status"); + expect(res.body).toHaveProperty("timestamp"); + expect(res.body).toHaveProperty("redis"); + expect(res.body).toHaveProperty("jobs"); + expect(res.body).toHaveProperty("database"); + expect(res.body).toHaveProperty("price_source_circuits"); }); - test('database field reflects configured-but-unused state', async () => { + test("database field reflects a successful database ping", async () => { jest.resetModules(); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.body.database).toEqual({ configured: true, - checked: false, - status: 'unused', + checked: true, + status: "ok", }); }); - test('jobs field contains price_refresh and webhook_retry_worker entries', async () => { + test("jobs field contains price_refresh and webhook_retry_worker entries", async () => { jest.resetModules(); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); - expect(res.body.jobs).toHaveProperty('price_refresh'); - expect(res.body.jobs).toHaveProperty('webhook_retry_worker'); + expect(res.body.jobs).toHaveProperty("price_refresh"); + expect(res.body.jobs).toHaveProperty("webhook_retry_worker"); - for (const key of ['price_refresh', 'webhook_retry_worker']) { + for (const key of ["price_refresh", "webhook_retry_worker"]) { const job = res.body.jobs[key]; - expect(job).toHaveProperty('healthy'); - expect(job).toHaveProperty('last_success_at'); - expect(job).toHaveProperty('last_error'); - expect(job).toHaveProperty('stalled'); + expect(job).toHaveProperty("healthy"); + expect(job).toHaveProperty("last_success_at"); + expect(job).toHaveProperty("last_error"); + expect(job).toHaveProperty("stalled"); } }); }); @@ -145,164 +170,239 @@ describe('GET /health – response shape', () => { // GET /health – status computation // --------------------------------------------------------------------------- -describe('GET /health – status computation', () => { - test('status is ok when Redis is connected and jobs are healthy', async () => { +describe("GET /health – status computation", () => { + test("status is ok when Redis is connected and jobs are healthy", async () => { jest.resetModules(); - jest.mock('../src/services/cache', () => ({ + jest.mock("../src/services/cache", () => ({ isConnected: () => true, disconnect: jest.fn(), - getConcurrencyStats: () => ({ active: 0, waiting: 0, available: 50, max: 50 }), + getConcurrencyStats: () => ({ + active: 0, + waiting: 0, + available: 50, + max: 50, + }), getCommandQueueLength: () => 0, getClient: () => ({ incr: async () => 1, expire: async () => 1, }), })); - jest.mock('../src/jobs/priceRefresh', () => ({ + jest.mock("../src/jobs/priceRefresh", () => ({ start: jest.fn(), stop: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ + jest.mock("../src/jobs/webhookRetryWorker", () => ({ start: jest.fn(), stop: jest.fn(), tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); - expect(res.body.status).toBe('ok'); + expect(res.body.status).toBe("ok"); }); - test('status is unhealthy when Redis is disconnected', async () => { + test("status is unhealthy when Redis is disconnected", async () => { jest.resetModules(); - jest.mock('../src/services/cache', () => ({ + jest.mock("../src/services/cache", () => ({ isConnected: () => false, disconnect: jest.fn(), - getConcurrencyStats: () => ({ active: 0, waiting: 0, available: 50, max: 50 }), + getConcurrencyStats: () => ({ + active: 0, + waiting: 0, + available: 50, + max: 50, + }), getCommandQueueLength: () => 0, getClient: () => ({ incr: async () => 1, expire: async () => 1, }), })); - jest.mock('../src/jobs/priceRefresh', () => ({ + jest.mock("../src/jobs/priceRefresh", () => ({ start: jest.fn(), stop: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ + jest.mock("../src/jobs/webhookRetryWorker", () => ({ start: jest.fn(), stop: jest.fn(), tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); - expect(res.body.status).toBe('unhealthy'); + expect(res.body.status).toBe("unhealthy"); expect(res.body.redis.connected).toBe(false); }); - test('status is unhealthy when a job is stalled', async () => { + test("status is unhealthy when a job is stalled", async () => { jest.resetModules(); - jest.mock('../src/services/cache', () => ({ + jest.mock("../src/services/cache", () => ({ isConnected: () => true, disconnect: jest.fn(), - getConcurrencyStats: () => ({ active: 0, waiting: 0, available: 50, max: 50 }), + getConcurrencyStats: () => ({ + active: 0, + waiting: 0, + available: 50, + max: 50, + }), getCommandQueueLength: () => 0, getClient: () => ({ incr: async () => 1, expire: async () => 1, }), })); - jest.mock('../src/jobs/priceRefresh', () => ({ + jest.mock("../src/jobs/priceRefresh", () => ({ start: jest.fn(), stop: jest.fn(), - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: 'timeout', stalled: true }), + getHealth: () => ({ + healthy: false, + lastSuccessAt: null, + lastError: "timeout", + stalled: true, + }), })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ + jest.mock("../src/jobs/webhookRetryWorker", () => ({ start: jest.fn(), stop: jest.fn(), tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); - expect(res.body.status).toBe('unhealthy'); + expect(res.body.status).toBe("unhealthy"); expect(res.body.jobs.price_refresh.stalled).toBe(true); - expect(res.body.jobs.price_refresh.last_error).toBe('timeout'); + expect(res.body.jobs.price_refresh.last_error).toBe("timeout"); }); - test('status is degraded during startup grace period (job not yet run)', async () => { + test("status is degraded during startup grace period (job not yet run)", async () => { jest.resetModules(); - jest.mock('../src/services/cache', () => ({ + jest.mock("../src/services/cache", () => ({ isConnected: () => true, disconnect: jest.fn(), - getConcurrencyStats: () => ({ active: 0, waiting: 0, available: 50, max: 50 }), + getConcurrencyStats: () => ({ + active: 0, + waiting: 0, + available: 50, + max: 50, + }), getCommandQueueLength: () => 0, getClient: () => ({ incr: async () => 1, expire: async () => 1, }), })); - jest.mock('../src/jobs/priceRefresh', () => ({ + jest.mock("../src/jobs/priceRefresh", () => ({ start: jest.fn(), stop: jest.fn(), // healthy=false, stalled=false → still in grace period - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: null, stalled: false }), + getHealth: () => ({ + healthy: false, + lastSuccessAt: null, + lastError: null, + stalled: false, + }), })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ + jest.mock("../src/jobs/webhookRetryWorker", () => ({ start: jest.fn(), stop: jest.fn(), tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); - expect(res.body.status).toBe('degraded'); + expect(res.body.status).toBe("degraded"); }); - test('overall status is never ok when any dependency is unhealthy', async () => { + test("overall status is never ok when any dependency is unhealthy", async () => { jest.resetModules(); - jest.mock('../src/services/cache', () => ({ + jest.mock("../src/services/cache", () => ({ isConnected: () => false, disconnect: jest.fn(), - getConcurrencyStats: () => ({ active: 0, waiting: 0, available: 50, max: 50 }), + getConcurrencyStats: () => ({ + active: 0, + waiting: 0, + available: 50, + max: 50, + }), getCommandQueueLength: () => 0, getClient: () => ({ incr: async () => 1, expire: async () => 1, }), })); - jest.mock('../src/jobs/priceRefresh', () => ({ + jest.mock("../src/jobs/priceRefresh", () => ({ start: jest.fn(), stop: jest.fn(), - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: 'err', stalled: true }), + getHealth: () => ({ + healthy: false, + lastSuccessAt: null, + lastError: "err", + stalled: true, + }), })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ + jest.mock("../src/jobs/webhookRetryWorker", () => ({ start: jest.fn(), stop: jest.fn(), tick: jest.fn(), - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: 'err', stalled: true }), + getHealth: () => ({ + healthy: false, + lastSuccessAt: null, + lastError: "err", + stalled: true, + }), })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); - expect(res.body.status).not.toBe('ok'); + expect(res.body.status).not.toBe("ok"); }); }); @@ -310,15 +410,15 @@ describe('GET /health – status computation', () => { // priceRefresh.getHealth() – unit tests for grace-period logic // --------------------------------------------------------------------------- -describe('priceRefresh.getHealth() – grace period', () => { - test('returns healthy=false and stalled=false before start() is called', () => { +describe("priceRefresh.getHealth() – grace period", () => { + test("returns healthy=false and stalled=false before start() is called", () => { // Load the real module, bypassing any jest.mock registrations from prior tests - const job = jest.requireActual('../src/jobs/priceRefresh'); + const job = jest.requireActual("../src/jobs/priceRefresh"); // Reset internal state by reloading via isolateModules let freshJob; jest.isolateModules(() => { - jest.unmock('../src/jobs/priceRefresh'); - freshJob = require('../src/jobs/priceRefresh'); + jest.unmock("../src/jobs/priceRefresh"); + freshJob = require("../src/jobs/priceRefresh"); }); const h = freshJob.getHealth(); expect(h.healthy).toBe(false); @@ -330,12 +430,12 @@ describe('priceRefresh.getHealth() – grace period', () => { // webhookRetryWorker.getHealth() – unit tests for grace-period logic // --------------------------------------------------------------------------- -describe('webhookRetryWorker.getHealth() – grace period', () => { - test('returns healthy=false and stalled=false before start() is called', () => { +describe("webhookRetryWorker.getHealth() – grace period", () => { + test("returns healthy=false and stalled=false before start() is called", () => { let freshWorker; jest.isolateModules(() => { - jest.unmock('../src/jobs/webhookRetryWorker'); - freshWorker = require('../src/jobs/webhookRetryWorker'); + jest.unmock("../src/jobs/webhookRetryWorker"); + freshWorker = require("../src/jobs/webhookRetryWorker"); }); const h = freshWorker.getHealth(); expect(h.healthy).toBe(false); @@ -347,18 +447,23 @@ describe('webhookRetryWorker.getHealth() – grace period', () => { // GET /health – webhook retry queue depth (issue #235) // --------------------------------------------------------------------------- -describe('GET /health – webhook_retry_worker queue depth', () => { +describe("GET /health – webhook_retry_worker queue depth", () => { function mockWorkerWithQueueStats(mockQueueStats) { - jest.mock('../src/jobs/webhookRetryWorker', () => ({ + jest.mock("../src/jobs/webhookRetryWorker", () => ({ start: jest.fn(), stop: jest.fn(), tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), + getHealth: () => ({ + healthy: true, + lastSuccessAt: Date.now(), + lastError: null, + stalled: false, + }), getQueueStats: mockQueueStats, })); } - test('reports pending retries, last batch size, and average delivery latency', async () => { + test("reports pending retries, last batch size, and average delivery latency", async () => { jest.resetModules(); mockWorkerWithQueueStats(async () => ({ pendingRetries: 4, @@ -368,18 +473,20 @@ describe('GET /health – webhook_retry_worker queue depth', () => { })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body.jobs.webhook_retry_worker).toEqual(expect.objectContaining({ - pending_retries: 4, - last_batch_size: 2, - avg_delivery_latency_ms: 12.5, - total_retries_processed: 9, - })); + expect(res.body.jobs.webhook_retry_worker).toEqual( + expect.objectContaining({ + pending_retries: 4, + last_batch_size: 2, + avg_delivery_latency_ms: 12.5, + total_retries_processed: 9, + }), + ); }); - test('keeps the existing liveness fields alongside the queue depth fields', async () => { + test("keeps the existing liveness fields alongside the queue depth fields", async () => { jest.resetModules(); mockWorkerWithQueueStats(async () => ({ pendingRetries: 0, @@ -389,16 +496,18 @@ describe('GET /health – webhook_retry_worker queue depth', () => { })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); - expect(res.body.jobs.webhook_retry_worker).toEqual(expect.objectContaining({ - healthy: true, - stalled: false, - pending_retries: 0, - })); + expect(res.body.jobs.webhook_retry_worker).toEqual( + expect.objectContaining({ + healthy: true, + stalled: false, + pending_retries: 0, + }), + ); }); - test('reports null pending retries rather than zero when the queue cannot be read', async () => { + test("reports null pending retries rather than zero when the queue cannot be read", async () => { jest.resetModules(); mockWorkerWithQueueStats(async () => ({ pendingRetries: null, @@ -408,47 +517,49 @@ describe('GET /health – webhook_retry_worker queue depth', () => { })); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body.jobs.webhook_retry_worker.pending_retries).toBeNull(); }); - test('still answers when reading the queue stats throws', async () => { + test("still answers when reading the queue stats throws", async () => { jest.resetModules(); mockWorkerWithQueueStats(async () => { - throw new Error('redis unreachable'); + throw new Error("redis unreachable"); }); const app = loadApp(); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body.jobs.webhook_retry_worker.pending_retries).toBeNull(); }); }); -describe('GET /health – rate limiting', () => { - test('returns 429 after the configured per-IP health limit', async () => { +describe("GET /health – rate limiting", () => { + test("returns 429 after the configured per-IP health limit", async () => { const mockHealthCache = createCacheMock(); const previousMax = process.env.RATE_LIMIT_MAX; - process.env.RATE_LIMIT_MAX = '2'; + process.env.RATE_LIMIT_MAX = "2"; mockHealthCache.reset(); jest.resetModules(); - jest.doMock('../src/services/cache', () => mockHealthCache.cacheMock); + jest.doMock("../src/services/cache", () => mockHealthCache.cacheMock); try { const app = loadApp(); - const first = await request(app).get('/health'); - const second = await request(app).get('/health'); - const blocked = await request(app).get('/health'); + const first = await request(app).get("/health"); + const second = await request(app).get("/health"); + const blocked = await request(app).get("/health"); expect(first.status).toBe(200); expect(second.status).toBe(200); expect(blocked.status).toBe(429); - expect(blocked.body.error.code).toBe('RATE_LIMITED'); - expect(blocked.headers['retry-after']).toBeDefined(); - expect(mockHealthCache.redis.incr.mock.calls[0][0]).toContain('ratelimit:health:'); + expect(blocked.body.error.code).toBe("RATE_LIMITED"); + expect(blocked.headers["retry-after"]).toBeDefined(); + expect(mockHealthCache.redis.incr.mock.calls[0][0]).toContain( + "ratelimit:health:", + ); } finally { if (previousMax === undefined) { delete process.env.RATE_LIMIT_MAX; diff --git a/test/requestLogger.test.js b/test/requestLogger.test.js index c61dd9c..e70615c 100644 --- a/test/requestLogger.test.js +++ b/test/requestLogger.test.js @@ -1,93 +1,122 @@ -'use strict'; +"use strict"; -const express = require('express'); -const request = require('supertest'); +const express = require("express"); +const request = require("supertest"); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), +jest.mock("../src/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), })); +jest.mock("../src/config", () => ({ slowRequestThresholdMs: 1000 })); -const logger = require('../src/logger'); -const requestLoggerMiddleware = require('../src/middleware/requestLogger'); +const logger = require("../src/logger"); +const requestLoggerMiddleware = require("../src/middleware/requestLogger"); function buildApp() { const app = express(); app.use(requestLoggerMiddleware); - app.get('/ok', (req, res) => res.json({ ok: true })); - app.get('/client-error', (req, res) => res.status(404).json({ error: 'not found' })); - app.get('/server-error', (req, res) => res.status(500).json({ error: 'boom' })); - app.get('/query', (req, res) => res.json({ ok: true })); + app.get("/ok", (req, res) => res.json({ ok: true })); + app.get("/client-error", (req, res) => + res.status(404).json({ error: "not found" }), + ); + app.get("/server-error", (req, res) => + res.status(500).json({ error: "boom" }), + ); + app.get("/query", (req, res) => res.json({ ok: true })); return app; } -describe('requestLogger middleware', () => { +describe("requestLogger middleware", () => { beforeEach(() => jest.clearAllMocks()); - test('logs method, path, status code, and a numeric duration on success', async () => { + test("logs method, path, status code, and a numeric duration on success", async () => { const app = buildApp(); - await request(app).get('/ok'); + await request(app).get("/ok"); expect(logger.info).toHaveBeenCalledTimes(1); const [message, meta] = logger.info.mock.calls[0]; - expect(message).toBe('HTTP request'); - expect(meta.method).toBe('GET'); - expect(meta.path).toBe('/ok'); + expect(message).toBe("HTTP request"); + expect(meta.method).toBe("GET"); + expect(meta.path).toBe("/ok"); expect(meta.statusCode).toBe(200); - expect(typeof meta.durationMs).toBe('number'); + expect(typeof meta.durationMs).toBe("number"); expect(meta.durationMs).toBeGreaterThanOrEqual(0); }); - test('logs a 4xx response at warn level', async () => { + test("logs a 4xx response at warn level", async () => { const app = buildApp(); - await request(app).get('/client-error'); + await request(app).get("/client-error"); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.info).not.toHaveBeenCalled(); expect(logger.warn.mock.calls[0][1].statusCode).toBe(404); }); - test('logs a 5xx response at error level', async () => { + test("logs a 5xx response at error level", async () => { const app = buildApp(); - await request(app).get('/server-error'); + await request(app).get("/server-error"); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error.mock.calls[0][1].statusCode).toBe(500); }); - test('does not include the query string in the logged path', async () => { + test("does not include the query string in the logged path", async () => { const app = buildApp(); - await request(app).get('/query?api_key=super-secret-value'); + await request(app).get("/query?api_key=super-secret-value"); const meta = logger.info.mock.calls[0][1]; - expect(meta.path).toBe('/query'); - expect(JSON.stringify(meta)).not.toContain('super-secret-value'); + expect(meta.path).toBe("/query"); + expect(JSON.stringify(meta)).not.toContain("super-secret-value"); }); - describe('slow request warning (#244)', () => { - test('does not log a slow-request warning for a fast request', async () => { + test("includes the mount path without logging the query string", async () => { + const app = express(); + const mountedRouter = express.Router(); + mountedRouter.use(requestLoggerMiddleware); + mountedRouter.get("/ok", (req, res) => res.json({ ok: true })); + app.use("/v1", mountedRouter); + + await request(app).get("/v1/ok?api_key=super-secret-value"); + + expect(logger.info.mock.calls[0][1].path).toBe("/v1/ok"); + expect(JSON.stringify(logger.info.mock.calls[0][1])).not.toContain( + "super-secret-value", + ); + }); + + describe("slow request warning (#244)", () => { + test("does not log a slow-request warning for a fast request", async () => { const app = buildApp(); - await request(app).get('/ok'); + await request(app).get("/ok"); - const slowCall = logger.warn.mock.calls.find(([message]) => message === 'Slow request detected'); + const slowCall = logger.warn.mock.calls.find( + ([message]) => message === "Slow request detected", + ); expect(slowCall).toBeUndefined(); }); test('logs a distinct "Slow request detected" warning when duration exceeds the threshold', async () => { - const hrtimeBigintSpy = jest.spyOn(process.hrtime, 'bigint'); + const hrtimeBigintSpy = jest.spyOn(process.hrtime, "bigint"); // First call is the middleware's startedAt, second is at res.on('finish') — // 1.5s apart in nanoseconds, well past the default 1000ms threshold. - hrtimeBigintSpy.mockReturnValueOnce(0n).mockReturnValueOnce(1_500_000_000n); + hrtimeBigintSpy + .mockReturnValueOnce(0n) + .mockReturnValueOnce(1_500_000_000n); const app = buildApp(); - await request(app).get('/ok'); + await request(app).get("/ok"); hrtimeBigintSpy.mockRestore(); - const slowCall = logger.warn.mock.calls.find(([message]) => message === 'Slow request detected'); + const slowCall = logger.warn.mock.calls.find( + ([message]) => message === "Slow request detected", + ); expect(slowCall).toBeDefined(); expect(slowCall[1]).toMatchObject({ - method: 'GET', - path: '/ok', + method: "GET", + path: "/ok", statusCode: 200, thresholdMs: 1000, }); @@ -97,17 +126,21 @@ describe('requestLogger middleware', () => { expect(logger.info).toHaveBeenCalledTimes(1); }); - test('a slow request that also errors logs both the routine error line and the slow warning', async () => { - const hrtimeBigintSpy = jest.spyOn(process.hrtime, 'bigint'); - hrtimeBigintSpy.mockReturnValueOnce(0n).mockReturnValueOnce(2_000_000_000n); + test("a slow request that also errors logs both the routine error line and the slow warning", async () => { + const hrtimeBigintSpy = jest.spyOn(process.hrtime, "bigint"); + hrtimeBigintSpy + .mockReturnValueOnce(0n) + .mockReturnValueOnce(2_000_000_000n); const app = buildApp(); - await request(app).get('/server-error'); + await request(app).get("/server-error"); hrtimeBigintSpy.mockRestore(); expect(logger.error).toHaveBeenCalledTimes(1); - const slowCall = logger.warn.mock.calls.find(([message]) => message === 'Slow request detected'); + const slowCall = logger.warn.mock.calls.find( + ([message]) => message === "Slow request detected", + ); expect(slowCall).toBeDefined(); expect(slowCall[1].statusCode).toBe(500); }); diff --git a/test/validate.test.js b/test/validate.test.js index eae4354..732326a 100644 --- a/test/validate.test.js +++ b/test/validate.test.js @@ -1,55 +1,67 @@ -'use strict'; +"use strict"; -const express = require('express'); -const request = require('supertest'); -const { z } = require('zod'); +const express = require("express"); +const request = require("supertest"); +const { z } = require("zod"); -const { validate } = require('../src/middleware/validate'); -const { errorHandler } = require('../src/middleware/errorHandler'); +const { validate } = require("../src/middleware/validate"); +const { errorHandler } = require("../src/middleware/errorHandler"); -function buildApp(schema, source = 'body') { +jest.mock("../src/services/errorTracker", () => ({ + captureException: jest.fn(), +})); + +function buildApp(schema, source = "body") { const app = express(); app.use(express.json()); - app.post('/validate/:id?', validate(schema, source), (req, res) => { - res.json({ validated: req.validated[source] }); + app.post("/validate/:id?", validate(schema, source), (req, res) => { + res.json({ validated: req.validated[source], original: req[source] }); }); app.use(errorHandler); return app; } -describe('validate middleware', () => { - test('stores parsed body data on req.validated', async () => { - const app = buildApp(z.object({ - count: z.coerce.number().int().min(1), - })); +describe("validate middleware", () => { + test("stores parsed body data on req.validated", async () => { + const app = buildApp( + z.object({ + count: z.coerce.number().int().min(1), + }), + ); - const res = await request(app).post('/validate').send({ count: '3' }); + const res = await request(app).post("/validate").send({ count: "3" }); expect(res.status).toBe(200); expect(res.body.validated).toEqual({ count: 3 }); + expect(res.body.original).toEqual({ count: "3" }); }); - test('returns a validation AppError with flattened field details', async () => { - const app = buildApp(z.object({ - count: z.coerce.number().int().min(1), - })); + test("returns a validation AppError with flattened field details", async () => { + const app = buildApp( + z.object({ + count: z.coerce.number().int().min(1), + }), + ); - const res = await request(app).post('/validate').send({ count: 0 }); + const res = await request(app).post("/validate").send({ count: 0 }); expect(res.status).toBe(400); expect(res.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', + code: "VALIDATION_ERROR", + message: "Validation failed", }); expect(res.body.error.details.fields.count).toEqual(expect.any(Array)); }); - test('validates route params before the handler runs', async () => { - const app = buildApp(z.object({ - id: z.string().regex(/^ok_[a-z]+$/), - }), 'params'); + test("validates route params before the handler runs", async () => { + const app = buildApp( + z.object({ + id: z.string().regex(/^ok_[a-z]+$/), + }), + "params", + ); - const res = await request(app).post('/validate/bad-id').send({}); + const res = await request(app).post("/validate/bad-id").send({}); expect(res.status).toBe(400); expect(res.body.error.details.fields.id).toEqual(expect.any(Array));