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
50 changes: 50 additions & 0 deletions src/webhooks/webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
IsUrl,
} from 'class-validator';

export enum WebhookEventType {
PROPERTY_CREATED = 'PROPERTY_CREATED',
PROPERTY_UPDATED = 'PROPERTY_UPDATED',
PROPERTY_STATUS_CHANGED = 'PROPERTY_STATUS_CHANGED',
TRANSACTION_CREATED = 'TRANSACTION_CREATED',
TRANSACTION_UPDATED = 'TRANSACTION_UPDATED',
TRANSACTION_COMPLETED = 'TRANSACTION_COMPLETED',
USER_VERIFIED = 'USER_VERIFIED',
}

export class CreateWebhookDto {
@IsUrl()
url: string;

@IsArray()
@IsEnum(WebhookEventType, { each: true })
eventTypes: WebhookEventType[];

@IsOptional()
@IsString()
description?: string;
}

export class UpdateWebhookDto {
@IsOptional()
@IsUrl()
url?: string;

@IsOptional()
@IsArray()
@IsEnum(WebhookEventType, { each: true })
eventTypes?: WebhookEventType[];

@IsOptional()
@IsBoolean()
isActive?: boolean;

@IsOptional()
@IsString()
description?: string;
}
54 changes: 54 additions & 0 deletions src/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { WebhooksService } from './webhooks.service';
import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';

@UseGuards(JwtAuthGuard)
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}

@Post()
create(@CurrentUser() user: any, @Body() dto: CreateWebhookDto) {
return this.webhooksService.create(user.id, dto);
}

@Get()
findAll(@CurrentUser() user: any) {
return this.webhooksService.findAll(user.id);
}

@Get(':id')
findOne(@Param('id') id: string, @CurrentUser() user: any) {
return this.webhooksService.findOne(id, user.id);
}

@Patch(':id')
update(
@Param('id') id: string,
@CurrentUser() user: any,
@Body() dto: UpdateWebhookDto,
) {
return this.webhooksService.update(id, user.id, dto);
}

@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() user: any) {
return this.webhooksService.remove(id, user.id);
}

@Get(':id/deliveries')
getDeliveries(@Param('id') id: string, @CurrentUser() user: any) {
return this.webhooksService.getDeliveries(id, user.id);
}
}
13 changes: 13 additions & 0 deletions src/webhooks/webhooks.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
import { PrismaModule } from '../database/prisma.module';
import { ScheduleModule } from '@nestjs/schedule';

@Module({
imports: [PrismaModule, ScheduleModule.forRoot()],
controllers: [WebhooksController],
providers: [WebhooksService],
exports: [WebhooksService],
})
export class WebhooksModule {}
143 changes: 143 additions & 0 deletions src/webhooks/webhooks.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WebhooksService } from './webhooks.service';
import { PrismaService } from '../database/prisma.service';
import { NotFoundException } from '@nestjs/common';
import { WebhookEventType, WebhookDeliveryStatus } from '@prisma/client';

const mockPrisma = {
webhook: {
create: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
webhookDelivery: {
create: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};

describe('WebhooksService', () => {
let service: WebhooksService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhooksService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();

service = module.get<WebhooksService>(WebhooksService);
jest.clearAllMocks();
});

describe('create', () => {
it('should create a webhook with a generated secret', async () => {
const dto = { url: 'https://example.com/hook', eventTypes: [WebhookEventType.PROPERTY_CREATED] };
mockPrisma.webhook.create.mockResolvedValue({ id: '1', ...dto, secret: 'abc', isActive: true });
const result = await service.create('user-1', dto as any);
expect(mockPrisma.webhook.create).toHaveBeenCalledTimes(1);
const callArgs = mockPrisma.webhook.create.mock.calls[0][0];
expect(callArgs.data.secret).toBeDefined();
expect(callArgs.data.secret).toHaveLength(64);
expect(result).toBeDefined();
});
});

describe('findAll', () => {
it('should return all webhooks for a user', async () => {
mockPrisma.webhook.findMany.mockResolvedValue([{ id: '1' }, { id: '2' }]);
const result = await service.findAll('user-1');
expect(result).toHaveLength(2);
expect(mockPrisma.webhook.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: 'user-1' } }),
);
});
});

describe('findOne', () => {
it('should return a webhook if found', async () => {
mockPrisma.webhook.findFirst.mockResolvedValue({ id: '1' });
const result = await service.findOne('1', 'user-1');
expect(result).toEqual({ id: '1' });
});

it('should throw NotFoundException if not found', async () => {
mockPrisma.webhook.findFirst.mockResolvedValue(null);
await expect(service.findOne('bad-id', 'user-1')).rejects.toThrow(NotFoundException);
});
});

describe('update', () => {
it('should update a webhook', async () => {
mockPrisma.webhook.findFirst.mockResolvedValue({ id: '1' });
mockPrisma.webhook.update.mockResolvedValue({ id: '1', isActive: false });
const result = await service.update('1', 'user-1', { isActive: false });
expect(mockPrisma.webhook.update).toHaveBeenCalledTimes(1);
expect(result.isActive).toBe(false);
});
});

describe('remove', () => {
it('should delete a webhook', async () => {
mockPrisma.webhook.findFirst.mockResolvedValue({ id: '1' });
mockPrisma.webhook.delete.mockResolvedValue({ id: '1' });
const result = await service.remove('1', 'user-1');
expect(result).toEqual({ message: 'Webhook deleted successfully' });
});
});

describe('trigger', () => {
it('should create a delivery for each matching active webhook', async () => {
mockPrisma.webhook.findMany.mockResolvedValue([
{ id: 'wh-1', url: 'https://example.com', secret: 'sec', eventTypes: [WebhookEventType.PROPERTY_CREATED] },
]);
mockPrisma.webhookDelivery.create.mockResolvedValue({ id: 'del-1' });
mockPrisma.webhookDelivery.update.mockResolvedValue({});

global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => 'ok',
});

await service.trigger(WebhookEventType.PROPERTY_CREATED, { event: 'PROPERTY_CREATED', data: {} });
expect(mockPrisma.webhookDelivery.create).toHaveBeenCalledTimes(1);
});
});

describe('getDeliveries', () => {
it('should return deliveries for a webhook', async () => {
mockPrisma.webhook.findFirst.mockResolvedValue({ id: 'wh-1' });
mockPrisma.webhookDelivery.findMany.mockResolvedValue([{ id: 'del-1', status: WebhookDeliveryStatus.SUCCESS }]);
const result = await service.getDeliveries('wh-1', 'user-1');
expect(result).toHaveLength(1);
});
});

describe('retryFailedDeliveries', () => {
it('should retry due failed deliveries', async () => {
mockPrisma.webhookDelivery.findMany.mockResolvedValue([
{
id: 'del-1',
attempts: 1,
payload: { event: 'PROPERTY_CREATED' },
webhook: { id: 'wh-1', url: 'https://example.com', secret: 'sec' },
},
]);
mockPrisma.webhookDelivery.update.mockResolvedValue({});

global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => 'ok',
});

await service.retryFailedDeliveries();
expect(mockPrisma.webhookDelivery.update).toHaveBeenCalled();
});
});
});
Loading