From 5667384decf107de8ebf3c33e13c4506c237e853 Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Tue, 1 Sep 2026 21:32:25 +0100 Subject: [PATCH] feat(limits): emit limit webhook events from LimitsService Add limit event types (LIMIT_UPDATED, LIMIT_EXCEEDED, LIMIT_WARNING) to WebhookEventType enum and corresponding emitter methods to WebhookEventEmitterService. LimitsService already bridged internal events to the webhook emitter but the target methods and event types did not exist. This commit adds them and makes the webhook emitter injection @Optional() with safe optional chaining so the service remains functional without webhooks. Closes #770 Co-Authored-By: Claude Opus 4.6 --- src/limits/limits-webhook-events.spec.ts | 174 ++++++++++++++++++ src/limits/limits.service.ts | 10 +- src/webhooks/domain/webhook-events.ts | 5 + src/webhooks/webhook-event-emitter.service.ts | 39 ++++ 4 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 src/limits/limits-webhook-events.spec.ts diff --git a/src/limits/limits-webhook-events.spec.ts b/src/limits/limits-webhook-events.spec.ts new file mode 100644 index 0000000..fc7e16c --- /dev/null +++ b/src/limits/limits-webhook-events.spec.ts @@ -0,0 +1,174 @@ +import { LimitsService } from './limits.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { WebhookEventEmitterService } from '../webhooks/webhook-event-emitter.service'; + +describe('LimitsService webhook events', () => { + let service: LimitsService; + let prisma: jest.Mocked; + let webhookEmitter: jest.Mocked; + + beforeEach(() => { + prisma = { + walletLimit: { + findUnique: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + transaction: { + findMany: jest.fn().mockResolvedValue([]), + }, + } as any; + + webhookEmitter = { + emitLimitUpdated: jest.fn().mockResolvedValue(undefined), + emitLimitExceeded: jest.fn().mockResolvedValue(undefined), + emitLimitWarning: jest.fn().mockResolvedValue(undefined), + } as any; + + service = new LimitsService(prisma, webhookEmitter); + }); + + describe('setLimits', () => { + it('emits limit.updated webhook when creating new limits', async () => { + prisma.walletLimit.findUnique.mockResolvedValue(null); + prisma.walletLimit.upsert.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 1000, + perTransactionLimit: 100, + } as any); + + await service.setLimits('w1', 1000, 100); + + expect(webhookEmitter.emitLimitUpdated).toHaveBeenCalledWith({ + walletId: 'w1', + limitType: 'daily', + oldValue: null, + newValue: 1000, + }); + expect(webhookEmitter.emitLimitUpdated).toHaveBeenCalledWith({ + walletId: 'w1', + limitType: 'perTransaction', + oldValue: null, + newValue: 100, + }); + }); + + it('emits limit.updated only for changed values', async () => { + prisma.walletLimit.findUnique.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 1000, + perTransactionLimit: 100, + } as any); + prisma.walletLimit.upsert.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 2000, + perTransactionLimit: 100, + } as any); + + await service.setLimits('w1', 2000, 100); + + expect(webhookEmitter.emitLimitUpdated).toHaveBeenCalledTimes(1); + expect(webhookEmitter.emitLimitUpdated).toHaveBeenCalledWith({ + walletId: 'w1', + limitType: 'daily', + oldValue: 1000, + newValue: 2000, + }); + }); + }); + + describe('checkLimits', () => { + it('emits limit.exceeded when per-tx limit is exceeded', async () => { + prisma.walletLimit.findUnique.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 10000, + perTransactionLimit: 50, + } as any); + + await expect(service.checkLimits('w1', 100)).rejects.toThrow( + 'Transaction limit exceeded', + ); + + expect(webhookEmitter.emitLimitExceeded).toHaveBeenCalledWith({ + walletId: 'w1', + limitType: 'perTransaction', + limit: 50, + attempted: 100, + }); + }); + + it('emits limit.exceeded when daily limit is exceeded', async () => { + prisma.walletLimit.findUnique.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 100, + perTransactionLimit: 200, + } as any); + prisma.transaction.findMany.mockResolvedValue([ + { amount: '90' }, + ] as any); + + await expect(service.checkLimits('w1', 20)).rejects.toThrow( + 'Daily limit exceeded', + ); + + expect(webhookEmitter.emitLimitExceeded).toHaveBeenCalledWith({ + walletId: 'w1', + limitType: 'daily', + limit: 100, + attempted: 110, + }); + }); + + it('emits limit.warning when approaching 80% of daily limit', async () => { + prisma.walletLimit.findUnique.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 100, + perTransactionLimit: 200, + } as any); + prisma.transaction.findMany.mockResolvedValue([ + { amount: '70' }, + ] as any); + + await service.checkLimits('w1', 15); + + expect(webhookEmitter.emitLimitWarning).toHaveBeenCalledWith({ + walletId: 'w1', + limitType: 'daily', + limit: 100, + projected: 85, + }); + }); + + it('does not throw when webhook dispatch fails', async () => { + webhookEmitter.emitLimitExceeded.mockRejectedValue( + new Error('dispatch fail'), + ); + prisma.walletLimit.findUnique.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 10000, + perTransactionLimit: 50, + } as any); + + // The limits check itself should still throw, but webhook failure is swallowed + await expect(service.checkLimits('w1', 100)).rejects.toThrow( + 'Transaction limit exceeded', + ); + }); + }); + + describe('optional webhook emitter', () => { + it('works without webhook emitter (undefined)', async () => { + const serviceWithoutWebhook = new LimitsService(prisma); + prisma.walletLimit.findUnique.mockResolvedValue(null); + prisma.walletLimit.upsert.mockResolvedValue({ + walletId: 'w1', + dailyLimit: 1000, + perTransactionLimit: 100, + } as any); + + await expect( + serviceWithoutWebhook.setLimits('w1', 1000, 100), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/src/limits/limits.service.ts b/src/limits/limits.service.ts index d594612..ddd8b1e 100644 --- a/src/limits/limits.service.ts +++ b/src/limits/limits.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, Optional } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { WebhookEventEmitterService } from '../webhooks/webhook-event-emitter.service'; import { CreateLimitDto, LimitPeriod } from './dto/create-limit.dto'; @@ -10,7 +10,7 @@ export class LimitsService { constructor( private readonly prisma: PrismaService, - private readonly webhookEmitter: WebhookEventEmitterService, + @Optional() private readonly webhookEmitter?: WebhookEventEmitterService, ) {} async setLimits(walletId: string, daily: number, perTx: number) { @@ -89,7 +89,7 @@ export class LimitsService { newValue: number, ): void { this.webhookEmitter - .emitLimitUpdated({ walletId, limitType, oldValue, newValue }) + ?.emitLimitUpdated({ walletId, limitType, oldValue, newValue }) .catch((err) => { this.logger.error( `Failed to dispatch limit.updated webhook for wallet ${walletId}: ${(err as Error).message}`, @@ -104,7 +104,7 @@ export class LimitsService { attempted: number, ): void { this.webhookEmitter - .emitLimitExceeded({ walletId, limitType, limit, attempted }) + ?.emitLimitExceeded({ walletId, limitType, limit, attempted }) .catch((err) => { this.logger.error( `Failed to dispatch limit.exceeded webhook for wallet ${walletId}: ${(err as Error).message}`, @@ -119,7 +119,7 @@ export class LimitsService { projected: number, ): void { this.webhookEmitter - .emitLimitWarning({ walletId, limitType, limit, projected }) + ?.emitLimitWarning({ walletId, limitType, limit, projected }) .catch((err) => { this.logger.error( `Failed to dispatch limit.warning webhook for wallet ${walletId}: ${(err as Error).message}`, diff --git a/src/webhooks/domain/webhook-events.ts b/src/webhooks/domain/webhook-events.ts index f020318..8cf6700 100644 --- a/src/webhooks/domain/webhook-events.ts +++ b/src/webhooks/domain/webhook-events.ts @@ -28,6 +28,11 @@ export enum WebhookEventType { // User events USER_CREATED = 'user.created', USER_UPDATED = 'user.updated', + + // Limit events + LIMIT_UPDATED = 'limit.updated', + LIMIT_EXCEEDED = 'limit.exceeded', + LIMIT_WARNING = 'limit.warning', } export interface WebhookEvent { diff --git a/src/webhooks/webhook-event-emitter.service.ts b/src/webhooks/webhook-event-emitter.service.ts index cec72ca..7b0f1cb 100644 --- a/src/webhooks/webhook-event-emitter.service.ts +++ b/src/webhooks/webhook-event-emitter.service.ts @@ -197,6 +197,45 @@ export class WebhookEventEmitterService { await this.webhookDispatcher.dispatchEvent({ event }); } + /** + * Emits a limit.updated event + */ + async emitLimitUpdated(data: { + walletId: string; + limitType: string; + oldValue: number | null; + newValue: number; + }): Promise { + const event = this.createEvent(WebhookEventType.LIMIT_UPDATED, data); + await this.webhookDispatcher.dispatchEvent({ event }); + } + + /** + * Emits a limit.exceeded event + */ + async emitLimitExceeded(data: { + walletId: string; + limitType: string; + limit: number; + attempted: number; + }): Promise { + const event = this.createEvent(WebhookEventType.LIMIT_EXCEEDED, data); + await this.webhookDispatcher.dispatchEvent({ event }); + } + + /** + * Emits a limit.warning event + */ + async emitLimitWarning(data: { + walletId: string; + limitType: string; + limit: number; + projected: number; + }): Promise { + const event = this.createEvent(WebhookEventType.LIMIT_WARNING, data); + await this.webhookDispatcher.dispatchEvent({ event }); + } + /** * Emits a user.created event */