From 9618aa3d4776b6682b41456593eb57e31ac32f1f Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Sun, 26 Jul 2026 03:31:38 +0100 Subject: [PATCH] feat: Add ETag / 304 caching on /api/subscriptions Closes #495 --- openapi.yaml | 27 +++++++ src/__tests__/routes/subscriptions.test.ts | 91 ++++++++++++++++++++++ src/index.ts | 2 + src/routes/subscriptions.ts | 23 ++++++ 4 files changed, 143 insertions(+) create mode 100644 src/__tests__/routes/subscriptions.test.ts create mode 100644 src/routes/subscriptions.ts diff --git a/openapi.yaml b/openapi.yaml index a6a5848..94271b3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3334,3 +3334,30 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/subscriptions: + get: + summary: List webhook subscriptions + description: Returns a list of webhook subscriptions. + tags: + - Webhooks + security: + - bearerAuth: [] + responses: + '200': + description: A list of subscriptions + headers: + ETag: + schema: + type: string + description: Strong ETag for caching + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + '304': + description: Not Modified diff --git a/src/__tests__/routes/subscriptions.test.ts b/src/__tests__/routes/subscriptions.test.ts new file mode 100644 index 0000000..f7e5fea --- /dev/null +++ b/src/__tests__/routes/subscriptions.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import request from "supertest"; +import express from "express"; +import { subscriptionsRouter } from "../../routes/subscriptions"; +import { db } from "../../db/client"; +import { generateETag } from "../../middleware/etag"; + +jest.mock("../../middleware/requireAdmin", () => ({ + requireAdmin: (req: any, res: any, next: any) => next(), +})); + +jest.mock("../../db/client", () => { + const mDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn(), + }; + return { db: mDb }; +}); + +describe("Subscriptions Routes", () => { + let app: express.Application; + + const mockSubscriptions = [ + { + id: "sub-1", + url: "https://example.com/webhook", + secret: "secret123", + events: ["market.created"], + active: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use("/api/subscriptions", subscriptionsRouter); + }); + + describe("GET /api/subscriptions", () => { + it("should return subscriptions and strong ETag", async () => { + (db.from as jest.Mock).mockResolvedValueOnce(mockSubscriptions); + + const response = await request(app).get("/api/subscriptions"); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual(JSON.parse(JSON.stringify(mockSubscriptions))); + + const expectedEtag = generateETag(mockSubscriptions); + expect(response.headers.etag).toBe(expectedEtag); + expect(response.headers["cache-control"]).toBe("no-cache"); + }); + + it("should return 304 if If-None-Match matches ETag", async () => { + (db.from as jest.Mock).mockResolvedValue(mockSubscriptions); + + const expectedEtag = generateETag(mockSubscriptions); + const response = await request(app) + .get("/api/subscriptions") + .set("If-None-Match", expectedEtag); + + expect(response.status).toBe(304); + expect(response.body).toEqual({}); + }); + + it("should return 200 if If-None-Match does not match ETag", async () => { + (db.from as jest.Mock).mockResolvedValueOnce(mockSubscriptions); + + const response = await request(app) + .get("/api/subscriptions") + .set("If-None-Match", '"non-matching-etag"'); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual(JSON.parse(JSON.stringify(mockSubscriptions))); + }); + + it("should handle db errors", async () => { + (db.from as jest.Mock).mockRejectedValueOnce(new Error("Database error")); + // Mute the express default error handler output in tests + app.use((err: any, req: any, res: any, next: any) => { + res.status(500).json({ error: "Internal Error" }); + }); + + const response = await request(app).get("/api/subscriptions"); + + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 53f08d7..079de80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { sessionsRouter } from "./routes/me/sessions"; import { notificationsRouter } from "./routes/notifications"; import { socialRouter } from "./routes/social"; import { webhooksRouter } from "./routes/webhooks"; +import { subscriptionsRouter } from "./routes/subscriptions"; import { adminAuditRouter } from "./routes/admin/audit"; import { adminAuditExportRouter } from "./routes/admin/audit/export"; import { adminMarketsRouter } from "./routes/admin/markets"; @@ -136,6 +137,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express { app.use("/api/quota/requests", quotaRequestsRouter); app.use("/api/notifications", notificationsRouter); app.use("/api/webhooks", webhooksRouter); + app.use("/api/subscriptions", subscriptionsRouter); app.use("/api/users/health", usersHealthRouter); app.use("/api/users", socialRouter); app.use("/api/users", userPortfolioRouter); diff --git a/src/routes/subscriptions.ts b/src/routes/subscriptions.ts new file mode 100644 index 0000000..6bd0d37 --- /dev/null +++ b/src/routes/subscriptions.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import { db } from "../db/client"; +import { webhookSubscriptions } from "../db/schema"; +import { conditionalGet } from "../middleware/etag"; +import { requireAdmin } from "../middleware/requireAdmin"; + +export const subscriptionsRouter = Router(); + +subscriptionsRouter.use(requireAdmin); + +subscriptionsRouter.get("/", async (req, res, next) => { + try { + const subscriptions = await db.select().from(webhookSubscriptions); + + if (conditionalGet(subscriptions, req, res)) { + return; + } + + res.json({ data: subscriptions }); + } catch (err) { + next(err); + } +});