Skip to content

Commit 630a216

Browse files
authored
Merge pull request AgesEmpire#422 from BethelDev-io/fix/373-371-372-370-backend-gaps
Fix/373 371 372 370 backend gaps
2 parents 05b0b01 + c347b00 commit 630a216

9 files changed

Lines changed: 351 additions & 11 deletions

File tree

src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ import { MarketIntelligenceModule } from './market-intelligence/market-intellige
4949
import { DocumentationModule } from './documentation/documentation.module';
5050
import { CompetitionsModule } from './competitions/competitions.module';
5151
import { NftModule } from './nft/nft.module';
52+
import { HealthModule } from './health/health.module';
53+
import { RateLimitModule } from './common/rate-limit.module';
5254
feature/295-discord-community-integration
5355
import { DiscordBotModule } from './integrations/discord/discord-bot.module';
5456

@@ -159,6 +161,8 @@ import { AutomationModule } from './integrations/automation-platforms/automation
159161
DocumentationModule,
160162
CompetitionsModule,
161163
NftModule,
164+
HealthModule,
165+
RateLimitModule,
162166
feature/295-discord-community-integration
163167
DiscordBotModule,
164168

src/common/guards/rate-limit.guard.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
HttpException,
66
HttpStatus,
77
Inject,
8+
Logger,
89
} from '@nestjs/common';
910
import { Reflector } from '@nestjs/core';
1011
import { CACHE_MANAGER } from '@nestjs/cache-manager';
@@ -18,6 +19,7 @@ interface RateLimitInfo {
1819

1920
@Injectable()
2021
export class RateLimitGuard implements CanActivate {
22+
private readonly logger = new Logger(RateLimitGuard.name);
2123
private readonly limits = {
2224
[RateLimitTier.PUBLIC]: { limit: 100, window: 15 * 60 },
2325
[RateLimitTier.AUTHENTICATED]: { limit: 1000, window: 15 * 60 },
@@ -141,12 +143,9 @@ export class RateLimitGuard implements CanActivate {
141143
}
142144

143145
private logViolation(identifier: string, tier: RateLimitTier, limit: number): void {
144-
console.warn({
145-
type: 'rate_limit_violation',
146-
identifier,
147-
tier,
148-
limit,
149-
timestamp: new Date().toISOString(),
150-
});
146+
this.logger.warn(
147+
`Rate limit exceeded: identifier=${identifier} tier=${tier} limit=${limit}`,
148+
{ type: 'rate_limit_violation', identifier, tier, limit, timestamp: new Date().toISOString() },
149+
);
151150
}
152151
}

src/health/health.controller.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Controller, Get } from '@nestjs/common';
1+
import { Controller, Get, OnApplicationBootstrap, Logger } from '@nestjs/common';
22
import {
33
HealthCheck,
44
HealthCheckService,
@@ -12,7 +12,9 @@ import {
1212
} from './indicators';
1313

1414
@Controller('health')
15-
export class HealthController {
15+
export class HealthController implements OnApplicationBootstrap {
16+
private readonly logger = new Logger(HealthController.name);
17+
1618
constructor(
1719
private health: HealthCheckService,
1820
private stellarHealth: StellarHealthIndicator,
@@ -21,6 +23,32 @@ export class HealthController {
2123
private redisHealth: RedisHealthIndicator,
2224
) { }
2325

26+
async onApplicationBootstrap(): Promise<void> {
27+
const maxRetries = 5;
28+
const retryDelayMs = 3000;
29+
30+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
31+
try {
32+
await this.health.check([
33+
() => this.databaseHealth.isHealthy('database'),
34+
() => this.redisHealth.isHealthy('cache'),
35+
]);
36+
this.logger.log('Startup health check passed: database and cache are ready');
37+
return;
38+
} catch (err) {
39+
this.logger.warn(
40+
`Startup health check attempt ${attempt}/${maxRetries} failed: ${(err as Error).message}`,
41+
);
42+
if (attempt < maxRetries) {
43+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
44+
} else {
45+
this.logger.error('Critical dependencies unavailable after max retries — aborting startup');
46+
process.exit(1);
47+
}
48+
}
49+
}
50+
}
51+
2452
@Get()
2553
@HealthCheck()
2654
async check(): Promise<HealthCheckResult> {

src/main.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ async function bootstrap() {
6060
// Enable compression
6161
app.use((compression as any)(compressionConfig));
6262

63+
// Track in-flight requests for graceful drain
64+
let inFlightRequests = 0;
65+
app.use((_req: any, _res: any, next: () => void) => {
66+
inFlightRequests++;
67+
_res.on('finish', () => { inFlightRequests--; });
68+
_res.on('close', () => { inFlightRequests--; });
69+
next();
70+
});
71+
72+
app.enableShutdownHooks();
73+
6374
// Global pipes
6475
app.useGlobalPipes(
6576
new SanitizationPipe(),
@@ -134,9 +145,27 @@ async function bootstrap() {
134145
});
135146

136147
process.on('SIGTERM', async () => {
137-
logger.info('SIGTERM signal received: closing HTTP server');
138-
await sentryService.flush();
148+
logger.info('SIGTERM received: starting graceful shutdown');
149+
150+
// Stop accepting new connections
139151
await app.close();
152+
153+
// Drain in-flight requests (max 30 s)
154+
const drainTimeout = 30_000;
155+
const drainStart = Date.now();
156+
while (inFlightRequests > 0 && Date.now() - drainStart < drainTimeout) {
157+
logger.info(`Draining ${inFlightRequests} in-flight request(s)…`);
158+
await new Promise((resolve) => setTimeout(resolve, 500));
159+
}
160+
161+
if (inFlightRequests > 0) {
162+
logger.warn(`Shutdown forced with ${inFlightRequests} request(s) still in-flight`);
163+
} else {
164+
logger.info('All in-flight requests drained. Shutdown complete.');
165+
}
166+
167+
await sentryService.flush();
168+
process.exit(0);
140169
});
141170
}
142171

src/rate-limit/rate-limit.guard.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Re-exports the canonical rate-limit guard from common/guards.
2+
// Issue #372: API gateway rate limiting entry point.
3+
export { RateLimitGuard } from '../common/guards/rate-limit.guard';
4+
export { RateLimitTier, RateLimit, RateLimitConfig, RATE_LIMIT_KEY } from '../common/decorators/rate-limit.decorator';

test/graceful-shutdown.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { INestApplication } from '@nestjs/common';
3+
import { AppModule } from '../src/app.module';
4+
5+
describe('Graceful Shutdown (#373)', () => {
6+
let app: INestApplication;
7+
8+
beforeAll(async () => {
9+
const module: TestingModule = await Test.createTestingModule({
10+
imports: [AppModule],
11+
}).compile();
12+
13+
app = module.createNestApplication();
14+
app.enableShutdownHooks();
15+
await app.init();
16+
});
17+
18+
afterAll(async () => {
19+
await app.close();
20+
});
21+
22+
it('should close the application cleanly without throwing', async () => {
23+
await expect(app.close()).resolves.not.toThrow();
24+
});
25+
26+
it('should drain in-flight counter correctly', () => {
27+
let inFlight = 0;
28+
const mockReq = {};
29+
const mockRes: any = { on: jest.fn((event, cb) => { if (event === 'finish') cb(); }) };
30+
const next = jest.fn(() => { inFlight++; });
31+
32+
// Simulate the middleware
33+
const middleware = (_req: any, res: any, n: () => void) => {
34+
inFlight++;
35+
res.on('finish', () => { inFlight--; });
36+
res.on('close', () => { inFlight--; });
37+
n();
38+
};
39+
40+
inFlight = 0;
41+
middleware(mockReq, mockRes, () => {});
42+
// finish event fires immediately via mock
43+
expect(inFlight).toBe(0);
44+
});
45+
});

test/health.controller.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { HealthController } from '../src/health/health.controller';
3+
import { HealthCheckService } from '@nestjs/terminus';
4+
import {
5+
DatabaseHealthIndicator,
6+
RedisHealthIndicator,
7+
StellarHealthIndicator,
8+
SorobanHealthIndicator,
9+
} from '../src/health/indicators';
10+
11+
const mockHealthCheck = jest.fn();
12+
const mockDbHealth = { isHealthy: jest.fn() };
13+
const mockRedisHealth = { isHealthy: jest.fn() };
14+
const mockStellarHealth = { isHealthy: jest.fn() };
15+
const mockSorobanHealth = { isHealthy: jest.fn() };
16+
17+
describe('HealthController (#370)', () => {
18+
let controller: HealthController;
19+
20+
beforeEach(async () => {
21+
jest.clearAllMocks();
22+
const module: TestingModule = await Test.createTestingModule({
23+
controllers: [HealthController],
24+
providers: [
25+
{ provide: HealthCheckService, useValue: { check: mockHealthCheck } },
26+
{ provide: DatabaseHealthIndicator, useValue: mockDbHealth },
27+
{ provide: RedisHealthIndicator, useValue: mockRedisHealth },
28+
{ provide: StellarHealthIndicator, useValue: mockStellarHealth },
29+
{ provide: SorobanHealthIndicator, useValue: mockSorobanHealth },
30+
],
31+
}).compile();
32+
33+
controller = module.get(HealthController);
34+
});
35+
36+
it('should pass startup health check when DB and cache are healthy', async () => {
37+
mockHealthCheck.mockResolvedValue({ status: 'ok', info: {}, error: {}, details: {} });
38+
await expect(controller.onApplicationBootstrap()).resolves.not.toThrow();
39+
expect(mockHealthCheck).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it('should retry and exit if dependencies remain unhealthy', async () => {
43+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {}) as any);
44+
mockHealthCheck.mockRejectedValue(new Error('DB unavailable'));
45+
46+
// Speed up retries
47+
jest.useFakeTimers();
48+
const bootstrapPromise = controller.onApplicationBootstrap();
49+
// Advance through all retry delays (5 retries × 3000ms)
50+
for (let i = 0; i < 5; i++) {
51+
await Promise.resolve();
52+
jest.advanceTimersByTime(3000);
53+
}
54+
await bootstrapPromise.catch(() => {});
55+
jest.useRealTimers();
56+
57+
expect(exitSpy).toHaveBeenCalledWith(1);
58+
exitSpy.mockRestore();
59+
});
60+
61+
it('GET /health should check all indicators', async () => {
62+
mockHealthCheck.mockResolvedValue({ status: 'ok', info: {}, error: {}, details: {} });
63+
await controller.check();
64+
expect(mockHealthCheck).toHaveBeenCalledWith(expect.arrayContaining([
65+
expect.any(Function),
66+
expect.any(Function),
67+
expect.any(Function),
68+
expect.any(Function),
69+
]));
70+
});
71+
72+
it('GET /health/readiness should check DB and cache only', async () => {
73+
mockHealthCheck.mockResolvedValue({ status: 'ok', info: {}, error: {}, details: {} });
74+
await controller.readiness();
75+
expect(mockHealthCheck).toHaveBeenCalledWith(expect.arrayContaining([
76+
expect.any(Function),
77+
expect.any(Function),
78+
]));
79+
});
80+
});

test/logger.service.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { LoggerService } from '../src/common/logger/logger.service';
2+
import { ConfigService } from '@nestjs/config';
3+
import * as winston from 'winston';
4+
5+
describe('LoggerService structured JSON logging (#371)', () => {
6+
let logger: LoggerService;
7+
let winstonSpy: jest.SpyInstance;
8+
9+
beforeEach(() => {
10+
const configService = {
11+
get: (key: string, defaultVal?: any) => {
12+
const map: Record<string, any> = {
13+
'app.nodeEnv': 'production',
14+
'app.logger.level': 'info',
15+
'app.logger.directory': '/tmp/logs',
16+
'app.logger.maxFiles': '14d',
17+
'app.logger.maxSize': '20m',
18+
};
19+
return map[key] ?? defaultVal;
20+
},
21+
} as unknown as ConfigService;
22+
23+
logger = new LoggerService(configService);
24+
});
25+
26+
it('should instantiate without errors in production mode', () => {
27+
expect(logger).toBeDefined();
28+
});
29+
30+
it('should redact sensitive fields', () => {
31+
const internalLogger = (logger as any).logger as winston.Logger;
32+
const writeSpy = jest.spyOn(internalLogger, 'info');
33+
34+
logger.info('test message', { password: 'secret123', userId: 'abc' });
35+
36+
expect(writeSpy).toHaveBeenCalledWith(
37+
'test message',
38+
expect.objectContaining({ password: '[REDACTED]', userId: 'abc' }),
39+
);
40+
});
41+
42+
it('should log errors with stack trace', () => {
43+
const internalLogger = (logger as any).logger as winston.Logger;
44+
const errorSpy = jest.spyOn(internalLogger, 'error');
45+
const err = new Error('boom');
46+
47+
logger.error('something failed', err);
48+
49+
expect(errorSpy).toHaveBeenCalledWith(
50+
'something failed',
51+
expect.objectContaining({
52+
error: expect.objectContaining({ message: 'boom', stack: expect.any(String) }),
53+
}),
54+
);
55+
});
56+
57+
it('should handle circular references without throwing', () => {
58+
const circular: any = { a: 1 };
59+
circular.self = circular;
60+
61+
expect(() => logger.info('circular test', circular)).not.toThrow();
62+
});
63+
});

0 commit comments

Comments
 (0)