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
174 changes: 174 additions & 0 deletions src/limits/limits-webhook-events.spec.ts
Original file line number Diff line number Diff line change
@@ -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<PrismaService>;
let webhookEmitter: jest.Mocked<WebhookEventEmitterService>;

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();
});
});
});
10 changes: 5 additions & 5 deletions src/limits/limits.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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}`,
Expand All @@ -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}`,
Expand All @@ -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}`,
Expand Down
5 changes: 5 additions & 0 deletions src/webhooks/domain/webhook-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions src/webhooks/webhook-event-emitter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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<void> {
const event = this.createEvent(WebhookEventType.LIMIT_WARNING, data);
await this.webhookDispatcher.dispatchEvent({ event });
}

/**
* Emits a user.created event
*/
Expand Down