Skip to content

Commit 2884d4b

Browse files
Merge pull request #880 from arisu6804/feat/subscriptions-schema-stability-668
Add response schema stability test for /api/subscriptions
2 parents 3e33144 + 5eab9d8 commit 2884d4b

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`/api/subscriptions response schema stability GET / — matches the stable empty-list shape 1`] = `
4+
{
5+
"data": [],
6+
}
7+
`;
8+
9+
exports[`/api/subscriptions response schema stability GET / — matches the stable list shape (secret stripped) 1`] = `
10+
{
11+
"data": [
12+
{
13+
"active": true,
14+
"createdAt": "2026-01-01T00:00:00.000Z",
15+
"events": [
16+
"market.created",
17+
"prediction.settled",
18+
],
19+
"id": "123e4567-e89b-12d3-a456-426614174000",
20+
"updatedAt": "2026-01-01T00:00:00.000Z",
21+
"url": "https://example.com/webhook",
22+
},
23+
],
24+
}
25+
`;
26+
27+
exports[`/api/subscriptions response schema stability GET /:id — matches the stable not-found error shape 1`] = `
28+
{
29+
"error": {
30+
"code": "not_found",
31+
"correlationId": Any<String>,
32+
"message": "Subscription not found",
33+
},
34+
}
35+
`;
36+
37+
exports[`/api/subscriptions response schema stability GET /:id — matches the stable single-resource shape 1`] = `
38+
{
39+
"data": {
40+
"active": true,
41+
"createdAt": "2026-01-01T00:00:00.000Z",
42+
"events": [
43+
"market.created",
44+
"prediction.settled",
45+
],
46+
"id": "123e4567-e89b-12d3-a456-426614174000",
47+
"updatedAt": "2026-01-01T00:00:00.000Z",
48+
"url": "https://example.com/webhook",
49+
},
50+
}
51+
`;
52+
53+
exports[`/api/subscriptions response schema stability POST / — matches the stable creation shape (secret included once) 1`] = `
54+
{
55+
"data": {
56+
"active": true,
57+
"createdAt": "2026-01-01T00:00:00.000Z",
58+
"events": [
59+
"market.created",
60+
"prediction.settled",
61+
],
62+
"id": "123e4567-e89b-12d3-a456-426614174000",
63+
"secret": Any<String>,
64+
"updatedAt": "2026-01-01T00:00:00.000Z",
65+
"url": "https://example.com/webhook",
66+
},
67+
}
68+
`;
69+
70+
exports[`/api/subscriptions response schema stability POST / — matches the stable validation-error shape 1`] = `
71+
{
72+
"error": {
73+
"code": "validation_error",
74+
"correlationId": Any<String>,
75+
"details": [
76+
{
77+
"code": "invalid_type",
78+
"expected": "string",
79+
"message": "url is required",
80+
"path": [
81+
"url",
82+
],
83+
"received": "undefined",
84+
},
85+
],
86+
"message": "Validation failed",
87+
},
88+
}
89+
`;

tests/schema/subscriptions.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* tests/schema/subscriptions.test.ts
3+
*
4+
* Snapshot-based response-shape stability tests for /api/subscriptions.
5+
* These complement the behavioral assertions in tests/subscriptions.test.ts
6+
* by pinning the exact JSON shape returned to clients, so an accidental
7+
* field rename/addition/removal shows up as a snapshot diff in review
8+
* rather than silently shipping.
9+
*/
10+
11+
jest.mock("../../src/middleware/requireAdmin", () => ({
12+
requireAdmin: (
13+
_req: import("express").Request,
14+
_res: import("express").Response,
15+
next: import("express").NextFunction,
16+
) => next(),
17+
}));
18+
19+
jest.mock("../../src/config/logger", () => ({
20+
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
21+
}));
22+
23+
jest.mock("../../src/services/auditService", () => ({
24+
createAuditLog: jest.fn().mockResolvedValue("corr-id"),
25+
sanitizeState: jest.fn((state: unknown) => state),
26+
}));
27+
28+
const mockSelectWhere = jest.fn();
29+
const mockFrom = jest.fn(() => ({ where: mockSelectWhere }));
30+
const mockReturning = jest.fn();
31+
const mockValues = jest.fn(() => ({ returning: mockReturning }));
32+
const mockInsert = jest.fn(() => ({ values: mockValues }));
33+
const mockSelect = jest.fn(() => ({ from: mockFrom }));
34+
35+
jest.mock("../../src/db/client", () => ({
36+
db: {
37+
select: (...args: unknown[]) => mockSelect(...args),
38+
insert: (...args: unknown[]) => mockInsert(...args),
39+
},
40+
}));
41+
42+
import request from "supertest";
43+
import express from "express";
44+
import { subscriptionsRouter } from "../../src/routes/subscriptions";
45+
import { errorHandler } from "../../src/middleware/errorHandler";
46+
47+
const VALID_UUID = "123e4567-e89b-12d3-a456-426614174000";
48+
49+
const mockSubscription = {
50+
id: VALID_UUID,
51+
url: "https://example.com/webhook",
52+
secret: "super-secret-hmac-key",
53+
events: ["market.created", "prediction.settled"],
54+
active: true,
55+
createdAt: new Date("2026-01-01T00:00:00.000Z"),
56+
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
57+
};
58+
59+
function makeApp(): express.Application {
60+
const app = express();
61+
app.use(express.json());
62+
app.use("/api/subscriptions", subscriptionsRouter);
63+
app.use(errorHandler);
64+
return app;
65+
}
66+
67+
describe("/api/subscriptions response schema stability", () => {
68+
let app: express.Application;
69+
70+
beforeEach(() => {
71+
jest.clearAllMocks();
72+
app = makeApp();
73+
mockFrom.mockReturnValue({ where: mockSelectWhere });
74+
});
75+
76+
it("GET / — matches the stable list shape (secret stripped)", async () => {
77+
mockFrom.mockResolvedValueOnce([mockSubscription]);
78+
79+
const res = await request(app).get("/api/subscriptions");
80+
81+
expect(res.status).toBe(200);
82+
expect(res.body).toMatchSnapshot();
83+
});
84+
85+
it("GET / — matches the stable empty-list shape", async () => {
86+
mockFrom.mockResolvedValueOnce([]);
87+
88+
const res = await request(app).get("/api/subscriptions");
89+
90+
expect(res.status).toBe(200);
91+
expect(res.body).toMatchSnapshot();
92+
});
93+
94+
it("POST / — matches the stable creation shape (secret included once)", async () => {
95+
mockReturning.mockResolvedValueOnce([mockSubscription]);
96+
97+
const res = await request(app)
98+
.post("/api/subscriptions")
99+
.send({ url: "https://example.com/webhook", events: ["market.created"] });
100+
101+
expect(res.status).toBe(201);
102+
// `secret` in the real handler is a freshly generated uuidv4() per request,
103+
// not the stored row's secret, so it varies run-to-run; pin its shape
104+
// (a string) rather than its value.
105+
expect(res.body).toMatchSnapshot({
106+
data: { secret: expect.any(String) },
107+
});
108+
});
109+
110+
it("POST / — matches the stable validation-error shape", async () => {
111+
const res = await request(app)
112+
.post("/api/subscriptions")
113+
.send({ events: ["market.created"] });
114+
115+
expect(res.status).toBe(400);
116+
// correlationId is a fresh randomUUID() per request outside of
117+
// requestContextStorage, so it varies run-to-run; pin its shape only.
118+
expect(res.body).toMatchSnapshot({
119+
error: { correlationId: expect.any(String) },
120+
});
121+
});
122+
123+
it("GET /:id — matches the stable single-resource shape", async () => {
124+
mockSelectWhere.mockResolvedValueOnce([mockSubscription]);
125+
126+
const res = await request(app).get(`/api/subscriptions/${VALID_UUID}`);
127+
128+
expect(res.status).toBe(200);
129+
expect(res.body).toMatchSnapshot();
130+
});
131+
132+
it("GET /:id — matches the stable not-found error shape", async () => {
133+
mockSelectWhere.mockResolvedValueOnce([]);
134+
135+
const res = await request(app).get(`/api/subscriptions/${VALID_UUID}`);
136+
137+
expect(res.status).toBe(404);
138+
expect(res.body).toMatchSnapshot({
139+
error: { correlationId: expect.any(String) },
140+
});
141+
});
142+
});

0 commit comments

Comments
 (0)