Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
91 changes: 91 additions & 0 deletions src/__tests__/routes/subscriptions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions src/routes/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});