Skip to content

Commit eb0e29c

Browse files
Merge PR #740 (admin, -X theirs)
2 parents 9a03521 + 8f1b2ab commit eb0e29c

4 files changed

Lines changed: 147 additions & 3 deletions

File tree

src/routes/markets/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { accessLog } from "../../middleware/accessLog";
1515
import { marketsCors } from "../../middleware/cors";
1616
import { listFeaturedMarkets } from "../../services/marketFeatureService";
1717
import { logger } from "../../config/logger";
18+
import type { Request, Response, NextFunction, RequestHandler } from "express";
1819
import { RouteErrorFactory } from "../../errors";
1920
import { conditionalGet } from "../../middleware/etag";
2021
import { recommendationsRouter } from "./recommendations";

src/routes/tags.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { Router } from "express";
2-
import type { Request, Response, NextFunction } from "express";
1+
import { Router, Request, Response, NextFunction } from "express";
32
import { z } from "zod";
43
import { accessLog } from "../middleware/accessLog";
54
import { logger } from "../config/logger";

src/routes/users.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ usersRouter.use(accessLog);
9696
// ---------------------------------------------------------------------------
9797
// Per-request timeout with graceful abort on /api/users
9898
// ---------------------------------------------------------------------------
99-
usersRouter.use(requestTimeout(15000)); // 15 seconds timeout
99+
usersRouter.use(
100+
requestTimeout(15000, {
101+
statusCode: 504,
102+
code: "gateway_timeout",
103+
message: "Request timed out",
104+
}),
105+
); // 15s timeout → 504 Gateway Timeout
100106

101107
// ---------------------------------------------------------------------------
102108
// Per-endpoint Prometheus metrics for /api/users

tests/usersTimeout.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* tests/usersTimeout.test.ts
3+
*
4+
* Tests for per-request timeout middleware on /api/users.
5+
* Verifies that the timeout returns 504 Gateway Timeout (as required
6+
* by issue #604) with cooperative abort.
7+
*/
8+
9+
process.env.NODE_ENV = "test";
10+
process.env.PORT = "3001";
11+
process.env.LOG_LEVEL = "fatal";
12+
process.env.DATABASE_URL = "postgres://localhost/test";
13+
process.env.JWT_SECRET = "users-timeout-test-secret-at-least-32-bytes!!!";
14+
process.env.JWT_ISSUER = "predictify";
15+
process.env.JWT_AUDIENCE = "predictify-app";
16+
process.env.JWT_TTL_SECONDS = "3600";
17+
process.env.STELLAR_NETWORK = "testnet";
18+
process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org";
19+
process.env.HORIZON_URL = "https://horizon-testnet.stellar.org";
20+
process.env.PREDICTIFY_CONTRACT_ID = "CABCDEF";
21+
22+
// ---------------------------------------------------------------------------
23+
// Mocks
24+
// ---------------------------------------------------------------------------
25+
26+
jest.mock("pg", () => {
27+
const Pool = jest.fn().mockImplementation(() => ({
28+
connect: jest.fn(),
29+
query: jest.fn(),
30+
end: jest.fn(),
31+
on: jest.fn(),
32+
}));
33+
return { Pool };
34+
});
35+
36+
jest.mock("drizzle-orm/node-postgres", () => ({
37+
drizzle: jest.fn(() => ({
38+
select: jest.fn(),
39+
})),
40+
}));
41+
42+
jest.mock("../src/db/client", () => ({
43+
db: { select: jest.fn() },
44+
pool: { on: jest.fn(), end: jest.fn() },
45+
}));
46+
47+
jest.mock("../src/middleware/rateLimit", () => ({
48+
createPerUserRateLimiter: () => (_req: any, _res: any, next: any) => next(),
49+
}));
50+
51+
jest.mock("../src/metrics/usersMetrics", () => ({
52+
usersMetricsMiddleware: (_req: any, _res: any, next: any) => next(),
53+
}));
54+
55+
jest.mock("../src/middleware/accessLog", () => ({
56+
accessLog: (_req: any, _res: any, next: any) => next(),
57+
}));
58+
59+
jest.mock("../src/middleware/etag", () => ({
60+
conditionalGet: () => false,
61+
}));
62+
63+
jest.mock("../src/services/userService", () => ({
64+
__esModule: true,
65+
listUsers: jest.fn(),
66+
getUserByAddress: jest.fn(),
67+
getUserPredictions: jest.fn(),
68+
getCurrentUserProfile: jest.fn(),
69+
getUserProfile: jest.fn(),
70+
}));
71+
72+
// ---------------------------------------------------------------------------
73+
// Imports
74+
// ---------------------------------------------------------------------------
75+
76+
import express from "express";
77+
import request from "supertest";
78+
import { usersRouter } from "../src/routes/users";
79+
import { errorHandler } from "../src/middleware/errorHandler";
80+
import { listUsers } from "../src/services/userService";
81+
82+
const mockListUsers = listUsers as jest.MockedFunction<typeof listUsers>;
83+
84+
function makeApp(): express.Express {
85+
const app = express();
86+
app.use(express.json());
87+
app.use("/api/users", usersRouter);
88+
app.use(errorHandler);
89+
return app;
90+
}
91+
92+
describe("GET /api/users — timeout middleware", () => {
93+
const originalSetTimeout = global.setTimeout;
94+
95+
beforeEach(() => {
96+
jest.clearAllMocks();
97+
98+
// Accelerate the 15s requestTimeout to 50ms so the test doesn't wait
99+
global.setTimeout = ((
100+
cb: (...args: any[]) => void,
101+
ms?: number,
102+
...args: any[]
103+
) => {
104+
if (ms === 15000) return originalSetTimeout(cb, 50, ...args);
105+
return originalSetTimeout(cb, ms, ...args);
106+
}) as typeof setTimeout;
107+
});
108+
109+
afterEach(() => {
110+
global.setTimeout = originalSetTimeout;
111+
});
112+
113+
it("returns 504 gateway_timeout when the service hangs past the deadline", async () => {
114+
mockListUsers.mockImplementation(() => new Promise(() => {}));
115+
116+
const res = await request(makeApp()).get("/api/users");
117+
118+
// Debug: log the response body on failure
119+
if (res.status !== 504) {
120+
console.log("Unexpected response:", res.status, JSON.stringify(res.body));
121+
}
122+
123+
expect(res.status).toBe(504);
124+
expect(res.body.error.code).toBe("gateway_timeout");
125+
expect(res.body.error.message).toBe("Request timed out");
126+
expect(res.body.error.requestId).toBeDefined();
127+
});
128+
129+
it("responds normally with 200 when the service resolves within the deadline", async () => {
130+
mockListUsers.mockResolvedValue({ data: [], nextCursor: null });
131+
132+
const res = await request(makeApp()).get("/api/users");
133+
134+
expect(res.status).toBe(200);
135+
expect(res.body.data).toEqual([]);
136+
expect(res.body.nextCursor).toBeNull();
137+
});
138+
});

0 commit comments

Comments
 (0)