690414:1113 Update README.md /.agents/skills, /.windsurf/workflows

This commit is contained in:
2026-04-14 11:13:42 +07:00
parent 02400fd88c
commit 6d45bdaeb5
194 changed files with 12708 additions and 8762 deletions
@@ -6,6 +6,7 @@ import {
ManyToOne,
JoinColumn,
} from 'typeorm';
import { WorkflowHistory } from '../../../modules/workflow-engine/entities/workflow-history.entity';
import { User } from '../../../modules/user/entities/user.entity';
import { UuidBaseEntity } from '../../entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@@ -46,6 +47,20 @@ export class Attachment extends UuidBaseEntity {
@Column({ name: 'reference_date', type: 'date', nullable: true })
referenceDate?: Date;
// ADR-021: FK ไปยัง workflow_histories สำหรับไฟล์แนบประจำ Step
// NULL = ไฟล์แนบหลัก (Main Document), NOT NULL = ไฟล์ประจำ Workflow Step
@Column({ name: 'workflow_history_id', length: 36, nullable: true })
workflowHistoryId?: string;
// Lazy relation — ไม่ include ใน default query เพื่อป้องกัน N+1
@ManyToOne(
() => WorkflowHistory,
(history: WorkflowHistory) => history.attachments,
{ nullable: true, onDelete: 'SET NULL', lazy: true }
)
@JoinColumn({ name: 'workflow_history_id' })
workflowHistory?: Promise<WorkflowHistory>;
@Column({ name: 'uploaded_by_user_id' })
uploadedByUserId!: number;
@@ -1,6 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageController } from './file-storage.controller';
import { FileStorageService } from './file-storage.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RbacGuard } from '../guards/rbac.guard';
import { RequestWithUser } from '../interfaces/request-with-user.interface';
describe('FileStorageController', () => {
@@ -12,6 +14,7 @@ describe('FileStorageController', () => {
upload: jest.fn(),
download: jest.fn(),
delete: jest.fn(),
preview: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
@@ -22,7 +25,12 @@ describe('FileStorageController', () => {
useValue: mockFileStorageService,
},
],
}).compile();
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RbacGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FileStorageController>(FileStorageController);
});
@@ -20,6 +20,8 @@ import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileStorageService } from './file-storage.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RbacGuard } from '../guards/rbac.guard';
import { RequirePermission } from '../decorators/require-permission.decorator';
import type { RequestWithUser } from '../interfaces/request-with-user.interface';
@Controller('files')
@@ -73,6 +75,32 @@ export class FileStorageController {
return new StreamableFile(stream);
}
/**
* ADR-021: Preview Endpoint — GET /files/preview/:publicId
* ส่งไฟล์กลับพร้อม Content-Disposition: inline เพื่อให้ Browser แสดงผลโดยตรง
* ใช้ publicId (UUIDv7) ตาม ADR-019 — ไม่ใช้ INT id
*/
@Get('preview/:publicId')
@UseGuards(RbacGuard)
@RequirePermission('document.view')
async previewFile(
@Param('publicId') publicId: string,
@Res({ passthrough: true }) res: Response
): Promise<StreamableFile> {
const { stream, attachment } =
await this.fileStorageService.preview(publicId);
const encodedFilename = encodeURIComponent(attachment.originalFilename);
res.set({
'Content-Type': attachment.mimeType ?? 'application/octet-stream',
// inline = browser แสดงผล, attachment = บังคับดาวน์โหลด
'Content-Disposition': `inline; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`,
'Content-Length': String(attachment.fileSize),
});
return new StreamableFile(stream);
}
/**
* ✅ NEW: Delete Endpoint
* DELETE /files/:id
@@ -170,6 +170,31 @@ export class FileStorageService {
return committedAttachments;
}
/**
* ADR-021: Preview File by publicId (Content-Disposition: inline)
* ดึงไฟล์มาเป็น Stream สำหรับแสดงผลใน Browser โดยตรง (ใช้กับ FilePreviewModal)
*/
async preview(
publicId: string
): Promise<{ stream: fs.ReadStream; attachment: Attachment }> {
const attachment = await this.attachmentRepository.findOne({
where: { publicId },
});
if (!attachment) {
throw new NotFoundException(`Attachment not found`);
}
const filePath = attachment.filePath;
if (!fs.existsSync(filePath)) {
this.logger.error(`Preview file missing on disk: ${filePath}`);
throw new NotFoundException('File not found on server storage');
}
const stream = fs.createReadStream(filePath);
return { stream, attachment };
}
/**
* Download File
* ดึงไฟล์มาเป็น Stream เพื่อส่งกลับไปให้ Controller
@@ -0,0 +1,304 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RbacGuard } from './rbac.guard';
import { UserService } from '../../modules/user/user.service';
import { User } from '../../modules/user/entities/user.entity';
import { PERMISSIONS_KEY } from '../decorators/require-permission.decorator';
describe('RbacGuard', () => {
let guard: RbacGuard;
let reflector: Reflector;
let userService: UserService;
const createMockExecutionContext = (
user?: User,
_handlerPermissions?: string[]
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => ({
user,
}),
}),
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
};
const createMockUser = (userId: number): User => {
const user = new User();
user.user_id = userId;
user.username = 'testuser';
user.email = 'test@example.com';
return user;
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RbacGuard,
{
provide: Reflector,
useValue: {
getAllAndOverride: jest.fn(),
},
},
{
provide: UserService,
useValue: {
getUserPermissions: jest.fn(),
},
},
],
}).compile();
guard = module.get<RbacGuard>(RbacGuard);
reflector = module.get<Reflector>(Reflector);
userService = module.get<UserService>(UserService);
});
it('should be defined', () => {
expect(guard).toBeDefined();
});
// ==========================================================
// No permissions required
// ==========================================================
describe('when no permissions required', () => {
it('should allow access when no permissions decorator', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
const context = createMockExecutionContext();
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should allow access when empty permissions array', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([]);
const context = createMockExecutionContext();
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
});
// ==========================================================
// User not in request
// ==========================================================
describe('when user not in request', () => {
it('should throw ForbiddenException when user is undefined', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read']);
const context = createMockExecutionContext(undefined);
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(
'User not found in request'
);
});
});
// ==========================================================
// Single permission checks
// ==========================================================
describe('single permission checks', () => {
it('should allow when user has exact permission', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['correspondence.read', 'correspondence.create']);
const context = createMockExecutionContext(createMockUser(1));
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should deny when user lacks required permission', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.delete']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['correspondence.read', 'correspondence.create']);
const context = createMockExecutionContext(createMockUser(1));
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
await expect(guard.canActivate(context)).rejects.toThrow(
'You do not have permission: correspondence.delete'
);
});
});
// ==========================================================
// Multiple permissions checks (ALL required)
// ==========================================================
describe('multiple permissions checks (ALL required)', () => {
it('should allow when user has ALL required permissions', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read', 'correspondence.create']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue([
'correspondence.read',
'correspondence.create',
'correspondence.update',
]);
const context = createMockExecutionContext(createMockUser(1));
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should deny when user has only SOME required permissions', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read', 'correspondence.create']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['correspondence.read']); // Missing create
const context = createMockExecutionContext(createMockUser(1));
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
});
it('should deny when user has none of the required permissions', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read', 'correspondence.create']);
jest.spyOn(userService, 'getUserPermissions').mockResolvedValue([]);
const context = createMockExecutionContext(createMockUser(1));
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
});
});
// ==========================================================
// Superadmin bypass (system.manage_all)
// ==========================================================
describe('superadmin bypass (system.manage_all)', () => {
it('should allow superadmin to access any permission', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.delete', 'user.manage']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['system.manage_all']); // Only superadmin permission
const context = createMockExecutionContext(createMockUser(1));
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should allow superadmin with other permissions', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['rfa.approve']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue([
'correspondence.read',
'system.manage_all',
'project.view',
]);
const context = createMockExecutionContext(createMockUser(1));
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should still check permissions for non-superadmin', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['admin.only.permission']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['correspondence.read', 'correspondence.create']);
const context = createMockExecutionContext(createMockUser(1));
await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
});
});
// ==========================================================
// Permission service integration
// ==========================================================
describe('permission service integration', () => {
it('should call getUserPermissions with correct user_id', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read']);
const getPermissionsSpy = jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['correspondence.read']);
const mockUser = createMockUser(42);
const context = createMockExecutionContext(mockUser);
await guard.canActivate(context);
expect(getPermissionsSpy).toHaveBeenCalledWith(42);
});
it('should call getUserPermissions only once per request', async () => {
jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read']);
const getPermissionsSpy = jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['correspondence.read']);
const context = createMockExecutionContext(createMockUser(1));
await guard.canActivate(context);
expect(getPermissionsSpy).toHaveBeenCalledTimes(1);
});
});
// ==========================================================
// Reflector metadata priority
// ==========================================================
describe('reflector metadata priority', () => {
it('should check handler and class metadata', async () => {
const getAllAndOverrideSpy = jest
.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['correspondence.read']);
jest
.spyOn(userService, 'getUserPermissions')
.mockResolvedValue(['correspondence.read']);
const context = createMockExecutionContext(createMockUser(1));
await guard.canActivate(context);
expect(getAllAndOverrideSpy).toHaveBeenCalledWith(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
});
});
});
@@ -0,0 +1,484 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AuditLogInterceptor } from './audit-log.interceptor';
import { AuditLog } from '../entities/audit-log.entity';
import { AuditMetadata } from '../decorators/audit.decorator';
import { User } from '../../modules/user/entities/user.entity';
import { of, lastValueFrom } from 'rxjs';
import { Request } from 'express';
import type { Socket } from 'net';
describe('AuditLogInterceptor', () => {
let interceptor: AuditLogInterceptor;
let reflector: Reflector;
let auditLogRepo: jest.Mocked<Partial<typeof AuditLog.prototype.constructor>>;
const createMockUser = (userId: number): User => {
const user = new User();
user.user_id = userId;
user.username = 'testuser';
user.email = 'test@example.com';
return user;
};
const createMockRequest = (
user?: User,
params: Record<string, string> = {},
ip: string = '127.0.0.1',
userAgent: string = 'test-agent'
): Partial<Request> => ({
user,
params,
ip,
socket: { remoteAddress: ip } as unknown as Socket,
get: jest.fn().mockReturnValue(userAgent),
});
const createMockExecutionContext = (
auditMetadata: AuditMetadata | undefined,
user?: User,
params: Record<string, string> = {}
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => createMockRequest(user, params),
}),
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
};
const createMockCallHandler = (data: unknown): CallHandler => ({
handle: () => of(data),
});
beforeEach(async () => {
const mockRepository = {
create: jest.fn().mockReturnValue({}),
save: jest.fn().mockResolvedValue({}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AuditLogInterceptor,
{
provide: Reflector,
useValue: {
getAllAndOverride: jest.fn(),
},
},
{
provide: getRepositoryToken(AuditLog),
useValue: mockRepository,
},
],
}).compile();
interceptor = module.get<AuditLogInterceptor>(AuditLogInterceptor);
reflector = module.get<Reflector>(Reflector);
auditLogRepo = module.get(getRepositoryToken(AuditLog));
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(interceptor).toBeDefined();
});
// ==========================================================
// No audit metadata
// ==========================================================
describe('when no audit metadata', () => {
it('should pass through without creating audit log', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
const context = createMockExecutionContext(undefined, undefined);
const callHandler = createMockCallHandler({ id: 'test-uuid' });
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result).toEqual({ id: 'test-uuid' });
expect(auditLogRepo.create).not.toHaveBeenCalled();
expect(auditLogRepo.save).not.toHaveBeenCalled();
});
});
// ==========================================================
// Audit log creation
// ==========================================================
describe('audit log creation', () => {
it('should create audit log with basic metadata', async () => {
const auditMetadata: AuditMetadata = {
action: 'correspondence.create',
entityType: 'correspondence',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler({ id: 'new-uuid' });
await lastValueFrom(interceptor.intercept(context, callHandler));
// Wait for async tap operation
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
action: 'correspondence.create',
entityType: 'correspondence',
severity: 'INFO',
})
);
expect(auditLogRepo.save).toHaveBeenCalled();
});
it('should extract entityId from response data.id', async () => {
const auditMetadata: AuditMetadata = {
action: 'correspondence.update',
entityType: 'correspondence',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler({ id: 'entity-uuid-123' });
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
entityId: 'entity-uuid-123',
})
);
});
it('should extract entityId from response audit_id', async () => {
const auditMetadata: AuditMetadata = {
action: 'audit.view',
entityType: 'audit_log',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler({ audit_id: 'audit-123' });
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
entityId: 'audit-123',
})
);
});
it('should extract entityId from response user_id', async () => {
const auditMetadata: AuditMetadata = {
action: 'user.update',
entityType: 'user',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler({ user_id: '42' });
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
entityId: '42',
})
);
});
it('should extract entityId from request params if not in data', async () => {
const auditMetadata: AuditMetadata = {
action: 'correspondence.delete',
entityType: 'correspondence',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user, {
id: 'param-uuid-456',
});
const callHandler = createMockCallHandler({ success: true });
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
entityId: 'param-uuid-456',
})
);
});
});
// ==========================================================
// User handling
// ==========================================================
describe('user handling', () => {
it('should handle authenticated user', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(42);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler({});
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 42,
})
);
});
it('should handle unauthenticated request (no user)', async () => {
const auditMetadata: AuditMetadata = {
action: 'public.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const context = createMockExecutionContext(auditMetadata, undefined);
const callHandler = createMockCallHandler({});
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: null,
})
);
});
});
// ==========================================================
// Request metadata extraction
// ==========================================================
describe('request metadata extraction', () => {
it('should capture IP address from request', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = {
switchToHttp: () => ({
getRequest: () =>
createMockRequest(user, {}, '192.168.1.100', 'test-agent'),
}),
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
const callHandler = createMockCallHandler({});
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
ipAddress: '192.168.1.100',
})
);
});
it('should capture user agent from request', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = {
switchToHttp: () => ({
getRequest: () =>
createMockRequest(
user,
{},
'127.0.0.1',
'Mozilla/5.0 Test Browser'
),
}),
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
const callHandler = createMockCallHandler({});
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userAgent: 'Mozilla/5.0 Test Browser',
})
);
});
it('should use socket.remoteAddress as IP fallback', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = {
switchToHttp: () => ({
getRequest: () => ({
user,
params: {},
ip: undefined,
socket: { remoteAddress: '10.0.0.1' },
get: jest.fn().mockReturnValue(''),
}),
}),
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
const callHandler = createMockCallHandler({});
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
ipAddress: '10.0.0.1',
})
);
});
});
// ==========================================================
// Error handling
// ==========================================================
describe('error handling', () => {
it('should log error when audit log save fails', async () => {
const loggerSpy = jest.spyOn(Logger.prototype, 'error');
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
jest.spyOn(auditLogRepo, 'save').mockRejectedValue(new Error('DB Error'));
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler({});
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to create audit log for test.action')
);
});
it('should not throw when audit log save fails', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
jest.spyOn(auditLogRepo, 'save').mockRejectedValue(new Error('DB Error'));
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler({ id: 'test' });
// Should not throw
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result).toEqual({ id: 'test' });
});
});
// ==========================================================
// Edge cases
// ==========================================================
describe('edge cases', () => {
it('should handle null response data', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler(null);
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalled();
});
it('should handle non-object response data', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = createMockExecutionContext(auditMetadata, user);
const callHandler = createMockCallHandler('string response');
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
entityId: undefined,
})
);
});
it('should handle array IP address', async () => {
const auditMetadata: AuditMetadata = {
action: 'test.action',
};
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata);
const user = createMockUser(1);
const context = {
switchToHttp: () => ({
getRequest: () => ({
user,
params: {},
ip: ['192.168.1.1', '10.0.0.1'],
socket: {},
get: jest.fn().mockReturnValue(''),
}),
}),
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
const callHandler = createMockCallHandler({});
await lastValueFrom(interceptor.intercept(context, callHandler));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
ipAddress: '192.168.1.1', // First element of array
})
);
});
});
});
@@ -0,0 +1,369 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { IdempotencyInterceptor } from './idempotency.interceptor';
import { of, lastValueFrom } from 'rxjs';
import { Request } from 'express';
describe('IdempotencyInterceptor', () => {
let interceptor: IdempotencyInterceptor;
let cacheManager: jest.Mocked<Cache>;
const createMockRequest = (
method: string,
idempotencyKey?: string
): Partial<Request> => ({
method,
headers: idempotencyKey ? { 'idempotency-key': idempotencyKey } : {},
});
const createMockExecutionContext = (
method: string,
idempotencyKey?: string
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => createMockRequest(method, idempotencyKey),
}),
} as ExecutionContext;
};
const createMockCallHandler = (data: unknown): CallHandler => ({
handle: () => of(data),
});
beforeEach(async () => {
const mockCache = {
get: jest.fn(),
set: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
IdempotencyInterceptor,
{
provide: CACHE_MANAGER,
useValue: mockCache,
},
],
}).compile();
interceptor = module.get<IdempotencyInterceptor>(IdempotencyInterceptor);
cacheManager = module.get(CACHE_MANAGER);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(interceptor).toBeDefined();
});
// ==========================================================
// HTTP Methods - Only mutating methods
// ==========================================================
describe('HTTP method filtering', () => {
it('should process POST requests', async () => {
const context = createMockExecutionContext('POST', 'key-123');
const callHandler = createMockCallHandler({ id: 'new' });
cacheManager.get.mockResolvedValue(undefined);
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-123');
});
it('should process PUT requests', async () => {
const context = createMockExecutionContext('PUT', 'key-456');
const callHandler = createMockCallHandler({ id: 'updated' });
cacheManager.get.mockResolvedValue(undefined);
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-456');
});
it('should process PATCH requests', async () => {
const context = createMockExecutionContext('PATCH', 'key-789');
const callHandler = createMockCallHandler({ id: 'patched' });
cacheManager.get.mockResolvedValue(undefined);
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-789');
});
it('should process DELETE requests', async () => {
const context = createMockExecutionContext('DELETE', 'key-del');
const callHandler = createMockCallHandler({ success: true });
cacheManager.get.mockResolvedValue(undefined);
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-del');
});
it('should skip GET requests (no idempotency check)', async () => {
const context = createMockExecutionContext('GET', 'key-get');
const callHandler = createMockCallHandler({ data: 'test' });
const result = await interceptor.intercept(context, callHandler);
const value = await lastValueFrom(result);
expect(value).toEqual({ data: 'test' });
expect(cacheManager.get).not.toHaveBeenCalled();
expect(cacheManager.set).not.toHaveBeenCalled();
});
it('should skip HEAD requests', async () => {
const context = createMockExecutionContext('HEAD', 'key-head');
const callHandler = createMockCallHandler(undefined);
await interceptor.intercept(context, callHandler);
expect(cacheManager.get).not.toHaveBeenCalled();
});
it('should skip OPTIONS requests', async () => {
const context = createMockExecutionContext('OPTIONS', 'key-opt');
const callHandler = createMockCallHandler(undefined);
await interceptor.intercept(context, callHandler);
expect(cacheManager.get).not.toHaveBeenCalled();
});
});
// ==========================================================
// Idempotency Key Handling
// ==========================================================
describe('idempotency key handling', () => {
it('should skip when no idempotency key header', async () => {
const context = createMockExecutionContext('POST');
const callHandler = createMockCallHandler({ id: 'test' });
const result = await interceptor.intercept(context, callHandler);
const value = await lastValueFrom(result);
expect(value).toEqual({ id: 'test' });
expect(cacheManager.get).not.toHaveBeenCalled();
expect(cacheManager.set).not.toHaveBeenCalled();
});
it('should check cache for existing key', async () => {
const context = createMockExecutionContext('POST', 'existing-key');
const callHandler = createMockCallHandler({ id: 'new' });
cacheManager.get.mockResolvedValue(undefined);
await interceptor.intercept(context, callHandler);
expect(cacheManager.get).toHaveBeenCalledWith('idempotency:existing-key');
});
it('should return cached response for duplicate key', async () => {
const cachedResponse = { id: 'cached-id', cached: true };
cacheManager.get.mockResolvedValue(cachedResponse);
const context = createMockExecutionContext('POST', 'duplicate-key');
const callHandler = {
handle: jest.fn().mockReturnValue(of({ id: 'new' })),
};
const result = await interceptor.intercept(context, callHandler);
const value = await lastValueFrom(result);
expect(value).toEqual(cachedResponse);
expect(callHandler.handle).not.toHaveBeenCalled(); // Should not call handler
});
it('should cache successful response', async () => {
cacheManager.get.mockResolvedValue(undefined);
const context = createMockExecutionContext('POST', 'cache-key');
const response = { id: 'new-id', data: 'test' };
const callHandler = createMockCallHandler(response);
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
// Wait for async cache operation
await new Promise((resolve) => setTimeout(resolve, 10));
expect(cacheManager.set).toHaveBeenCalledWith(
'idempotency:cache-key',
response,
86400 * 1000 // 24 hours in ms
);
});
});
// ==========================================================
// Cache Key Format
// ==========================================================
describe('cache key format', () => {
it('should prefix idempotency keys correctly', async () => {
cacheManager.get.mockResolvedValue(undefined);
const context = createMockExecutionContext('POST', 'my-key');
const callHandler = createMockCallHandler({});
await interceptor.intercept(context, callHandler);
expect(cacheManager.get).toHaveBeenCalledWith('idempotency:my-key');
});
it('should handle UUID idempotency keys', async () => {
cacheManager.get.mockResolvedValue(undefined);
const uuid = '019505a1-7c3e-7000-8000-abc123def456';
const context = createMockExecutionContext('POST', uuid);
const callHandler = createMockCallHandler({});
await interceptor.intercept(context, callHandler);
expect(cacheManager.get).toHaveBeenCalledWith(`idempotency:${uuid}`);
});
});
// ==========================================================
// Error Handling
// ==========================================================
describe('error handling', () => {
it('should log error when cache set fails', async () => {
const _consoleSpy = jest.spyOn(console, 'error').mockImplementation();
cacheManager.get.mockResolvedValue(undefined);
cacheManager.set.mockRejectedValue(new Error('Cache Error'));
const context = createMockExecutionContext('POST', 'error-key');
const callHandler = createMockCallHandler({ id: 'test' });
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
// Wait for async error handling
await new Promise((resolve) => setTimeout(resolve, 50));
expect(cacheManager.set).toHaveBeenCalled();
});
it('should not throw when cache get fails', async () => {
cacheManager.get.mockRejectedValue(new Error('Redis down'));
const context = createMockExecutionContext('POST', 'fail-key');
const callHandler = {
handle: jest.fn().mockReturnValue(of({ id: 'test' })),
};
// Should not throw, let request proceed - handler should still be called
const result = await interceptor.intercept(context, callHandler);
const value = await lastValueFrom(result);
expect(value).toEqual({ id: 'test' });
expect(callHandler.handle).toHaveBeenCalled();
});
it('should handle null cached value', async () => {
cacheManager.get.mockResolvedValue(null);
const context = createMockExecutionContext('POST', 'null-key');
const callHandler = createMockCallHandler({ id: 'test' });
const result = await interceptor.intercept(context, callHandler);
const value = await lastValueFrom(result);
expect(value).toEqual({ id: 'test' });
});
});
// ==========================================================
// Cache TTL
// ==========================================================
describe('cache TTL configuration', () => {
it('should cache for 24 hours (86400 seconds)', async () => {
cacheManager.get.mockResolvedValue(undefined);
const context = createMockExecutionContext('POST', 'ttl-key');
const callHandler = createMockCallHandler({ id: 'test' });
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(cacheManager.set).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
86400 * 1000 // 24 hours in milliseconds
);
});
});
// ==========================================================
// Edge Cases
// ==========================================================
describe('edge cases', () => {
it('should handle empty idempotency key', async () => {
const context = createMockExecutionContext('POST', '');
const callHandler = createMockCallHandler({ id: 'test' });
const result = await interceptor.intercept(context, callHandler);
const value = await lastValueFrom(result);
// Empty string is falsy, so should pass through
expect(value).toEqual({ id: 'test' });
});
it('should handle complex response objects', async () => {
cacheManager.get.mockResolvedValue(undefined);
const complexResponse = {
id: 'uuid-123',
nested: {
data: [1, 2, 3],
meta: { count: 3 },
},
tags: ['a', 'b', 'c'],
};
const context = createMockExecutionContext('POST', 'complex-key');
const callHandler = createMockCallHandler(complexResponse);
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(cacheManager.set).toHaveBeenCalledWith(
'idempotency:complex-key',
complexResponse,
expect.any(Number)
);
});
it('should handle array responses', async () => {
cacheManager.get.mockResolvedValue(undefined);
const arrayResponse = [{ id: '1' }, { id: '2' }];
const context = createMockExecutionContext('POST', 'array-key');
const callHandler = createMockCallHandler(arrayResponse);
const result = await interceptor.intercept(context, callHandler);
await lastValueFrom(result);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(cacheManager.set).toHaveBeenCalledWith(
'idempotency:array-key',
arrayResponse,
expect.any(Number)
);
});
});
});
@@ -41,7 +41,19 @@ export class IdempotencyInterceptor implements NestInterceptor {
const cacheKey = `idempotency:${idempotencyKey}`;
const cachedResponse = await this.cacheManager.get(cacheKey);
let cachedResponse;
try {
cachedResponse = await this.cacheManager.get(cacheKey);
} catch (err) {
// Log error but proceed with request if cache get fails
const errorMessage = err instanceof Error ? err.stack : String(err);
this.logger.error(
`Failed to get idempotency key ${idempotencyKey} from cache`,
errorMessage
);
// Proceed with request as if no cached response exists
cachedResponse = undefined;
}
if (cachedResponse) {
this.logger.warn(
@@ -0,0 +1,486 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { PerformanceInterceptor } from './performance.interceptor';
import { MetricsService } from '../../modules/monitoring/services/metrics.service';
import { of, throwError, lastValueFrom } from 'rxjs';
import { delay } from 'rxjs/operators';
describe('PerformanceInterceptor', () => {
let interceptor: PerformanceInterceptor;
let metricsService: jest.Mocked<MetricsService>;
const createMockRequest = (url: string, method: string) => ({
url,
method,
route: url.startsWith('/api/')
? { path: url.replace('/api', '') }
: undefined,
});
const createMockResponse = (statusCode: number) => ({
statusCode,
});
const createMockExecutionContext = (
url: string,
method: string,
statusCode: number
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => createMockRequest(url, method),
getResponse: () => createMockResponse(statusCode),
}),
} as ExecutionContext;
};
const createMockCallHandler = (data: unknown): CallHandler => ({
handle: () => of(data),
});
beforeEach(async () => {
const mockMetrics = {
httpRequestsTotal: {
inc: jest.fn(),
},
httpRequestDuration: {
observe: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
PerformanceInterceptor,
{
provide: MetricsService,
useValue: mockMetrics,
},
],
}).compile();
interceptor = module.get<PerformanceInterceptor>(PerformanceInterceptor);
metricsService = module.get(MetricsService);
// Mock Logger to suppress output
jest.spyOn(Logger.prototype, 'log').mockImplementation();
jest.spyOn(Logger.prototype, 'error').mockImplementation();
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
});
afterEach(() => {
jest.clearAllMocks();
// Re-initialize metricsService mocks after clearing
metricsService.httpRequestsTotal.inc = jest.fn();
metricsService.httpRequestDuration.observe = jest.fn();
});
it('should be defined', () => {
expect(interceptor).toBeDefined();
});
// ==========================================================
// Metrics endpoint exclusion
// ==========================================================
describe('endpoint exclusions', () => {
it('should skip /metrics endpoint', () => {
const context = createMockExecutionContext('/metrics', 'GET', 200);
const callHandler = createMockCallHandler({});
const handleSpy = jest.spyOn(callHandler, 'handle');
interceptor.intercept(context, callHandler);
expect(handleSpy).toHaveBeenCalled();
expect(metricsService.httpRequestsTotal.inc).not.toHaveBeenCalled();
});
it('should skip /health endpoint', () => {
const context = createMockExecutionContext('/health', 'GET', 200);
const callHandler = createMockCallHandler({ status: 'ok' });
interceptor.intercept(context, callHandler);
expect(metricsService.httpRequestsTotal.inc).not.toHaveBeenCalled();
});
it('should process regular API endpoints', async () => {
const context = createMockExecutionContext(
'/api/correspondences',
'GET',
200
);
const callHandler = createMockCallHandler({ data: [] });
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalled();
});
});
// ==========================================================
// Metrics recording
// ==========================================================
describe('metrics recording', () => {
it('should increment request counter with correct labels', async () => {
const context = createMockExecutionContext(
'/api/correspondences',
'GET',
200
);
const callHandler = createMockCallHandler({ data: [] });
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith({
method: 'GET',
route: '/correspondences',
status_code: '200',
});
});
it('should observe request duration with correct labels', async () => {
const context = createMockExecutionContext(
'/api/correspondences',
'POST',
201
);
const callHandler = createMockCallHandler({ id: 'new-uuid' });
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestDuration.observe).toHaveBeenCalledWith(
{
method: 'POST',
route: '/correspondences',
status_code: '201',
},
expect.any(Number) // Duration in seconds
);
});
it('should use route path when available', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
url: '/api/correspondences/123e4567-e89b-12d3-a456-426614174000',
method: 'GET',
route: { path: '/api/correspondences/:uuid' },
}),
getResponse: () => ({ statusCode: 200 }),
}),
} as ExecutionContext;
const callHandler = createMockCallHandler({});
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith(
expect.objectContaining({
route: '/api/correspondences/:uuid',
})
);
});
it('should fallback to URL when route not available', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
url: '/api/special-endpoint',
method: 'GET',
// No route property
}),
getResponse: () => ({ statusCode: 200 }),
}),
} as ExecutionContext;
const callHandler = createMockCallHandler({});
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith(
expect.objectContaining({
route: '/api/special-endpoint',
})
);
});
});
// ==========================================================
// Error handling
// ==========================================================
describe('error handling', () => {
it('should record metrics for error responses', async () => {
const context = createMockExecutionContext(
'/api/correspondences',
'POST',
500
);
const callHandler: CallHandler = {
handle: () => throwError(() => ({ status: 500, message: 'Error' })),
};
const result = interceptor.intercept(context, callHandler);
try {
await lastValueFrom(result);
} catch {
// Expected error
}
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith({
method: 'POST',
route: '/correspondences',
status_code: '500',
});
});
it('should use error status code when available', async () => {
const context = createMockExecutionContext('/api/test', 'GET', 400);
const callHandler: CallHandler = {
handle: () => throwError(() => ({ status: 400 })),
};
const result = interceptor.intercept(context, callHandler);
try {
await lastValueFrom(result);
} catch {
// Expected
}
expect(metricsService.httpRequestDuration.observe).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Number)
);
});
it('should default to 500 for errors without status', async () => {
const context = createMockExecutionContext('/api/test', 'GET', 500);
const callHandler: CallHandler = {
handle: () => throwError(() => new Error('Unknown error')),
};
const result = interceptor.intercept(context, callHandler);
try {
await lastValueFrom(result);
} catch {
// Expected
}
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith(
expect.objectContaining({
status_code: '500',
})
);
});
});
// ==========================================================
// Logging behavior
// ==========================================================
describe('logging behavior', () => {
it('should log slow requests (>200ms)', async () => {
const logSpy = jest.spyOn(Logger.prototype, 'log');
const context = {
switchToHttp: () => ({
getRequest: () => ({
url: '/api/slow-endpoint',
method: 'GET',
route: { path: '/api/slow-endpoint' },
}),
getResponse: () => ({ statusCode: 200 }),
}),
} as ExecutionContext;
const callHandler: CallHandler = {
handle: () =>
of({}).pipe(
delay(250) // Simulate slow response >200ms
),
};
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(logSpy).toHaveBeenCalled();
});
it('should log error responses (>=400)', async () => {
const logSpy = jest.spyOn(Logger.prototype, 'log');
const context = createMockExecutionContext('/api/error', 'GET', 400);
const callHandler = createMockCallHandler({ error: 'Bad Request' });
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(logSpy).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 400,
level: 'warn',
})
);
});
it('should log server errors with error level (>=500)', async () => {
const logSpy = jest.spyOn(Logger.prototype, 'log');
const context = createMockExecutionContext('/api/error', 'GET', 500);
const callHandler = createMockCallHandler({ error: 'Server Error' });
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(logSpy).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 500,
level: 'error',
})
);
});
it('should NOT log fast successful requests', async () => {
const logSpy = jest.spyOn(Logger.prototype, 'log');
const context = createMockExecutionContext('/api/fast', 'GET', 200);
const callHandler = createMockCallHandler({ data: 'quick' });
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
// Fast requests (<200ms, status <400) should not be logged
const slowOrErrorLogs = logSpy.mock.calls.filter(
(call) =>
(call[0] as { durationMs?: number })?.durationMs !== undefined ||
((call[0] as { statusCode?: number })?.statusCode ?? 0) >= 400
);
expect(slowOrErrorLogs.length).toBe(0);
});
});
// ==========================================================
// Duration calculation
// ==========================================================
describe('duration calculation', () => {
it('should calculate duration in seconds', async () => {
const context = createMockExecutionContext('/api/test', 'GET', 200);
const callHandler = createMockCallHandler({});
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
const observeCall = (
metricsService.httpRequestDuration.observe as jest.Mock
).mock.calls[0] as unknown[];
const durationSeconds = observeCall[1] as number;
expect(durationSeconds).toBeGreaterThanOrEqual(0);
expect(durationSeconds).toBeLessThan(1); // Should be very fast in tests
});
it('should log duration in milliseconds', async () => {
const logSpy = jest.spyOn(Logger.prototype, 'log');
const context = createMockExecutionContext('/api/slow', 'GET', 200);
const callHandler: CallHandler = {
handle: () => of({}).pipe(delay(250)), // Slow response
};
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
const logCall = logSpy.mock.calls.find(
(call) => (call[0] as { durationMs?: number })?.durationMs !== undefined
);
expect(logCall).toBeDefined();
if (logCall) {
expect(
(logCall[0] as { durationMs: number }).durationMs
).toBeGreaterThanOrEqual(200);
}
});
});
// ==========================================================
// Edge cases
// ==========================================================
describe('edge cases', () => {
it('should handle empty URL', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
url: '',
method: 'GET',
}),
getResponse: () => ({ statusCode: 200 }),
}),
} as ExecutionContext;
const callHandler = createMockCallHandler({});
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith(
expect.objectContaining({
route: '',
})
);
});
it('should handle various HTTP methods', async () => {
const methods = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS',
];
for (const method of methods) {
jest.clearAllMocks();
// Re-initialize metricsService mocks after clearing
metricsService.httpRequestsTotal.inc = jest.fn();
metricsService.httpRequestDuration.observe = jest.fn();
const context = createMockExecutionContext('/api/test', method, 200);
const callHandler = createMockCallHandler({});
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith(
expect.objectContaining({
method,
})
);
}
});
it('should handle response with final status code', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
url: '/api/test',
method: 'POST',
route: { path: '/api/test' },
}),
getResponse: () => ({ statusCode: 201 }), // Different from initial
}),
} as ExecutionContext;
const callHandler = createMockCallHandler({ id: 'new' });
const result = interceptor.intercept(context, callHandler);
await lastValueFrom(result);
expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith(
expect.objectContaining({
status_code: '201',
})
);
});
});
});
@@ -0,0 +1,387 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { TransformInterceptor, ApiResponse } from './transform.interceptor';
import { of, lastValueFrom } from 'rxjs';
describe('TransformInterceptor', () => {
let interceptor: TransformInterceptor<unknown>;
const createMockExecutionContext = (
statusCode: number = 200
): ExecutionContext => {
return {
switchToHttp: () => ({
getResponse: () => ({
statusCode,
}),
}),
} as ExecutionContext;
};
const createMockCallHandler = (data: unknown): CallHandler => {
return {
handle: () => of(data),
};
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TransformInterceptor],
}).compile();
interceptor =
module.get<TransformInterceptor<unknown>>(TransformInterceptor);
});
it('should be defined', () => {
expect(interceptor).toBeDefined();
});
// ==========================================================
// Standard Response Wrapping
// ==========================================================
describe('standard response wrapping', () => {
it('should wrap simple data in ApiResponse format', async () => {
const data = { id: 'test-uuid', name: 'Test' };
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(data);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result).toEqual({
statusCode: 200,
message: 'Success',
data,
});
});
it('should use custom message from data if provided', async () => {
const data = { result: { id: 'test' }, message: 'Custom message' };
const context = createMockExecutionContext(201);
const callHandler = createMockCallHandler(data);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result).toEqual({
statusCode: 201,
message: 'Custom message',
data: { id: 'test' },
});
});
it('should handle 201 Created status', async () => {
const data = { id: 'new-uuid' };
const context = createMockExecutionContext(201);
const callHandler = createMockCallHandler(data);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.statusCode).toBe(201);
});
});
// ==========================================================
// Paginated Response Handling
// ==========================================================
describe('paginated response handling', () => {
it('should unwrap paginated payload correctly', async () => {
const paginatedData = {
data: [{ id: '1' }, { id: '2' }],
meta: {
total: 100,
page: 1,
limit: 10,
totalPages: 10,
},
};
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(paginatedData);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result).toEqual({
statusCode: 200,
message: 'Success',
data: [{ id: '1' }, { id: '2' }],
meta: {
total: 100,
page: 1,
limit: 10,
totalPages: 10,
},
});
});
it('should preserve custom message in paginated response', async () => {
const paginatedData = {
data: [{ id: '1' }],
meta: {
total: 1,
page: 1,
limit: 10,
totalPages: 1,
},
message: 'Filtered results',
};
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(paginatedData);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.message).toBe('Filtered results');
});
it('should handle empty paginated results', async () => {
const paginatedData = {
data: [],
meta: {
total: 0,
page: 1,
limit: 10,
totalPages: 0,
},
};
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(paginatedData);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toEqual([]);
expect(result.meta?.total).toBe(0);
});
it('should handle paginated response with result field', async () => {
// When data has both result and meta, it should be treated as paginated
const paginatedData = {
data: [{ id: '1' }],
meta: {
total: 1,
page: 1,
limit: 10,
totalPages: 1,
},
};
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(paginatedData);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toEqual([{ id: '1' }]);
});
});
// ==========================================================
// Non-Paginated Data with Result Field
// ==========================================================
describe('non-paginated data with result field', () => {
it('should extract result field as data when present', async () => {
const data = {
result: { id: 'extracted', name: 'Extracted Data' },
otherField: 'ignored',
};
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(data);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toEqual({ id: 'extracted', name: 'Extracted Data' });
});
it('should handle data without result field', async () => {
const data = { id: 'direct', name: 'Direct Data' };
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(data);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toEqual({ id: 'direct', name: 'Direct Data' });
});
});
// ==========================================================
// ADR-019: Entity Serialization
// ==========================================================
describe('ADR-019: Entity serialization', () => {
it('should serialize class instances via instanceToPlain', async () => {
// Mock entity with @Exclude decorator
class MockEntity {
id = 1; // Internal ID (should be excluded)
publicId = 'uuid-123'; // Public ID
name = 'Test';
}
const entity = new MockEntity();
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(entity);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
// instanceToPlain should be applied (no @Exclude in this test, so all fields present)
expect(result.data).toBeDefined();
});
it('should handle null data gracefully', async () => {
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(null);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result).toEqual({
statusCode: 200,
message: 'Success',
data: null,
});
});
it('should handle undefined data gracefully', async () => {
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(undefined);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result).toEqual({
statusCode: 200,
message: 'Success',
data: undefined,
});
});
});
// ==========================================================
// Edge Cases
// ==========================================================
describe('edge cases', () => {
it('should handle primitive string data', async () => {
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler('simple string');
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toBe('simple string');
});
it('should handle primitive number data', async () => {
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(42);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toBe(42);
});
it('should handle primitive boolean data', async () => {
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(true);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toBe(true);
});
it('should handle array data', async () => {
const data = [{ id: '1' }, { id: '2' }];
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(data);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toEqual(data);
expect(result.meta).toBeUndefined(); // Arrays are NOT paginated
});
it('should handle deeply nested objects', async () => {
const data = {
level1: {
level2: {
level3: { value: 'deep' },
},
},
};
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(data);
const result = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.data).toEqual(data);
});
});
// ==========================================================
// Type Safety Tests
// ==========================================================
describe('type safety', () => {
it('should return correct ApiResponse type structure', async () => {
const data = { test: 'data' };
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(data);
const result: ApiResponse<unknown> = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
// Type checking at runtime
expect(result).toHaveProperty('statusCode');
expect(result).toHaveProperty('message');
expect(result).toHaveProperty('data');
expect(result.statusCode).toBe(200);
});
it('should include meta for paginated responses', async () => {
const paginatedData = {
data: [],
meta: {
total: 0,
page: 1,
limit: 10,
totalPages: 0,
},
};
const context = createMockExecutionContext(200);
const callHandler = createMockCallHandler(paginatedData);
const result: ApiResponse<unknown> = await lastValueFrom(
interceptor.intercept(context, callHandler)
);
expect(result.meta).toBeDefined();
expect(result.meta).toHaveProperty('total');
expect(result.meta).toHaveProperty('page');
expect(result.meta).toHaveProperty('limit');
expect(result.meta).toHaveProperty('totalPages');
});
});
});
+145
View File
@@ -0,0 +1,145 @@
import { assertUuid } from './uuid-guard';
describe('assertUuid', () => {
// ==========================================================
// Valid UUIDs (should pass)
// ==========================================================
describe('valid UUIDs', () => {
it('should accept valid UUIDv7 format (lowercase)', () => {
const uuid = '019505a1-7c3e-7000-8000-abc123def456';
expect(assertUuid(uuid)).toBe(uuid);
});
it('should accept valid UUIDv7 format (uppercase)', () => {
const uuid = '019505A1-7C3E-7000-8000-ABC123DEF456';
expect(assertUuid(uuid)).toBe(uuid);
});
it('should accept valid UUIDv7 format (mixed case)', () => {
const uuid = '019505a1-7C3E-7000-8000-aBc123DeF456';
expect(assertUuid(uuid)).toBe(uuid);
});
it('should accept valid UUIDv4 format', () => {
const uuid = 'a1b2c3d4-e5f6-4789-abcd-ef0123456789';
expect(assertUuid(uuid)).toBe(uuid);
});
it('should accept valid UUIDv1 format (MariaDB native)', () => {
const uuid = '550e8400-e29b-11d4-a716-446655440000';
expect(assertUuid(uuid)).toBe(uuid);
});
it('should accept UUID with all zeros', () => {
const uuid = '00000000-0000-0000-0000-000000000000';
expect(assertUuid(uuid)).toBe(uuid);
});
it('should accept UUID with all Fs', () => {
const uuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
expect(assertUuid(uuid)).toBe(uuid);
});
});
// ==========================================================
// Invalid UUIDs (should throw)
// ==========================================================
describe('invalid UUIDs', () => {
it('should throw for empty string', () => {
expect(() => assertUuid('')).toThrow('Invalid UUID format: ');
});
it('should throw for null (as string)', () => {
expect(() => assertUuid('null')).toThrow('Invalid UUID format: null');
});
it('should throw for undefined (as string)', () => {
expect(() => assertUuid('undefined')).toThrow(
'Invalid UUID format: undefined'
);
});
it('should throw for numeric string', () => {
expect(() => assertUuid('12345')).toThrow('Invalid UUID format: 12345');
});
it('should throw for string without hyphens', () => {
expect(() => assertUuid('019505a17c3e70008000abc123def456')).toThrow(
'Invalid UUID format: 019505a17c3e70008000abc123def456'
);
});
it('should throw for UUID with missing segments', () => {
expect(() => assertUuid('019505a1-7c3e-7000-8000')).toThrow(
'Invalid UUID format: 019505a1-7c3e-7000-8000'
);
});
it('should throw for UUID with extra segments', () => {
expect(() =>
assertUuid('019505a1-7c3e-7000-8000-abc123def456-extra')
).toThrow(
'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def456-extra'
);
});
it('should throw for UUID with invalid characters', () => {
expect(() => assertUuid('019505a1-7c3e-7000-8000-abc123def45g')).toThrow(
'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def45g'
);
});
it('should throw for UUID with special characters', () => {
expect(() => assertUuid('019505a1-7c3e-7000-8000-abc123def45!')).toThrow(
'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def45!'
);
});
it('should throw for whitespace-only string', () => {
expect(() => assertUuid(' ')).toThrow('Invalid UUID format: ');
});
it('should throw for UUID with leading whitespace', () => {
expect(() => assertUuid(' 019505a1-7c3e-7000-8000-abc123def456')).toThrow(
'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def456'
);
});
it('should throw for UUID with trailing whitespace', () => {
expect(() => assertUuid('019505a1-7c3e-7000-8000-abc123def456 ')).toThrow(
'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def456 '
);
});
it('should throw for random text', () => {
expect(() => assertUuid('not-a-valid-uuid-string')).toThrow(
'Invalid UUID format: not-a-valid-uuid-string'
);
});
});
// ==========================================================
// ADR-019 Compliance Tests
// ==========================================================
describe('ADR-019 UUID compliance', () => {
it('should NOT use parseInt on UUID (would cause incorrect behavior)', () => {
const uuid = '019505a1-7c3e-7000-8000-abc123def456';
// ADR-019: UUID must be validated as string — parseInt('019505a1-...') yields 19505 (WRONG)
// assertUuid enforces string-based UUID validation, preventing this data corruption
expect(() => assertUuid(uuid)).not.toThrow();
});
it('should validate UUID as string, not numeric', () => {
// UUIDs that look like numbers should still be validated as strings
const numericLikeUuid = '12345678-1234-1234-1234-123456789abc';
expect(() => assertUuid(numericLikeUuid)).not.toThrow();
});
it('should return the original UUID string when valid', () => {
const uuid = '019505a1-7c3e-7000-8000-abc123def456';
const result = assertUuid(uuid);
expect(result).toBe(uuid);
expect(typeof result).toBe('string');
});
});
});