Compare commits
No commits in common. "b49a051d854962a0eeefb1dc73c96c9d073f36a8" and "bb556a77896573046737af8bbda4ab244d7b247b" have entirely different histories.
b49a051d85
...
bb556a7789
@ -6,12 +6,11 @@ NODE_ENV=development
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# FUENTE VERDAD: workspace-v2/orchestration/inventarios/WORKSPACE-INTEGRATION.yml
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=template_saas_dev
|
DB_NAME=template_saas_dev
|
||||||
DB_USER=template_saas_user
|
DB_USER=gamilit_user
|
||||||
DB_PASSWORD=saas_dev_2026
|
DB_PASSWORD=GO0jAOgw8Yzankwt
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|||||||
@ -1,175 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,380 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,330 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,435 +0,0 @@
|
|||||||
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