[SYNC] feat: Add pending entities, tests and notification adapters

- audit/unified-log.entity.ts
- billing/__tests__/billing-usage.service.spec.ts
- feature-flags/flag-override.entity.ts
- notifications/adapters/
- webhooks/__tests__/webhook-retry.spec.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-24 18:12:16 -06:00
parent a2b8bc652f
commit b49a051d85
6 changed files with 1580 additions and 0 deletions

View File

@ -0,0 +1,175 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
/**
* Unified log type enum - combines audit and activity
*/
export enum LogType {
// Audit actions (compliance/security)
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
READ = 'read',
LOGIN = 'login',
LOGOUT = 'logout',
EXPORT = 'export',
IMPORT = 'import',
// Activity types (user engagement)
PAGE_VIEW = 'page_view',
FEATURE_USE = 'feature_use',
SEARCH = 'search',
DOWNLOAD = 'download',
UPLOAD = 'upload',
SHARE = 'share',
INVITE = 'invite',
SETTINGS_CHANGE = 'settings_change',
SUBSCRIPTION_CHANGE = 'subscription_change',
PAYMENT = 'payment',
}
/**
* Log category enum - distinguishes between audit and activity
*/
export enum LogCategory {
AUDIT = 'audit',
ACTIVITY = 'activity',
}
/**
* Audit severity enum - only applies to AUDIT category
*/
export enum AuditSeverity {
INFO = 'info',
WARNING = 'warning',
ERROR = 'error',
CRITICAL = 'critical',
}
/**
* Actor type for logs
*/
export enum ActorType {
USER = 'user',
SYSTEM = 'system',
API_KEY = 'api_key',
WEBHOOK = 'webhook',
}
/**
* UnifiedLog Entity
* Merges AuditLog and ActivityLog into a single entity
* Maps to audit.unified_logs DDL table
*
* Rationale:
* - Both entities track user actions with similar fields
* - Unified querying across all user actions
* - Simplified reporting and analytics
* - Category field distinguishes audit vs activity
*/
@Entity({ name: 'unified_logs', schema: 'audit' })
@Index(['tenant_id', 'created_at'])
@Index(['category', 'log_type', 'created_at'])
@Index(['user_id', 'created_at'])
@Index(['resource_type', 'resource_id'])
@Index(['tenant_id', 'category', 'log_type', 'created_at'])
export class UnifiedLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
// Category and type
@Column({
type: 'enum',
enum: LogCategory,
default: LogCategory.ACTIVITY,
})
category: LogCategory;
@Column({
type: 'enum',
enum: LogType,
})
log_type: LogType;
// Actor information
@Column({ type: 'uuid', nullable: true })
user_id: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
user_email: string | null;
@Column({ type: 'varchar', length: 50, default: ActorType.USER })
actor_type: string;
// Resource identification
@Column({ type: 'varchar', length: 100 })
resource_type: string;
@Column({ type: 'varchar', length: 255, nullable: true })
resource_id: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
resource_name: string | null;
// Change tracking (only for AUDIT category)
@Column({ type: 'jsonb', nullable: true })
old_values: Record<string, any> | null;
@Column({ type: 'jsonb', nullable: true })
new_values: Record<string, any> | null;
@Column({ type: 'jsonb', nullable: true })
changed_fields: string[] | null;
// Severity (only for AUDIT category)
@Column({
type: 'enum',
enum: AuditSeverity,
nullable: true,
})
severity: AuditSeverity | null;
// Context information
@Column({ type: 'inet', nullable: true })
ip_address: string | null;
@Column({ type: 'text', nullable: true })
user_agent: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
request_id: string | null;
@Column({ type: 'uuid', nullable: true })
session_id: string | null;
// HTTP context (for API audit logs)
@Column({ type: 'varchar', length: 255, nullable: true })
endpoint: string | null;
@Column({ type: 'varchar', length: 10, nullable: true })
http_method: string | null;
@Column({ type: 'smallint', nullable: true })
response_status: number | null;
@Column({ type: 'integer', nullable: true })
duration_ms: number | null;
// Description and metadata
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
}

View File

@ -0,0 +1,380 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BillingService } from '../billing.service';
import { Subscription, SubscriptionStatus } from '../entities/subscription.entity';
import { Invoice, InvoiceStatus } from '../entities/invoice.entity';
import { PaymentMethod } from '../entities/payment-method.entity';
import { CreateSubscriptionDto } from '../dto/create-subscription.dto';
import { UpdateSubscriptionDto } from '../dto/update-subscription.dto';
describe('BillingService', () => {
let service: BillingService;
let subscriptionRepo: Repository<Subscription>;
let invoiceRepo: Repository<Invoice>;
let paymentMethodRepo: Repository<PaymentMethod>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingService,
{
provide: getRepositoryToken(Subscription),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
{
provide: getRepositoryToken(Invoice),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
{
provide: getRepositoryToken(PaymentMethod),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
service = module.get<BillingService>(BillingService);
subscriptionRepo = module.get<Repository<Subscription>>(getRepositoryToken(Subscription));
invoiceRepo = module.get<Repository<Invoice>>(getRepositoryToken(Invoice));
paymentMethodRepo = module.get<Repository<PaymentMethod>>(getRepositoryToken(PaymentMethod));
});
describe('createSubscription', () => {
it('should create a trial subscription', async () => {
const dto: CreateSubscriptionDto = {
tenant_id: 'tenant-123',
plan_id: 'plan-456',
trial_end: '2026-02-20',
payment_provider: 'stripe',
};
const expectedSubscription = {
tenant_id: dto.tenant_id,
plan_id: dto.plan_id,
status: SubscriptionStatus.TRIALING,
current_period_start: expect.any(Date),
current_period_end: expect.any(Date),
trial_end: new Date(dto.trial_end),
payment_provider: dto.payment_provider,
};
jest.spyOn(subscriptionRepo, 'create').mockReturnValue(expectedSubscription as any);
jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(expectedSubscription as any);
const result = await service.createSubscription(dto);
expect(subscriptionRepo.create).toHaveBeenCalledWith(expectedSubscription);
expect(subscriptionRepo.save).toHaveBeenCalledWith(expectedSubscription);
expect(result).toEqual(expectedSubscription);
});
it('should create an active subscription', async () => {
const dto: CreateSubscriptionDto = {
tenant_id: 'tenant-123',
plan_id: 'plan-456',
payment_provider: 'stripe',
};
const expectedSubscription = {
tenant_id: dto.tenant_id,
plan_id: dto.plan_id,
status: SubscriptionStatus.ACTIVE,
current_period_start: expect.any(Date),
current_period_end: expect.any(Date),
trial_end: null,
payment_provider: dto.payment_provider,
};
jest.spyOn(subscriptionRepo, 'create').mockReturnValue(expectedSubscription as any);
jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(expectedSubscription as any);
const result = await service.createSubscription(dto);
expect(subscriptionRepo.create).toHaveBeenCalledWith(expectedSubscription);
expect(subscriptionRepo.save).toHaveBeenCalledWith(expectedSubscription);
expect(result).toEqual(expectedSubscription);
});
});
describe('getSubscription', () => {
it('should return subscription for tenant', async () => {
const tenantId = 'tenant-123';
const subscription = {
id: 'sub-123',
tenant_id: tenantId,
status: SubscriptionStatus.ACTIVE,
};
jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(subscription as any);
const result = await service.getSubscription(tenantId);
expect(subscriptionRepo.findOne).toHaveBeenCalledWith({
where: { tenant_id: tenantId },
});
expect(result).toEqual(subscription);
});
it('should return null if subscription not found', async () => {
const tenantId = 'tenant-123';
jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(null);
const result = await service.getSubscription(tenantId);
expect(subscriptionRepo.findOne).toHaveBeenCalledWith({
where: { tenant_id: tenantId },
});
expect(result).toBeNull();
});
});
describe('updateSubscription', () => {
it('should update subscription status', async () => {
const tenantId = 'tenant-123';
const dto: UpdateSubscriptionDto = {
status: SubscriptionStatus.CANCELLED,
};
const existingSubscription = {
id: 'sub-123',
tenant_id: tenantId,
status: SubscriptionStatus.ACTIVE,
};
const updatedSubscription = {
...existingSubscription,
status: SubscriptionStatus.CANCELLED,
};
jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(existingSubscription as any);
jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(updatedSubscription as any);
const result = await service.updateSubscription(tenantId, dto);
expect(subscriptionRepo.findOne).toHaveBeenCalledWith({
where: { tenant_id: tenantId },
});
expect(subscriptionRepo.save).toHaveBeenCalledWith(updatedSubscription);
expect(result).toEqual(updatedSubscription);
});
it('should throw NotFoundException if subscription not found', async () => {
const tenantId = 'tenant-123';
const dto: UpdateSubscriptionDto = {
status: SubscriptionStatus.CANCELLED,
};
jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(null);
await expect(service.updateSubscription(tenantId, dto)).rejects.toThrow(
NotFoundException,
);
});
});
describe('cancelSubscription', () => {
it('should cancel subscription and create final invoice', async () => {
const tenantId = 'tenant-123';
const dto = {
reason: 'Customer request',
refund_amount: 50.00,
};
const existingSubscription = {
id: 'sub-123',
tenant_id: tenantId,
status: SubscriptionStatus.ACTIVE,
plan_id: 'plan-456',
};
const cancelledSubscription = {
...existingSubscription,
status: SubscriptionStatus.CANCELLED,
cancelled_at: expect.any(Date),
cancellation_reason: dto.reason,
};
const finalInvoice = {
tenant_id: tenantId,
subscription_id: existingSubscription.id,
status: InvoiceStatus.DRAFT,
total: dto.refund_amount,
type: 'refund',
};
jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(existingSubscription as any);
jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(cancelledSubscription as any);
jest.spyOn(invoiceRepo, 'create').mockReturnValue(finalInvoice as any);
jest.spyOn(invoiceRepo, 'save').mockResolvedValue(finalInvoice as any);
const result = await service.cancelSubscription(tenantId, dto);
expect(subscriptionRepo.findOne).toHaveBeenCalledWith({
where: { tenant_id: tenantId },
});
expect(subscriptionRepo.save).toHaveBeenCalledWith(cancelledSubscription);
expect(invoiceRepo.create).toHaveBeenCalledWith(finalInvoice);
expect(invoiceRepo.save).toHaveBeenCalledWith(finalInvoice);
expect(result).toEqual(cancelledSubscription);
});
});
describe('calculateUsage', () => {
it('should calculate monthly usage for subscription', async () => {
const tenantId = 'tenant-123';
const subscriptionId = 'sub-123';
const startDate = new Date('2026-01-01');
const endDate = new Date('2026-01-31');
const mockUsage = [
{ metric: 'api_calls', value: 1000, unit: 'count' },
{ metric: 'storage_gb', value: 50, unit: 'gb' },
{ metric: 'users', value: 25, unit: 'count' },
];
// Mock usage records query
jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue({
id: subscriptionId,
tenant_id: tenantId,
} as any);
// This would typically query a usage table
// For now, we'll mock the calculation
const expectedUsage = {
period: {
start: startDate,
end: endDate,
},
metrics: mockUsage,
total_cost: 150.00,
};
// Assuming service has a calculateUsage method
// If not, this test would need to be adjusted
const result = await service['calculateUsage']?.(tenantId, subscriptionId, startDate, endDate);
// For demonstration, we'll test the structure
expect(result).toBeDefined();
expect(result.period.start).toEqual(startDate);
expect(result.period.end).toEqual(endDate);
expect(result.metrics).toHaveLength(3);
});
});
describe('getInvoices', () => {
it('should return paginated invoices for tenant', async () => {
const tenantId = 'tenant-123';
const pagination = {
page: 1,
limit: 10,
};
const mockInvoices = [
{ id: 'inv-1', tenant_id: tenantId, total: 100.00 },
{ id: 'inv-2', tenant_id: tenantId, total: 50.00 },
];
jest.spyOn(invoiceRepo, 'find').mockResolvedValue(mockInvoices as any);
jest.spyOn(invoiceRepo, 'count').mockResolvedValue(2);
const result = await service.getInvoices(tenantId, pagination);
expect(invoiceRepo.find).toHaveBeenCalledWith({
where: { tenant_id: tenantId },
order: { created_at: 'DESC' },
skip: 0,
take: 10,
});
expect(invoiceRepo.count).toHaveBeenCalledWith({
where: { tenant_id: tenantId },
});
expect(result).toEqual({
invoices: mockInvoices,
total: 2,
page: 1,
limit: 10,
});
});
});
describe('processPayment', () => {
it('should process successful payment and update invoice', async () => {
const invoiceId = 'inv-123';
const paymentData = {
amount: 100.00,
method: 'card',
transaction_id: 'txn-456',
};
const existingInvoice = {
id: invoiceId,
status: InvoiceStatus.PENDING,
total: 100.00,
paid_amount: 0,
};
const updatedInvoice = {
...existingInvoice,
status: InvoiceStatus.PAID,
paid_amount: 100.00,
paid_at: expect.any(Date),
payment_method: paymentData.method,
transaction_id: paymentData.transaction_id,
};
jest.spyOn(invoiceRepo, 'findOne').mockResolvedValue(existingInvoice as any);
jest.spyOn(invoiceRepo, 'save').mockResolvedValue(updatedInvoice as any);
const result = await service.processPayment(invoiceId, paymentData);
expect(invoiceRepo.findOne).toHaveBeenCalledWith({
where: { id: invoiceId },
});
expect(invoiceRepo.save).toHaveBeenCalledWith(updatedInvoice);
expect(result).toEqual(updatedInvoice);
});
it('should throw error if invoice already paid', async () => {
const invoiceId = 'inv-123';
const paymentData = {
amount: 100.00,
method: 'card',
};
const existingInvoice = {
id: invoiceId,
status: InvoiceStatus.PAID,
total: 100.00,
paid_amount: 100.00,
};
jest.spyOn(invoiceRepo, 'findOne').mockResolvedValue(existingInvoice as any);
await expect(service.processPayment(invoiceId, paymentData)).rejects.toThrow(
BadRequestException,
);
});
});
});

View File

@ -0,0 +1,74 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { FeatureFlag } from './feature-flag.entity';
/**
* Target type enum for flag overrides
*/
export enum FlagTargetType {
USER = 'user',
TENANT = 'tenant',
}
/**
* FlagOverride Entity
* Merges UserFlag and TenantFlag into a single entity
* Maps to feature_flags.flag_overrides DDL table
*
* Rationale:
* - Both entities represent flag overrides with identical structure
* - Simplified flag evaluation logic
* - Single query for all overrides
* - target_type field distinguishes user vs tenant overrides
*/
@Entity({ name: 'flag_overrides', schema: 'feature_flags' })
@Index(['target_type', 'target_id', 'flag_id'], { unique: true })
@Index(['tenant_id', 'target_type'])
export class FlagOverride {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
// Target identification
@Column({
type: 'enum',
enum: FlagTargetType,
})
target_type: FlagTargetType;
@Column({ type: 'uuid' })
target_id: string; // user_id or tenant_id based on target_type
@Column({ type: 'uuid' })
flag_id: string;
@ManyToOne(() => FeatureFlag, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'flag_id' })
flag: FeatureFlag;
// Flag value
@Column({ type: 'boolean', default: true })
is_enabled: boolean;
@Column({ type: 'jsonb', nullable: true })
value: any;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updated_at: Date;
}

View File

@ -0,0 +1,186 @@
import { Injectable, Logger } from '@nestjs/common';
/**
* Channel Adapter Interface
* Defines contract for all notification channels
*/
export interface INotificationChannel {
/**
* Unique identifier for the channel
*/
readonly channelType: string;
/**
* Send notification through this channel
* @param notification Notification data
* @param preferences User preferences for this channel
* @returns Delivery result
*/
send(notification: any, preferences?: any): Promise<NotificationDeliveryResult>;
/**
* Validate if channel is available
* @returns True if channel can send messages
*/
isAvailable(): Promise<boolean>;
/**
* Get channel configuration schema
* @returns JSON schema for channel config
*/
getConfigSchema(): Record<string, any>;
}
/**
* Notification delivery result
*/
export interface NotificationDeliveryResult {
success: boolean;
messageId?: string;
error?: string;
metadata?: Record<string, any>;
}
/**
* Channel Adapter Base Class
* Provides common functionality for all channels
*/
@Injectable()
export abstract class BaseNotificationAdapter implements INotificationChannel {
protected readonly logger: Logger;
constructor(channelType: string) {
this.logger = new Logger(`${channelType}Adapter`);
}
abstract readonly channelType: string;
abstract send(notification: any, preferences?: any): Promise<NotificationDeliveryResult>;
abstract isAvailable(): Promise<boolean>;
abstract getConfigSchema(): Record<string, any>;
/**
* Transform notification to channel-specific format
*/
protected abstract transform(notification: any): any;
/**
* Validate notification data
*/
protected validate(notification: any): boolean {
return !!(notification.title && notification.message);
}
/**
* Log delivery attempt
*/
protected logAttempt(notification: any, result: NotificationDeliveryResult): void {
if (result.success) {
this.logger.log(
`Notification sent via ${this.channelType}: ${result.messageId}`,
);
} else {
this.logger.error(
`Failed to send via ${this.channelType}: ${result.error}`,
);
}
}
}
/**
* Channel Registry
* Manages all available notification channels
*/
@Injectable()
export class NotificationChannelRegistry {
private readonly channels = new Map<string, INotificationChannel>();
/**
* Register a new channel
*/
register(channel: INotificationChannel): void {
this.channels.set(channel.channelType, channel);
}
/**
* Get channel by type
*/
get(channelType: string): INotificationChannel | undefined {
return this.channels.get(channelType);
}
/**
* Get all registered channels
*/
getAll(): INotificationChannel[] {
return Array.from(this.channels.values());
}
/**
* Get available channels
*/
async getAvailable(): Promise<INotificationChannel[]> {
const channels = await Promise.all(
Array.from(this.channels.values()).map(async (channel) => ({
channel,
available: await channel.isAvailable(),
})),
);
return channels
.filter(({ available }) => available)
.map(({ channel }) => channel);
}
}
/**
* Channel Manager
* Orchestrates notification delivery across multiple channels
*/
@Injectable()
export class NotificationChannelManager {
constructor(private readonly registry: NotificationChannelRegistry) {}
/**
* Send notification through preferred channels
*/
async send(
notification: any,
preferredChannels: string[] = [],
fallbackChannels: string[] = ['in_app'],
): Promise<NotificationDeliveryResult[]> {
const results: NotificationDeliveryResult[] = [];
const channels = await this.registry.getAvailable();
// Try preferred channels first
for (const channelType of preferredChannels) {
const channel = channels.find((c) => c.channelType === channelType);
if (channel) {
const result = await channel.send(notification);
results.push(result);
}
}
// If no preferred channels worked, try fallbacks
if (!results.some((r) => r.success)) {
for (const channelType of fallbackChannels) {
const channel = channels.find((c) => c.channelType === channelType);
if (channel) {
const result = await channel.send(notification);
results.push(result);
if (result.success) break; // Stop at first successful fallback
}
}
}
return results;
}
/**
* Send through all available channels
*/
async sendToAll(notification: any): Promise<NotificationDeliveryResult[]> {
const channels = await this.registry.getAvailable();
const promises = channels.map((channel) => channel.send(notification));
return Promise.all(promises);
}
}

View File

@ -0,0 +1,330 @@
import { Injectable, Logger } from '@nestjs/common';
import { BaseNotificationAdapter, NotificationDeliveryResult } from './channel-adapter.interface';
/**
* Email Channel Adapter
* Handles email notifications
*/
@Injectable()
export class EmailChannelAdapter extends BaseNotificationAdapter {
readonly channelType = 'email';
constructor(private readonly emailService: any) {
super('email');
}
async isAvailable(): Promise<boolean> {
try {
// Check email service configuration
return !!(this.emailService && await this.emailService.isConfigured?.());
} catch {
return false;
}
}
getConfigSchema(): Record<string, any> {
return {
type: 'object',
properties: {
smtp: {
type: 'object',
properties: {
host: { type: 'string' },
port: { type: 'number' },
secure: { type: 'boolean' },
auth: {
type: 'object',
properties: {
user: { type: 'string' },
pass: { type: 'string' },
},
},
},
},
},
};
}
protected transform(notification: any): any {
return {
to: notification.recipient,
subject: notification.title,
text: notification.message,
html: notification.html || notification.message,
attachments: notification.attachments || [],
};
}
async send(notification: any, preferences?: any): Promise<NotificationDeliveryResult> {
try {
if (!this.validate(notification)) {
return {
success: false,
error: 'Invalid notification data',
};
}
const emailData = this.transform(notification);
const result = await this.emailService.sendMail(emailData);
const deliveryResult: NotificationDeliveryResult = {
success: true,
messageId: result.messageId,
metadata: {
provider: 'smtp',
...result,
},
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
} catch (error) {
const deliveryResult: NotificationDeliveryResult = {
success: false,
error: error.message,
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
}
}
}
/**
* Push Notification Channel Adapter
* Handles mobile/web push notifications
*/
@Injectable()
export class PushChannelAdapter extends BaseNotificationAdapter {
readonly channelType = 'push';
constructor(private readonly pushService: any) {
super('push');
}
async isAvailable(): Promise<boolean> {
try {
return !!(this.pushService && await this.pushService.isConfigured?.());
} catch {
return false;
}
}
getConfigSchema(): Record<string, any> {
return {
type: 'object',
properties: {
fcm: {
type: 'object',
properties: {
serverKey: { type: 'string' },
},
},
apns: {
type: 'object',
properties: {
keyId: { type: 'string' },
teamId: { type: 'string' },
bundleId: { type: 'string' },
},
},
},
};
}
protected transform(notification: any): any {
return {
title: notification.title,
body: notification.message,
icon: notification.icon,
image: notification.image,
data: notification.data || {},
actions: notification.actions || [],
badge: notification.badge,
sound: notification.sound || 'default',
};
}
async send(notification: any, preferences?: any): Promise<NotificationDeliveryResult> {
try {
if (!notification.deviceToken) {
return {
success: false,
error: 'Device token required for push notifications',
};
}
const pushData = this.transform(notification);
const result = await this.pushService.send(notification.deviceToken, pushData);
const deliveryResult: NotificationDeliveryResult = {
success: true,
messageId: result.messageId,
metadata: {
provider: 'fcm/apns',
deviceToken: notification.deviceToken,
...result,
},
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
} catch (error) {
const deliveryResult: NotificationDeliveryResult = {
success: false,
error: error.message,
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
}
}
}
/**
* WhatsApp Channel Adapter
* Handles WhatsApp Business API notifications
*/
@Injectable()
export class WhatsAppChannelAdapter extends BaseNotificationAdapter {
readonly channelType = 'whatsapp';
constructor(private readonly whatsappService: any) {
super('whatsapp');
}
async isAvailable(): Promise<boolean> {
try {
return !!(this.whatsappService && await this.whatsappService.isConfigured?.());
} catch {
return false;
}
}
getConfigSchema(): Record<string, any> {
return {
type: 'object',
properties: {
accessToken: { type: 'string' },
phoneNumberId: { type: 'string' },
version: { type: 'string' },
webhookVerifyToken: { type: 'string' },
},
};
}
protected transform(notification: any): any {
return {
to: notification.recipient,
type: notification.template ? 'template' : 'text',
text: { body: notification.message },
template: notification.template,
};
}
async send(notification: any, preferences?: any): Promise<NotificationDeliveryResult> {
try {
if (!notification.recipient) {
return {
success: false,
error: 'Recipient required for WhatsApp notifications',
};
}
const whatsappData = this.transform(notification);
const result = await this.whatsappService.sendMessage(whatsappData);
const deliveryResult: NotificationDeliveryResult = {
success: true,
messageId: result.messageId,
metadata: {
provider: 'whatsapp',
recipient: notification.recipient,
...result,
},
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
} catch (error) {
const deliveryResult: NotificationDeliveryResult = {
success: false,
error: error.message,
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
}
}
}
/**
* In-App Channel Adapter
* Handles in-app notifications (stored in database)
*/
@Injectable()
export class InAppChannelAdapter extends BaseNotificationAdapter {
readonly channelType = 'in_app';
constructor(private readonly notificationRepository: any) {
super('in_app');
}
async isAvailable(): Promise<boolean> {
return true; // In-app is always available if DB is accessible
}
getConfigSchema(): Record<string, any> {
return {
type: 'object',
properties: {
retention: {
type: 'object',
properties: {
days: { type: 'number', default: 30 },
},
},
},
};
}
protected transform(notification: any): any {
return {
user_id: notification.userId,
tenant_id: notification.tenantId,
type: notification.type || 'info',
title: notification.title,
message: notification.message,
data: notification.data || null,
action_url: notification.actionUrl || null,
delivery_status: 'delivered',
};
}
async send(notification: any, preferences?: any): Promise<NotificationDeliveryResult> {
try {
const notificationData = this.transform(notification);
const saved = await this.notificationRepository.save(notificationData);
const deliveryResult: NotificationDeliveryResult = {
success: true,
messageId: saved.id,
metadata: {
provider: 'database',
notificationId: saved.id,
},
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
} catch (error) {
const deliveryResult: NotificationDeliveryResult = {
success: false,
error: error.message,
};
this.logAttempt(notification, deliveryResult);
return deliveryResult;
}
}
}

View File

@ -0,0 +1,435 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getQueueToken } from '@nestjs/bullmq';
import { Repository } from 'typeorm';
import { Queue } from 'bullmq';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { WebhookService } from '../services/webhook.service';
import { WebhookEntity, WebhookDeliveryEntity, DeliveryStatus } from '../entities';
describe('WebhookService - Retry Logic', () => {
let service: WebhookService;
let webhookRepo: jest.Mocked<Repository<WebhookEntity>>;
let deliveryRepo: jest.Mocked<Repository<WebhookDeliveryEntity>>;
let webhookQueue: jest.Mocked<Queue>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
const mockWebhook: Partial<WebhookEntity> = {
id: 'webhook-001',
tenantId: mockTenantId,
name: 'Test Webhook',
description: 'Test webhook description',
url: 'https://example.com/webhook',
events: ['user.created', 'user.updated'],
headers: { 'X-Custom': 'header' },
secret: 'whsec_testsecret123',
isActive: true,
retryPolicy: {
maxAttempts: 3,
backoffStrategy: 'exponential',
initialDelay: 1000,
maxDelay: 30000,
},
createdBy: mockUserId,
createdAt: new Date(),
};
beforeEach(async () => {
const mockWebhookRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
const mockDeliveryRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
const mockWebhookQueue = {
add: jest.fn(),
getJob: jest.fn(),
remove: jest.fn(),
pause: jest.fn(),
resume: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhookService,
{
provide: getRepositoryToken(WebhookEntity),
useValue: mockWebhookRepo,
},
{
provide: getRepositoryToken(WebhookDeliveryEntity),
useValue: mockDeliveryRepo,
},
{
provide: getQueueToken('webhook-queue'),
useValue: mockWebhookQueue,
},
],
}).compile();
service = module.get<WebhookService>(WebhookService);
webhookRepo = module.get(getRepositoryToken(WebhookEntity));
deliveryRepo = module.get(getRepositoryToken(WebhookDeliveryEntity));
webhookQueue = module.get(getQueueToken('webhook-queue'));
});
describe('retryFailedDelivery', () => {
it('should retry delivery with exponential backoff', async () => {
const deliveryId = 'delivery-001';
const mockDelivery = {
id: deliveryId,
webhookId: 'webhook-001',
tenantId: mockTenantId,
eventType: 'user.created',
payload: { type: 'user.created', data: { id: 'user-001' } },
status: DeliveryStatus.FAILED,
attempt: 1,
maxAttempts: 3,
lastError: 'Connection timeout',
nextRetryAt: new Date(Date.now() + 2000),
createdAt: new Date(),
};
const expectedRetryDelivery = {
...mockDelivery,
attempt: 2,
lastError: null,
nextRetryAt: new Date(Date.now() + 4000), // Exponential: 2^1 * 1000ms
};
jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue(mockDelivery as any);
jest.spyOn(deliveryRepo, 'save').mockResolvedValue(expectedRetryDelivery as any);
jest.spyOn(webhookQueue, 'add').mockResolvedValue({ id: 'job-123' } as any);
const result = await service.retryFailedDelivery(deliveryId);
expect(deliveryRepo.findOne).toHaveBeenCalledWith({
where: { id: deliveryId },
});
// Check exponential backoff calculation
const delay = Math.min(
1000 * Math.pow(2, mockDelivery.attempt),
30000,
);
expect(expectedRetryDelivery.nextRetryAt.getTime()).toBeCloseTo(
Date.now() + delay,
100,
);
expect(deliveryRepo.save).toHaveBeenCalledWith(expectedRetryDelivery);
expect(webhookQueue.add).toHaveBeenCalledWith(
'webhook-delivery',
{
deliveryId: deliveryId,
webhookId: mockDelivery.webhookId,
url: expect.any(String),
payload: mockDelivery.payload,
headers: expect.any(Object),
attempt: 2,
},
{
delay: Math.floor(delay / 1000),
attempts: 3,
backoff: {
type: 'exponential',
delay: delay,
},
},
);
expect(result).toEqual(expectedRetryDelivery);
});
it('should not retry if max attempts reached', async () => {
const deliveryId = 'delivery-002';
const mockDelivery = {
id: deliveryId,
webhookId: 'webhook-001',
tenantId: mockTenantId,
eventType: 'user.created',
payload: { type: 'user.created', data: { id: 'user-001' } },
status: DeliveryStatus.FAILED,
attempt: 3,
maxAttempts: 3,
lastError: 'Connection timeout',
createdAt: new Date(),
};
const expectedFinalDelivery = {
...mockDelivery,
status: DeliveryStatus.FAILED_PERMANENT,
failedAt: expect.any(Date),
finalError: 'Max retry attempts exceeded',
};
jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue(mockDelivery as any);
jest.spyOn(deliveryRepo, 'save').mockResolvedValue(expectedFinalDelivery as any);
const result = await service.retryFailedDelivery(deliveryId);
expect(deliveryRepo.save).toHaveBeenCalledWith(expectedFinalDelivery);
expect(webhookQueue.add).not.toHaveBeenCalled();
expect(result).toEqual(expectedFinalDelivery);
});
it('should not retry if webhook is inactive', async () => {
const deliveryId = 'delivery-003';
const mockDelivery = {
id: deliveryId,
webhookId: 'webhook-001',
tenantId: mockTenantId,
status: DeliveryStatus.FAILED,
attempt: 1,
maxAttempts: 3,
};
const inactiveWebhook = {
...mockWebhook,
isActive: false,
};
jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue(mockDelivery as any);
jest.spyOn(webhookRepo, 'findOne').mockResolvedValue(inactiveWebhook as any);
await expect(service.retryFailedDelivery(deliveryId)).rejects.toThrow(
BadRequestException,
);
});
});
describe('calculateBackoffDelay', () => {
it('should calculate exponential backoff correctly', () => {
const retryPolicy = {
maxAttempts: 3,
backoffStrategy: 'exponential',
initialDelay: 1000,
maxDelay: 30000,
};
// Test exponential backoff
expect(service['calculateBackoffDelay'](retryPolicy, 1)).toBe(1000); // 2^0 * 1000
expect(service['calculateBackoffDelay'](retryPolicy, 2)).toBe(2000); // 2^1 * 1000
expect(service['calculateBackoffDelay'](retryPolicy, 3)).toBe(4000); // 2^2 * 1000
});
it('should respect max delay limit', () => {
const retryPolicy = {
maxAttempts: 10,
backoffStrategy: 'exponential',
initialDelay: 1000,
maxDelay: 5000,
};
// Should not exceed maxDelay
expect(service['calculateBackoffDelay'](retryPolicy, 6)).toBe(5000); // 2^5 * 1000 = 32000 > 5000
expect(service['calculateBackoffDelay'](retryPolicy, 10)).toBe(5000);
});
it('should handle linear backoff', () => {
const retryPolicy = {
maxAttempts: 3,
backoffStrategy: 'linear',
initialDelay: 1000,
maxDelay: 30000,
};
expect(service['calculateBackoffDelay'](retryPolicy, 1)).toBe(1000);
expect(service['calculateBackoffDelay'](retryPolicy, 2)).toBe(2000);
expect(service['calculateBackoffDelay'](retryPolicy, 3)).toBe(3000);
});
it('should handle fixed delay', () => {
const retryPolicy = {
maxAttempts: 3,
backoffStrategy: 'fixed',
initialDelay: 5000,
maxDelay: 30000,
};
expect(service['calculateBackoffDelay'](retryPolicy, 1)).toBe(5000);
expect(service['calculateBackoffDelay'](retryPolicy, 2)).toBe(5000);
expect(service['calculateBackoffDelay'](retryPolicy, 3)).toBe(5000);
});
});
describe('processDeliveryQueue', () => {
it('should process successful delivery', async () => {
const job = {
data: {
deliveryId: 'delivery-004',
webhookId: 'webhook-001',
url: 'https://example.com/webhook',
payload: { test: 'data' },
headers: { 'Authorization': 'Bearer token' },
attempt: 1,
},
opts: {
attempts: 3,
},
id: 'job-123',
};
const mockResponse = {
status: 200,
data: { success: true },
};
const updatedDelivery = {
id: job.data.deliveryId,
status: DeliveryStatus.DELIVERED,
attempt: job.data.attempt,
deliveredAt: new Date(),
responseStatus: 200,
responseBody: JSON.stringify(mockResponse),
};
jest.spyOn(service, 'sendHttpRequest').mockResolvedValue(mockResponse);
jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue({
id: job.data.deliveryId,
} as any);
jest.spyOn(deliveryRepo, 'save').mockResolvedValue(updatedDelivery as any);
const result = await service.processDeliveryQueue(job);
expect(service.sendHttpRequest).toHaveBeenCalledWith(
job.data.url,
job.data.payload,
job.data.headers,
);
expect(deliveryRepo.save).toHaveBeenCalledWith(updatedDelivery);
expect(result).toEqual(updatedDelivery);
});
it('should handle delivery failure and schedule retry', async () => {
const job = {
data: {
deliveryId: 'delivery-005',
webhookId: 'webhook-001',
url: 'https://example.com/webhook',
payload: { test: 'data' },
attempt: 1,
},
opts: {
attempts: 3,
},
id: 'job-123',
};
const error = new Error('Connection timeout');
error.code = 'ECONNRESET';
const failedDelivery = {
id: job.data.deliveryId,
status: DeliveryStatus.FAILED,
attempt: job.data.attempt,
lastError: error.message,
nextRetryAt: new Date(Date.now() + 2000),
};
jest.spyOn(service, 'sendHttpRequest').mockRejectedValue(error);
jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue({
id: job.data.deliveryId,
} as any);
jest.spyOn(deliveryRepo, 'save').mockResolvedValue(failedDelivery as any);
jest.spyOn(service, 'retryFailedDelivery').mockResolvedValue(failedDelivery as any);
const result = await service.processDeliveryQueue(job);
expect(service.sendHttpRequest).toHaveBeenCalled();
expect(deliveryRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
status: DeliveryStatus.FAILED,
lastError: error.message,
}),
);
expect(service.retryFailedDelivery).toHaveBeenCalledWith(job.data.deliveryId);
expect(result).toEqual(failedDelivery);
});
it('should handle 429 rate limit with longer backoff', async () => {
const job = {
data: {
deliveryId: 'delivery-006',
webhookId: 'webhook-001',
url: 'https://example.com/webhook',
payload: { test: 'data' },
attempt: 1,
},
opts: {
attempts: 3,
},
};
const rateLimitError = new Error('Too Many Requests');
rateLimitError.code = '429';
const rateLimitedDelivery = {
id: job.data.deliveryId,
status: DeliveryStatus.FAILED,
attempt: job.data.attempt,
lastError: rateLimitError.message,
nextRetryAt: new Date(Date.now() + 60000), // Longer delay for rate limit
};
jest.spyOn(service, 'sendHttpRequest').mockRejectedValue(rateLimitError);
jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue({
id: job.data.deliveryId,
} as any);
jest.spyOn(deliveryRepo, 'save').mockResolvedValue(rateLimitedDelivery as any);
const result = await service.processDeliveryQueue(job);
expect(deliveryRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
lastError: rateLimitError.message,
nextRetryAt: expect.any(Date),
}),
);
// Verify rate limit gets special treatment
const nextRetryDelay = rateLimitedDelivery.nextRetryAt.getTime() - Date.now();
expect(nextRetryDelay).toBeGreaterThan(30000); // Should exceed normal max delay
});
});
describe('getRetryStatistics', () => {
it('should return retry statistics for webhook', async () => {
const webhookId = 'webhook-001';
const mockStats = {
totalDeliveries: 100,
successfulDeliveries: 85,
failedDeliveries: 15,
averageAttempts: 1.2,
retryRate: 0.15,
last24Hours: {
total: 20,
successful: 18,
failed: 2,
},
};
jest.spyOn(deliveryRepo, 'count').mockResolvedValue(mockStats.totalDeliveries);
jest.spyOn(deliveryRepo, 'count').mockResolvedValue(mockStats.successfulDeliveries);
jest.spyOn(deliveryRepo, 'average').mockResolvedValue(mockStats.averageAttempts);
const result = await service.getRetryStatistics(webhookId);
expect(result).toEqual(mockStats);
});
});
});