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 */