Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@keyv/redis": "^5.1.6",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
Expand All @@ -47,6 +48,7 @@
"archiver": "^7.0.1",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
"bullmq": "^5.79.1",
"cache-manager": "^7.2.8",
"cache-manager-redis-store": "^3.0.1",
"cacheable": "^2.3.4",
Expand All @@ -55,6 +57,7 @@
"compression": "^1.7.4",
"dotenv": "^17.3.1",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"isomorphic-dompurify": "^3.15.0",
"joi": "^18.0.2",
"multer": "^2.1.1",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { PostmanModule } from './common/postman/postman.module';
import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware';
import { CompressionMetricsMiddleware } from './common/middleware/compression.middleware';
import { JobsModule } from './modules/jobs/jobs.module';
import { JobQueueModule } from './modules/job-queue/job-queue.module';
import { GracefulShutdownService } from './common/services/graceful-shutdown.service';
import { ApmModule } from './modules/apm/apm.module';
import { PerformanceModule } from './modules/performance/performance.module';
Expand Down Expand Up @@ -325,6 +326,7 @@ const envValidationSchema = Joi.object({
ApmModule,
FeatureFlagsModule,
JobsModule,
JobQueueModule,
SandboxModule,
FeedbackModule,
CommonModule,
Expand Down
13 changes: 12 additions & 1 deletion backend/src/auth/dto/auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,30 @@ import {
MinLength,
MaxLength,
IsOptional,
Matches,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsStellarPublicKey } from '../../common/validators/is-stellar-key.validator';
import { IsStrongPassword } from '../../common/validators/is-strong-password.validator';
import { Trim } from '../../common/validators/sanitize.transform';

export class RegisterDto {
@ApiProperty({ example: 'alice@example.com' })
@Trim()
@IsEmail()
email: string;

@ApiProperty({ example: 'supersecret123' })
@IsString()
@MinLength(8)
@MaxLength(32)
@MaxLength(72, { message: 'password must not exceed 72 characters' })
@IsStrongPassword()
password: string;

@ApiProperty({ example: 'Alice', required: false })
@IsString()
@Trim()
@MaxLength(255)
name?: string;

@ApiPropertyOptional({
Expand All @@ -29,6 +36,10 @@ export class RegisterDto {
})
@IsOptional()
@IsString()
@Trim()
@Matches(/^[A-Z0-9]{4,12}$/, {
message: 'referralCode must be 4-12 uppercase alphanumeric characters',
})
referralCode?: string;
}

Expand Down
7 changes: 5 additions & 2 deletions backend/src/common/circuit-breaker/circuit-breaker.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { CircuitBreakerService } from './circuit-breaker.service';
import { ExternalCallService } from './external-call.service';
import { DependencyHealthController } from './dependency-health.controller';

@Module({
providers: [CircuitBreakerService],
exports: [CircuitBreakerService],
controllers: [DependencyHealthController],
providers: [CircuitBreakerService, ExternalCallService],
exports: [CircuitBreakerService, ExternalCallService],
})
export class CircuitBreakerModule {}
37 changes: 37 additions & 0 deletions backend/src/common/circuit-breaker/dependency-health.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { ExternalCallService } from './external-call.service';
import { Roles } from '../decorators/roles.decorator';
import { Role } from '../enums/role.enum';

@ApiTags('Admin - Dependencies')
@ApiBearerAuth()
@Controller('admin/dependencies')
@Roles(Role.ADMIN)
export class DependencyHealthController {
constructor(private readonly externalCallService: ExternalCallService) {}

@Get('health')
@ApiOperation({ summary: 'Get health status of all external dependencies' })
getHealth() {
return {
success: true,
data: this.externalCallService.getDependencyHealth(),
};
}

@Get('metrics')
@ApiOperation({
summary: 'Get recent call metrics for external dependencies',
})
getMetrics(@Query('dependency') dependency?: string) {
const metrics = this.externalCallService.getMetrics(dependency);
return {
success: true,
data: {
count: metrics.length,
metrics: metrics.slice(-50),
},
};
}
}
161 changes: 161 additions & 0 deletions backend/src/common/circuit-breaker/external-call.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ExternalCallService } from './external-call.service';

describe('ExternalCallService', () => {
let service: ExternalCallService;
let eventEmitter: EventEmitter2;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ExternalCallService,
{
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue(5),
},
},
{
provide: EventEmitter2,
useValue: {
emit: jest.fn(),
},
},
],
}).compile();

service = module.get<ExternalCallService>(ExternalCallService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('execute', () => {
it('should execute a successful call', async () => {
const result = await service.execute('email', async () => 'success');
expect(result).toBe('success');
});

it('should retry on failure and succeed', async () => {
let attempts = 0;
const result = await service.execute(
'email',
async () => {
attempts++;
if (attempts < 2) throw new Error('temp failure');
return 'recovered';
},
{ retryDelayMs: 10 },
);
expect(result).toBe('recovered');
expect(attempts).toBe(2);
});

it('should throw after all retries exhausted', async () => {
await expect(
service.execute(
'email',
async () => {
throw new Error('permanent failure');
},
{ maxRetries: 1, retryDelayMs: 10 },
),
).rejects.toThrow('permanent failure');
});

it('should timeout slow calls', async () => {
await expect(
service.execute(
'email',
() => new Promise((resolve) => setTimeout(resolve, 5000)),
{ timeoutMs: 50, maxRetries: 0 },
),
).rejects.toThrow('timed out');
}, 10000);

it('should emit dependency.call event', async () => {
await service.execute('email', async () => 'ok');
expect(eventEmitter.emit).toHaveBeenCalledWith(
'dependency.call',
expect.objectContaining({
dependency: 'email',
success: true,
}),
);
});
});

describe('executeWithFallback', () => {
it('should return primary result on success', async () => {
const result = await service.executeWithFallback(
'email',
async () => 'primary',
() => 'fallback',
);
expect(result).toBe('primary');
});

it('should use fallback when primary fails', async () => {
const result = await service.executeWithFallback(
'email',
async () => {
throw new Error('failed');
},
() => 'fallback',
{ maxRetries: 0, retryDelayMs: 10 },
);
expect(result).toBe('fallback');
});

it('should support async fallback', async () => {
const result = await service.executeWithFallback(
'email',
async () => {
throw new Error('failed');
},
async () => 'async-fallback',
{ maxRetries: 0, retryDelayMs: 10 },
);
expect(result).toBe('async-fallback');
});
});

describe('getDependencyHealth', () => {
it('should return health for all registered dependencies', () => {
const health = service.getDependencyHealth();
expect(health).toHaveProperty('email');
expect(health).toHaveProperty('stellar-rpc');
expect(health['email']).toHaveProperty('state');
expect(health['email']).toHaveProperty('failureRate');
expect(health['email']).toHaveProperty('avgLatencyMs');
});
});

describe('getMetrics', () => {
it('should return empty metrics initially', () => {
const metrics = service.getMetrics();
expect(metrics).toEqual([]);
});

it('should record metrics after calls', async () => {
await service.execute('email', async () => 'ok');
const metrics = service.getMetrics('email');
expect(metrics).toHaveLength(1);
expect(metrics[0].success).toBe(true);
expect(metrics[0].dependency).toBe('email');
});

it('should filter by dependency name', async () => {
await service.execute('email', async () => 'ok');
await service.execute('storage', async () => 'ok');

const emailMetrics = service.getMetrics('email');
expect(emailMetrics).toHaveLength(1);
expect(emailMetrics[0].dependency).toBe('email');
});
});
});
Loading
Loading