Skip to content

Commit 716dd39

Browse files
authored
Merge pull request #257 from Babigdk/feature/be221-shutdown-hooks
fix: enable NestJS shutdown hooks and add bootstrap unit test
2 parents 3fb35bd + 79c0f44 commit 716dd39

7 files changed

Lines changed: 176 additions & 5 deletions

File tree

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Module, Logger } from '@nestjs/common';
22
import { ConfigModule, ConfigService } from '@nestjs/config';
3+
import { ScheduleModule } from '@nestjs/schedule';
34
import { ThrottlerModule } from '@nestjs/throttler';
45
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
56
import { AppController } from './app.controller';
@@ -222,6 +223,7 @@ async function createThrottlerStorage(configService: ConfigService): Promise<any
222223
load: [blockchainConfig, throttlerConfig, sybilConfig],
223224
envFilePath: ['.env.local', '.env'],
224225
}),
226+
ScheduleModule.forRoot(),
225227
TypeOrmModule.forRoot({
226228
type: 'sqlite',
227229
database: 'database.sqlite',

src/audit/audit.module.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { Module, Global } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { ScheduleModule } from '@nestjs/schedule';
34
import { AuditLog } from './entities/audit-log.entity';
45
import { AuditTrailService } from './services/audit-trail.service';
6+
import { AuditRetentionService } from './services/audit-retention.service';
57
import { AuditController } from './controllers/audit-log.controller';
68
import { AuditLoggingInterceptor } from './interceptors/audit-logging.interceptor';
79

810
@Global()
911
@Module({
10-
imports: [TypeOrmModule.forFeature([AuditLog])],
11-
providers: [AuditTrailService, AuditLoggingInterceptor],
12+
imports: [TypeOrmModule.forFeature([AuditLog]), ScheduleModule],
13+
providers: [AuditTrailService, AuditLoggingInterceptor, AuditRetentionService],
1214
controllers: [AuditController],
1315
exports: [AuditTrailService, AuditLoggingInterceptor],
1416
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { AuditRetentionService } from './audit-retention.service';
2+
import { AuditTrailService } from './audit-trail.service';
3+
import { ConfigService } from '@nestjs/config';
4+
5+
describe('AuditRetentionService', () => {
6+
let service: AuditRetentionService;
7+
let auditTrailService: jest.Mocked<AuditTrailService>;
8+
let configService: jest.Mocked<ConfigService>;
9+
10+
beforeEach(() => {
11+
auditTrailService = {
12+
deleteOldLogs: jest.fn(),
13+
} as unknown as jest.Mocked<AuditTrailService>;
14+
15+
configService = {
16+
get: jest.fn(),
17+
} as unknown as jest.Mocked<ConfigService>;
18+
});
19+
20+
it('should use configured retention days and purge old audit logs', async () => {
21+
(configService.get as jest.Mock).mockImplementation((key: string) => {
22+
if (key === 'AUDIT_LOG_RETENTION_DAYS') return '30';
23+
return undefined;
24+
});
25+
auditTrailService.deleteOldLogs.mockResolvedValue(8);
26+
27+
service = new AuditRetentionService(auditTrailService, configService);
28+
29+
await expect(service.purgeOldAuditLogs()).resolves.toBe(8);
30+
expect(auditTrailService.deleteOldLogs).toHaveBeenCalledWith(30);
31+
});
32+
33+
it('should default to 365 days when configuration is missing or invalid', async () => {
34+
(configService.get as jest.Mock).mockReturnValue(undefined);
35+
auditTrailService.deleteOldLogs.mockResolvedValue(0);
36+
37+
service = new AuditRetentionService(auditTrailService, configService);
38+
39+
await expect(service.purgeOldAuditLogs()).resolves.toBe(0);
40+
expect(auditTrailService.deleteOldLogs).toHaveBeenCalledWith(365);
41+
});
42+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { ConfigService } from '@nestjs/config';
4+
import { AuditTrailService } from './audit-trail.service';
5+
6+
@Injectable()
7+
export class AuditRetentionService {
8+
private readonly logger = new Logger(AuditRetentionService.name);
9+
private readonly daysToKeep: number;
10+
11+
constructor(
12+
private readonly auditTrailService: AuditTrailService,
13+
private readonly configService: ConfigService,
14+
) {
15+
this.daysToKeep = this.resolveRetentionDays();
16+
}
17+
18+
@Cron(process.env.AUDIT_LOG_RETENTION_CRON || CronExpression.EVERY_DAY_AT_MIDNIGHT, {
19+
name: 'audit-log-retention',
20+
timeZone: 'UTC',
21+
})
22+
async purgeOldAuditLogs(): Promise<number> {
23+
const deletedCount = await this.auditTrailService.deleteOldLogs(
24+
this.daysToKeep,
25+
);
26+
27+
this.logger.log(
28+
`Audit retention job removed ${deletedCount} records older than ${this.daysToKeep} days`,
29+
);
30+
31+
return deletedCount;
32+
}
33+
34+
private resolveRetentionDays(): number {
35+
const rawDays = this.configService.get<string>('AUDIT_LOG_RETENTION_DAYS');
36+
const parsedDays = parseInt(rawDays ?? '', 10);
37+
return Number.isNaN(parsedDays) || parsedDays <= 0 ? 365 : parsedDays;
38+
}
39+
}

src/audit/services/audit-trail.service.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,43 @@ describe('AuditTrailService - IP Security and Masking', () => {
284284
});
285285
});
286286

287+
describe('deleteOldLogs', () => {
288+
it('should delete audit logs older than the configured cutoff date', async () => {
289+
const mockQueryBuilder = {
290+
delete: jest.fn().mockReturnThis(),
291+
where: jest.fn().mockReturnThis(),
292+
execute: jest.fn().mockResolvedValue({ affected: 4 }),
293+
} as any;
294+
295+
(repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder);
296+
297+
const deleted = await service.deleteOldLogs(90);
298+
299+
expect(repository.createQueryBuilder).toHaveBeenCalledWith('audit');
300+
expect(mockQueryBuilder.delete).toHaveBeenCalled();
301+
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
302+
'audit.createdAt < :cutoff',
303+
expect.objectContaining({ cutoff: expect.any(Date) }),
304+
);
305+
expect(mockQueryBuilder.execute).toHaveBeenCalled();
306+
expect(deleted).toBe(4);
307+
});
308+
309+
it('should return zero when no old audit logs are deleted', async () => {
310+
const mockQueryBuilder = {
311+
delete: jest.fn().mockReturnThis(),
312+
where: jest.fn().mockReturnThis(),
313+
execute: jest.fn().mockResolvedValue({ affected: 0 }),
314+
} as any;
315+
316+
(repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder);
317+
318+
const deleted = await service.deleteOldLogs(30);
319+
320+
expect(deleted).toBe(0);
321+
});
322+
});
323+
287324
describe('maskIp utility', () => {
288325
it('should handle undefined and empty values', () => {
289326
expect(maskIp(undefined)).toBeUndefined();

src/main.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { bootstrap } from './main';
2+
3+
jest.mock('@nestjs/core', () => ({
4+
NestFactory: {
5+
create: jest.fn(),
6+
},
7+
}));
8+
9+
jest.mock('./bootstrap', () => ({
10+
configureApp: jest.fn(),
11+
}));
12+
13+
jest.mock('./app.module', () => ({
14+
AppModule: Symbol('AppModule'),
15+
}));
16+
17+
const mockApp = {
18+
enableShutdownHooks: jest.fn(),
19+
listen: jest.fn().mockResolvedValue(undefined),
20+
};
21+
22+
describe('bootstrap', () => {
23+
beforeEach(() => {
24+
jest.clearAllMocks();
25+
});
26+
27+
it('enables shutdown hooks and starts the application', async () => {
28+
const { NestFactory } = await import('@nestjs/core');
29+
const { AppModule } = await import('./app.module');
30+
// eslint-disable-next-line @typescript-eslint/unbound-method
31+
const createMock = NestFactory.create as jest.Mock;
32+
createMock.mockResolvedValue(mockApp);
33+
34+
await bootstrap();
35+
36+
expect(createMock).toHaveBeenCalledWith(AppModule, {
37+
bufferLogs: true,
38+
});
39+
expect(mockApp.enableShutdownHooks).toHaveBeenCalled();
40+
expect(mockApp.listen).toHaveBeenCalledWith(process.env.PORT ?? 3000);
41+
});
42+
});

src/main.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { NestFactory } from '@nestjs/core';
2-
import { AppModule } from './app.module';
32
import { configureApp } from './bootstrap';
43

5-
async function bootstrap() {
4+
export async function bootstrap() {
5+
const { AppModule } = await import('./app.module');
66
const app = await NestFactory.create(AppModule, { bufferLogs: true });
77
configureApp(app);
8+
app.enableShutdownHooks();
89
await app.listen(process.env.PORT ?? 3000);
910
}
10-
bootstrap();
11+
12+
if (require.main === module) {
13+
void bootstrap().catch((error) => {
14+
console.error(error);
15+
process.exit(1);
16+
});
17+
}

0 commit comments

Comments
 (0)