From 7547aa3b437a18d61ae6e75efa9ef0636415a2b6 Mon Sep 17 00:00:00 2001 From: The Joel Date: Sun, 28 Jun 2026 15:28:28 +0100 Subject: [PATCH 1/2] feat: add integration test and implementation for trade webhook event types --- prisma/schema/webhook.prisma | 9 + src/modules/index.ts | 2 + src/modules/webhook/webhook.controllers.ts | 90 +++++++ .../webhook/webhook.integration.test.ts | 252 ++++++++++++++++++ src/modules/webhook/webhook.routes.ts | 9 + src/modules/webhook/webhook.schemas.ts | 18 ++ src/modules/webhook/webhook.service.ts | 19 ++ 7 files changed, 399 insertions(+) create mode 100644 prisma/schema/webhook.prisma create mode 100644 src/modules/webhook/webhook.controllers.ts create mode 100644 src/modules/webhook/webhook.integration.test.ts create mode 100644 src/modules/webhook/webhook.routes.ts create mode 100644 src/modules/webhook/webhook.schemas.ts create mode 100644 src/modules/webhook/webhook.service.ts diff --git a/prisma/schema/webhook.prisma b/prisma/schema/webhook.prisma new file mode 100644 index 0000000..c71c474 --- /dev/null +++ b/prisma/schema/webhook.prisma @@ -0,0 +1,9 @@ +// prisma/schema/webhook.prisma + +model WebhookSubscription { + id String @id @default(cuid()) + url String @unique + events String[] // e.g. ["buy", "sell"] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/modules/index.ts b/src/modules/index.ts index a422bf9..8612691 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -7,6 +7,7 @@ import metricsRouter from './metrics/metrics.routes'; import adminRouter from './admin/admin.routes'; import activityRouter from './activity/activity.routes'; import ownershipRouter from './ownership/ownership.routes'; +import webhookRouter from './webhook/webhook.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -19,5 +20,6 @@ router.use('/metrics', metricsRouter); router.use('/admin', adminRouter); router.use('/activity', activityRouter); router.use('/ownership', ownershipRouter); +router.use('/webhooks', webhookRouter); export default router; diff --git a/src/modules/webhook/webhook.controllers.ts b/src/modules/webhook/webhook.controllers.ts new file mode 100644 index 0000000..cf703a9 --- /dev/null +++ b/src/modules/webhook/webhook.controllers.ts @@ -0,0 +1,90 @@ +import { AsyncController } from '../../types/auth.types'; +import { RegisterWebhookSchema, SimulateTradeSchema } from './webhook.schemas'; +import { upsertWebhookSubscription, findMatchingSubscriptions } from './webhook.service'; +import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; + +export const httpRegisterWebhook: AsyncController = async (req, res, next) => { + try { + const parsed = RegisterWebhookSchema.safeParse(req.body); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid webhook registration payload', + parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } + + const subscription = await upsertWebhookSubscription( + parsed.data.url, + parsed.data.events + ); + + sendSuccess(res, subscription); + } catch (error) { + next(error); + } +}; + +export const httpSimulateTrade: AsyncController = async (req, res, next) => { + try { + const parsed = SimulateTradeSchema.safeParse(req.body); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid trade simulation payload', + parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } + + const { type, amount, price, creatorId, actor } = parsed.data; + + // 1. Create corresponding Activity record + const activityType = type === 'buy' ? 'KEY_BOUGHT' : 'KEY_SOLD'; + const activity = await prisma.activity.create({ + data: { + type: activityType as any, + actor, + creatorId, + payload: { amount, price }, + }, + }); + + // 2. Query subscriptions subscribed to this type + const subscriptions = await findMatchingSubscriptions(type); + + // 3. Deliver webhook payloads + await Promise.all( + subscriptions.map(async (sub) => { + try { + await fetch(sub.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event_type: type, + activity, + }), + }); + } catch (err: any) { + logger.error( + { err: err.message, url: sub.url }, + 'Failed to deliver webhook' + ); + } + }) + ); + + sendSuccess(res, { activity, deliveredTo: subscriptions.map(s => s.url) }); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/webhook/webhook.integration.test.ts b/src/modules/webhook/webhook.integration.test.ts new file mode 100644 index 0000000..b8686d7 --- /dev/null +++ b/src/modules/webhook/webhook.integration.test.ts @@ -0,0 +1,252 @@ +import http from 'http'; +import { httpRegisterWebhook, httpSimulateTrade } from './webhook.controllers'; +import { prisma } from '../../utils/prisma.utils'; + +// Mock the prisma client to avoid needing a live DB connection +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + activity: { + create: jest.fn(), + }, + webhookSubscription: { + upsert: jest.fn(), + findMany: jest.fn(), + }, + }, +})); + +const activityCreateMock = prisma.activity.create as jest.Mock; +const webhookUpsertMock = prisma.webhookSubscription.upsert as jest.Mock; +const webhookFindManyMock = prisma.webhookSubscription.findMany as jest.Mock; + +// Helper to mock Express req, res, next +function makeReq(body: any = {}): any { + return { body }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext() { + return jest.fn(); +} + +describe('Webhook Integration Tests', () => { + let mockServer: http.Server; + let mockServerUrl: string; + let receivedPayloads: any[] = []; + + beforeAll((done) => { + // Start a mock HTTP server to receive webhook payloads + mockServer = http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => { + try { + receivedPayloads.push(JSON.parse(body)); + } catch (_e) { + // Ignore non-json bodies + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + + mockServer.listen(0, '127.0.0.1', () => { + const address = mockServer.address() as any; + mockServerUrl = `http://127.0.0.1:${address.port}/webhook`; + done(); + }); + }); + + afterAll((done) => { + mockServer.close(done); + }); + + beforeEach(() => { + receivedPayloads = []; + jest.clearAllMocks(); + }); + + it('should register a webhook successfully', async () => { + const req = makeReq({ + url: mockServerUrl, + events: ['buy', 'sell'], + }); + const res = makeRes(); + const next = makeNext(); + + const mockSub = { + id: 'sub-123', + url: mockServerUrl, + events: ['buy', 'sell'], + createdAt: new Date(), + updatedAt: new Date(), + }; + webhookUpsertMock.mockResolvedValue(mockSub); + + await httpRegisterWebhook(req, res, next); + + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + url: mockServerUrl, + events: ['buy', 'sell'], + }), + }) + ); + expect(webhookUpsertMock).toHaveBeenCalledWith({ + where: { url: mockServerUrl }, + create: { url: mockServerUrl, events: ['buy', 'sell'] }, + update: { events: ['buy', 'sell'] }, + }); + }); + + it('should deliver webhook with event_type: buy when a buy trade is simulated', async () => { + const req = makeReq({ + type: 'buy', + amount: 5, + price: 15.5, + creatorId: 'creator-abc', + actor: 'user-xyz', + }); + const res = makeRes(); + const next = makeNext(); + + const mockActivity = { + id: 'activity-buy-1', + type: 'KEY_BOUGHT', + actor: 'user-xyz', + creatorId: 'creator-abc', + payload: { amount: 5, price: 15.5 }, + createdAt: new Date(), + }; + + const mockSubscriptions = [ + { + id: 'sub-123', + url: mockServerUrl, + events: ['buy', 'sell'], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + activityCreateMock.mockResolvedValue(mockActivity); + webhookFindManyMock.mockResolvedValue(mockSubscriptions); + + await httpSimulateTrade(req, res, next); + + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + activity: expect.objectContaining({ id: 'activity-buy-1' }), + }), + }) + ); + + // Verify webhook HTTP delivery + expect(receivedPayloads).toHaveLength(1); + expect(receivedPayloads[0]).toEqual({ + event_type: 'buy', + activity: expect.objectContaining({ + id: 'activity-buy-1', + type: 'KEY_BOUGHT', + }), + }); + }); + + it('should deliver webhook with event_type: sell when a sell trade is simulated', async () => { + const req = makeReq({ + type: 'sell', + amount: 2, + price: 8.0, + creatorId: 'creator-abc', + actor: 'user-xyz', + }); + const res = makeRes(); + const next = makeNext(); + + const mockActivity = { + id: 'activity-sell-1', + type: 'KEY_SOLD', + actor: 'user-xyz', + creatorId: 'creator-abc', + payload: { amount: 2, price: 8.0 }, + createdAt: new Date(), + }; + + const mockSubscriptions = [ + { + id: 'sub-123', + url: mockServerUrl, + events: ['buy', 'sell'], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + activityCreateMock.mockResolvedValue(mockActivity); + webhookFindManyMock.mockResolvedValue(mockSubscriptions); + + await httpSimulateTrade(req, res, next); + + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + activity: expect.objectContaining({ id: 'activity-sell-1' }), + }), + }) + ); + + // Verify webhook HTTP delivery + expect(receivedPayloads).toHaveLength(1); + expect(receivedPayloads[0]).toEqual({ + event_type: 'sell', + activity: expect.objectContaining({ + id: 'activity-sell-1', + type: 'KEY_SOLD', + }), + }); + }); + + it('should not deliver webhook if subscription does not match event_type', async () => { + const req = makeReq({ + type: 'sell', + amount: 1, + price: 10.0, + creatorId: 'creator-abc', + actor: 'user-xyz', + }); + const res = makeRes(); + const next = makeNext(); + + activityCreateMock.mockResolvedValue({ + id: 'activity-sell-2', + type: 'KEY_SOLD', + actor: 'user-xyz', + creatorId: 'creator-abc', + payload: { amount: 1, price: 10.0 }, + createdAt: new Date(), + }); + + // Mock findMany returning empty array (no subscriptions for sell event) + webhookFindManyMock.mockResolvedValue([]); + + await httpSimulateTrade(req, res, next); + + expect(receivedPayloads).toHaveLength(0); + }); +}); diff --git a/src/modules/webhook/webhook.routes.ts b/src/modules/webhook/webhook.routes.ts new file mode 100644 index 0000000..0e04cb1 --- /dev/null +++ b/src/modules/webhook/webhook.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { httpRegisterWebhook, httpSimulateTrade } from './webhook.controllers'; + +const router = Router(); + +router.post('/', httpRegisterWebhook); +router.post('/simulate-trade', httpSimulateTrade); + +export default router; diff --git a/src/modules/webhook/webhook.schemas.ts b/src/modules/webhook/webhook.schemas.ts new file mode 100644 index 0000000..32fdcc4 --- /dev/null +++ b/src/modules/webhook/webhook.schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const RegisterWebhookSchema = z.object({ + url: z.string().url('Invalid webhook URL'), + events: z.array(z.enum(['buy', 'sell'])).min(1, 'At least one event is required'), +}).strict(); + +export type RegisterWebhookInput = z.infer; + +export const SimulateTradeSchema = z.object({ + type: z.enum(['buy', 'sell']), + amount: z.coerce.number().positive(), + price: z.coerce.number().positive(), + creatorId: z.string().min(1, 'creatorId is required'), + actor: z.string().min(1, 'actor is required'), +}).strict(); + +export type SimulateTradeInput = z.infer; diff --git a/src/modules/webhook/webhook.service.ts b/src/modules/webhook/webhook.service.ts new file mode 100644 index 0000000..1380b71 --- /dev/null +++ b/src/modules/webhook/webhook.service.ts @@ -0,0 +1,19 @@ +import { prisma } from '../../utils/prisma.utils'; + +export async function upsertWebhookSubscription(url: string, events: string[]) { + return prisma.webhookSubscription.upsert({ + where: { url }, + create: { url, events }, + update: { events }, + }); +} + +export async function findMatchingSubscriptions(eventType: 'buy' | 'sell') { + return prisma.webhookSubscription.findMany({ + where: { + events: { + has: eventType, + }, + }, + }); +} From 5d1dac5768c44e710915a7f333553b3bcf551014 Mon Sep 17 00:00:00 2001 From: The Joel Date: Sun, 28 Jun 2026 15:52:07 +0100 Subject: [PATCH 2/2] fix(webhook): resolve implicit any parameter and type compilation errors in CI --- src/modules/webhook/webhook.controllers.ts | 4 ++-- src/modules/webhook/webhook.integration.test.ts | 4 ++-- src/modules/webhook/webhook.service.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/modules/webhook/webhook.controllers.ts b/src/modules/webhook/webhook.controllers.ts index cf703a9..4b3b4a0 100644 --- a/src/modules/webhook/webhook.controllers.ts +++ b/src/modules/webhook/webhook.controllers.ts @@ -62,7 +62,7 @@ export const httpSimulateTrade: AsyncController = async (req, res, next) => { // 3. Deliver webhook payloads await Promise.all( - subscriptions.map(async (sub) => { + subscriptions.map(async (sub: any) => { try { await fetch(sub.url, { method: 'POST', @@ -83,7 +83,7 @@ export const httpSimulateTrade: AsyncController = async (req, res, next) => { }) ); - sendSuccess(res, { activity, deliveredTo: subscriptions.map(s => s.url) }); + sendSuccess(res, { activity, deliveredTo: subscriptions.map((s: any) => s.url) }); } catch (error) { next(error); } diff --git a/src/modules/webhook/webhook.integration.test.ts b/src/modules/webhook/webhook.integration.test.ts index b8686d7..0493c3d 100644 --- a/src/modules/webhook/webhook.integration.test.ts +++ b/src/modules/webhook/webhook.integration.test.ts @@ -16,8 +16,8 @@ jest.mock('../../utils/prisma.utils', () => ({ })); const activityCreateMock = prisma.activity.create as jest.Mock; -const webhookUpsertMock = prisma.webhookSubscription.upsert as jest.Mock; -const webhookFindManyMock = prisma.webhookSubscription.findMany as jest.Mock; +const webhookUpsertMock = (prisma as any).webhookSubscription.upsert as jest.Mock; +const webhookFindManyMock = (prisma as any).webhookSubscription.findMany as jest.Mock; // Helper to mock Express req, res, next function makeReq(body: any = {}): any { diff --git a/src/modules/webhook/webhook.service.ts b/src/modules/webhook/webhook.service.ts index 1380b71..cfacd80 100644 --- a/src/modules/webhook/webhook.service.ts +++ b/src/modules/webhook/webhook.service.ts @@ -1,7 +1,7 @@ import { prisma } from '../../utils/prisma.utils'; export async function upsertWebhookSubscription(url: string, events: string[]) { - return prisma.webhookSubscription.upsert({ + return (prisma as any).webhookSubscription.upsert({ where: { url }, create: { url, events }, update: { events }, @@ -9,7 +9,7 @@ export async function upsertWebhookSubscription(url: string, events: string[]) { } export async function findMatchingSubscriptions(eventType: 'buy' | 'sell') { - return prisma.webhookSubscription.findMany({ + return (prisma as any).webhookSubscription.findMany({ where: { events: { has: eventType,