From 3377cba61ce1913e2603952ef11f7ceff3a44ebe Mon Sep 17 00:00:00 2001 From: kilodesodiq-arch Date: Wed, 19 Aug 2026 13:41:42 +0000 Subject: [PATCH] fix(webhooks): emit domain events from auth, module, and reward flows Wire WebhookService.queueEvent into the three request paths that were documented but never produced events: user.registered on registration, module.completed on quiz completion, and reward.issued on completed withdrawals. Add employer.contact_attempt to the WebhookEventType union, document the emitted events in docs/API.md, and assert each producer invokes queueEvent in controller tests. --- docs/API.md | 5 +- integrations/auth.controller.test.ts | 21 +++++ integrations/unit/module.controller.test.ts | 91 +++++++++++++++++++++ integrations/unit/reward.controller.test.ts | 22 +++++ src/controllers/auth.controller.ts | 12 +++ src/controllers/employer.controller.ts | 3 + src/controllers/module.controller.ts | 13 +++ src/controllers/reward.controller.ts | 15 ++++ src/types/webhook.types.ts | 3 +- tests/auth.controller.test.ts | 21 +++++ tests/unit/module.controller.test.ts | 91 +++++++++++++++++++++ tests/unit/reward.controller.test.ts | 22 +++++ 12 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 integrations/unit/module.controller.test.ts create mode 100644 tests/unit/module.controller.test.ts diff --git a/docs/API.md b/docs/API.md index 3a7f7e56..eee9b3ae 100644 --- a/docs/API.md +++ b/docs/API.md @@ -463,9 +463,10 @@ X-RateLimit-Reset: 1627583492 You can register webhooks to receive real-time events: -- `user.completed_module` +- `user.registered` +- `module.completed` - `reward.issued` -- `credential.verified` +- `employer.contact_attempt` See [Webhook Documentation](./WEBHOOKS.md) for details. diff --git a/integrations/auth.controller.test.ts b/integrations/auth.controller.test.ts index f916d4c1..9cfb7a2c 100644 --- a/integrations/auth.controller.test.ts +++ b/integrations/auth.controller.test.ts @@ -5,6 +5,10 @@ import prisma from '../src/config/database' import bcrypt from 'bcryptjs' // import jwt from 'jsonwebtoken' +const { queueEventMock } = vi.hoisted(() => ({ + queueEventMock: vi.fn().mockResolvedValue(undefined), +})) + // Mock dependencies vi.mock('../src/config/database', () => ({ default: { @@ -31,6 +35,14 @@ vi.mock('jsonwebtoken', () => ({ }, })) +vi.mock('../src/services/webhook.service', () => { + class WebhookService { + queueEvent = queueEventMock + } + + return { WebhookService } +}) + describe('AuthController', () => { let authController: AuthController let mockRequest: Partial @@ -70,6 +82,15 @@ describe('AuthController', () => { message: 'User registered successfully', token: 'mock_token', })) + expect(queueEventMock).toHaveBeenCalledWith( + 'user.registered', + expect.objectContaining({ + userId: '1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + }), + ) }) it('should return 400 for invalid input', async () => { diff --git a/integrations/unit/module.controller.test.ts b/integrations/unit/module.controller.test.ts new file mode 100644 index 00000000..dca30867 --- /dev/null +++ b/integrations/unit/module.controller.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Request, Response } from 'express' +import { completeModule } from '../../src/controllers/module.controller' +import { prisma } from '../../src/config/database' + +const { queueEventMock } = vi.hoisted(() => ({ + queueEventMock: vi.fn().mockResolvedValue(undefined), +})) + +const { queueNotificationMock } = vi.hoisted(() => ({ + queueNotificationMock: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../../src/config/database', () => ({ + prisma: { + module: { + findUnique: vi.fn(), + }, + completion: { + findUnique: vi.fn(), + update: vi.fn(), + }, + transaction: { + create: vi.fn(), + }, + }, +})) + +vi.mock('../../src/services/notification.service', () => ({ + NotificationService: class { + queueNotification = queueNotificationMock + }, +})) + +vi.mock('../../src/services/webhook.service', () => ({ + WebhookService: class { + queueEvent = queueEventMock + }, +})) + +function createResponse() { + const response: Partial = {} + response.status = vi.fn().mockReturnValue(response) + response.json = vi.fn().mockReturnValue(response) + + return response as Response +} + +describe('ModuleController.completeModule', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('emits module.completed webhook event after a passing quiz', async () => { + ;(prisma.module.findUnique as any).mockResolvedValue({ + id: 'mod-1', + title: 'Stellar Fundamentals', + reward: 10, + }) + ;(prisma.completion.findUnique as any).mockResolvedValue({ + userId: 'user-1', + moduleId: 'mod-1', + score: -1, + }) + const completedAt = new Date('2026-01-01T00:00:00Z') + ;(prisma.completion.update as any).mockResolvedValue({ completedAt }) + ;(prisma.transaction.create as any).mockResolvedValue({ id: 'txn-1' }) + + const req = { + user: { id: 'user-1' }, + params: { id: 'mod-1' }, + body: { quizAnswers: [{ questionId: 'q1', answer: 'a' }] }, + } as unknown as Request + const res = createResponse() + + await completeModule(req, res) + + expect(queueEventMock).toHaveBeenCalledWith( + 'module.completed', + expect.objectContaining({ + userId: 'user-1', + moduleId: 'mod-1', + moduleTitle: 'Stellar Fundamentals', + score: 100, + isEligibleForReward: true, + reward: 10, + completedAt: completedAt.toISOString(), + }), + ) + }) +}) diff --git a/integrations/unit/reward.controller.test.ts b/integrations/unit/reward.controller.test.ts index fca88aaa..550f3fb5 100644 --- a/integrations/unit/reward.controller.test.ts +++ b/integrations/unit/reward.controller.test.ts @@ -3,6 +3,18 @@ import { RewardController } from '../../src/controllers/reward.controller' import { RewardService } from '../../src/services/reward.service' import { Request, Response } from 'express' +const { queueEventMock } = vi.hoisted(() => ({ + queueEventMock: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../../src/services/webhook.service', () => { + class WebhookService { + queueEvent = queueEventMock + } + + return { WebhookService } +}) + // Mock RewardService methods using vi.spyOn describe('RewardController', () => { let controller: RewardController @@ -337,6 +349,15 @@ describe('RewardController', () => { message: 'Withdrawal processed successfully', }), ) + expect(queueEventMock).toHaveBeenCalledWith( + 'reward.issued', + expect.objectContaining({ + userId: 'user-123', + transactionId: 'txn-withdrawal-123', + amount: 50, + stellarTxHash: 'stellar-hash-xyz', + }), + ) }) it('should reject withdrawal if wallet address is missing', async () => { @@ -459,6 +480,7 @@ describe('RewardController', () => { expect(nextFn).toHaveBeenCalledWith( expect.objectContaining({ message: 'Stellar network error' }), ) + expect(queueEventMock).not.toHaveBeenCalled() }) }) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 31069a32..770701f5 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -4,10 +4,13 @@ import jwt from 'jsonwebtoken' import prisma from '../config/database' import { loginSchema, registerSchema } from '../schemas/auth.schema' import { UserRole } from '../types/user.types' +import { WebhookService } from '../services/webhook.service' const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret' const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' +const webhookService = new WebhookService() + export class AuthController { /** * @openapi @@ -83,6 +86,15 @@ export class AuthController { // Generate token const token = this.generateToken(user.id, user.role) + // Emit user.registered webhook event (non-blocking) + webhookService.queueEvent('user.registered', { + userId: user.id, + email: user.email, + username: user.username, + role: user.role, + registeredAt: new Date().toISOString(), + }).catch(err => console.error('[Webhook] user.registered error:', err)) + res.status(201).json({ message: 'User registered successfully', token, diff --git a/src/controllers/employer.controller.ts b/src/controllers/employer.controller.ts index 59dc542e..72e74f7c 100644 --- a/src/controllers/employer.controller.ts +++ b/src/controllers/employer.controller.ts @@ -366,6 +366,9 @@ export const contactCandidate = async (req: Request, res: Response) => { return res.status(404).json({ message: 'Candidate not found' }) } + // Outreach is recorded as an audit trail directly on WebhookDelivery rather than + // routed through WebhookService.queueEvent: the system endpoint's URL is + // internal-only and must never trigger an outbound HMAC-signed HTTP delivery. const outreachEndpoint = await prisma.webhookEndpoint.upsert({ where: { id: 'system-employer-outreach-log' }, update: { diff --git a/src/controllers/module.controller.ts b/src/controllers/module.controller.ts index 8987bb58..bd7d1046 100644 --- a/src/controllers/module.controller.ts +++ b/src/controllers/module.controller.ts @@ -5,8 +5,10 @@ const COMPLETION_IN_PROGRESS_SCORE = -1 import { z } from 'zod' import { prisma } from '../config/database' import { NotificationService } from '../services/notification.service' +import { WebhookService } from '../services/webhook.service' const notificationService = new NotificationService() +const webhookService = new WebhookService() // Query parameter schemas for validation const listModulesSchema = z.object({ @@ -450,6 +452,17 @@ export const completeModule = async (req: Request, res: Response) => { : `You scored ${score}% on "${module.title}". Keep practicing to earn rewards!` ).catch(err => console.error('[Notifications] Quiz notification error:', err)) + // Emit module.completed webhook event (non-blocking) + webhookService.queueEvent('module.completed', { + userId: req.user.id, + moduleId: id, + moduleTitle: module.title, + score, + isEligibleForReward, + reward: isEligibleForReward ? module.reward : 0, + completedAt: updatedCompletion.completedAt?.toISOString(), + }).catch(err => console.error('[Webhook] module.completed error:', err)) + res.json({ message: 'Module completed successfully', score, diff --git a/src/controllers/reward.controller.ts b/src/controllers/reward.controller.ts index fdaaae46..0242c9e8 100644 --- a/src/controllers/reward.controller.ts +++ b/src/controllers/reward.controller.ts @@ -2,12 +2,15 @@ import { Request, Response } from 'express' import { RewardService } from '../services/reward.service' import { asyncHandler } from '../middleware/error.middleware' import { BadRequestError } from '../utils/errors' +import { WebhookService } from '../services/webhook.service' export class RewardController { private rewardService: RewardService + private webhookService: WebhookService constructor() { this.rewardService = new RewardService() + this.webhookService = new WebhookService() } /** @@ -282,6 +285,18 @@ export class RewardController { completedAt: result.completedAt?.toISOString(), }, }) + + // Emit reward.issued for completed withdrawals (non-blocking, after response sent) + if (result.status === 'completed') { + this.webhookService.queueEvent('reward.issued', { + userId, + transactionId: result.transactionId, + amount: result.amount, + stellarTxHash: result.stellarTxHash, + walletAddress, + issuedAt: result.completedAt?.toISOString() ?? new Date().toISOString(), + }).catch(err => console.error('[Webhook] reward.issued error:', err)) + } }, ) diff --git a/src/types/webhook.types.ts b/src/types/webhook.types.ts index d2d6da26..a6c9e4ac 100644 --- a/src/types/webhook.types.ts +++ b/src/types/webhook.types.ts @@ -2,7 +2,8 @@ export type WebhookEventType = | 'module.completed' | 'reward.issued' | 'user.registered' - | 'system.test'; + | 'system.test' + | 'employer.contact_attempt'; export interface WebhookPayload { eventId: string; diff --git a/tests/auth.controller.test.ts b/tests/auth.controller.test.ts index f916d4c1..9cfb7a2c 100644 --- a/tests/auth.controller.test.ts +++ b/tests/auth.controller.test.ts @@ -5,6 +5,10 @@ import prisma from '../src/config/database' import bcrypt from 'bcryptjs' // import jwt from 'jsonwebtoken' +const { queueEventMock } = vi.hoisted(() => ({ + queueEventMock: vi.fn().mockResolvedValue(undefined), +})) + // Mock dependencies vi.mock('../src/config/database', () => ({ default: { @@ -31,6 +35,14 @@ vi.mock('jsonwebtoken', () => ({ }, })) +vi.mock('../src/services/webhook.service', () => { + class WebhookService { + queueEvent = queueEventMock + } + + return { WebhookService } +}) + describe('AuthController', () => { let authController: AuthController let mockRequest: Partial @@ -70,6 +82,15 @@ describe('AuthController', () => { message: 'User registered successfully', token: 'mock_token', })) + expect(queueEventMock).toHaveBeenCalledWith( + 'user.registered', + expect.objectContaining({ + userId: '1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + }), + ) }) it('should return 400 for invalid input', async () => { diff --git a/tests/unit/module.controller.test.ts b/tests/unit/module.controller.test.ts new file mode 100644 index 00000000..dca30867 --- /dev/null +++ b/tests/unit/module.controller.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Request, Response } from 'express' +import { completeModule } from '../../src/controllers/module.controller' +import { prisma } from '../../src/config/database' + +const { queueEventMock } = vi.hoisted(() => ({ + queueEventMock: vi.fn().mockResolvedValue(undefined), +})) + +const { queueNotificationMock } = vi.hoisted(() => ({ + queueNotificationMock: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../../src/config/database', () => ({ + prisma: { + module: { + findUnique: vi.fn(), + }, + completion: { + findUnique: vi.fn(), + update: vi.fn(), + }, + transaction: { + create: vi.fn(), + }, + }, +})) + +vi.mock('../../src/services/notification.service', () => ({ + NotificationService: class { + queueNotification = queueNotificationMock + }, +})) + +vi.mock('../../src/services/webhook.service', () => ({ + WebhookService: class { + queueEvent = queueEventMock + }, +})) + +function createResponse() { + const response: Partial = {} + response.status = vi.fn().mockReturnValue(response) + response.json = vi.fn().mockReturnValue(response) + + return response as Response +} + +describe('ModuleController.completeModule', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('emits module.completed webhook event after a passing quiz', async () => { + ;(prisma.module.findUnique as any).mockResolvedValue({ + id: 'mod-1', + title: 'Stellar Fundamentals', + reward: 10, + }) + ;(prisma.completion.findUnique as any).mockResolvedValue({ + userId: 'user-1', + moduleId: 'mod-1', + score: -1, + }) + const completedAt = new Date('2026-01-01T00:00:00Z') + ;(prisma.completion.update as any).mockResolvedValue({ completedAt }) + ;(prisma.transaction.create as any).mockResolvedValue({ id: 'txn-1' }) + + const req = { + user: { id: 'user-1' }, + params: { id: 'mod-1' }, + body: { quizAnswers: [{ questionId: 'q1', answer: 'a' }] }, + } as unknown as Request + const res = createResponse() + + await completeModule(req, res) + + expect(queueEventMock).toHaveBeenCalledWith( + 'module.completed', + expect.objectContaining({ + userId: 'user-1', + moduleId: 'mod-1', + moduleTitle: 'Stellar Fundamentals', + score: 100, + isEligibleForReward: true, + reward: 10, + completedAt: completedAt.toISOString(), + }), + ) + }) +}) diff --git a/tests/unit/reward.controller.test.ts b/tests/unit/reward.controller.test.ts index fca88aaa..550f3fb5 100644 --- a/tests/unit/reward.controller.test.ts +++ b/tests/unit/reward.controller.test.ts @@ -3,6 +3,18 @@ import { RewardController } from '../../src/controllers/reward.controller' import { RewardService } from '../../src/services/reward.service' import { Request, Response } from 'express' +const { queueEventMock } = vi.hoisted(() => ({ + queueEventMock: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../../src/services/webhook.service', () => { + class WebhookService { + queueEvent = queueEventMock + } + + return { WebhookService } +}) + // Mock RewardService methods using vi.spyOn describe('RewardController', () => { let controller: RewardController @@ -337,6 +349,15 @@ describe('RewardController', () => { message: 'Withdrawal processed successfully', }), ) + expect(queueEventMock).toHaveBeenCalledWith( + 'reward.issued', + expect.objectContaining({ + userId: 'user-123', + transactionId: 'txn-withdrawal-123', + amount: 50, + stellarTxHash: 'stellar-hash-xyz', + }), + ) }) it('should reject withdrawal if wallet address is missing', async () => { @@ -459,6 +480,7 @@ describe('RewardController', () => { expect(nextFn).toHaveBeenCalledWith( expect.objectContaining({ message: 'Stellar network error' }), ) + expect(queueEventMock).not.toHaveBeenCalled() }) })