[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:
parent
a2b8bc652f
commit
b49a051d85
175
src/modules/audit/entities/unified-log.entity.ts
Normal file
175
src/modules/audit/entities/unified-log.entity.ts
Normal 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;
|
||||
}
|
||||
380
src/modules/billing/__tests__/billing-usage.service.spec.ts
Normal file
380
src/modules/billing/__tests__/billing-usage.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
74
src/modules/feature-flags/entities/flag-override.entity.ts
Normal file
74
src/modules/feature-flags/entities/flag-override.entity.ts
Normal 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;
|
||||
}
|
||||
186
src/modules/notifications/adapters/channel-adapter.interface.ts
Normal file
186
src/modules/notifications/adapters/channel-adapter.interface.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
330
src/modules/notifications/adapters/channel-adapters.service.ts
Normal file
330
src/modules/notifications/adapters/channel-adapters.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
435
src/modules/webhooks/__tests__/webhook-retry.spec.ts
Normal file
435
src/modules/webhooks/__tests__/webhook-retry.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user