690414:1113 Update README.md /.agents/skills, /.windsurf/workflows
This commit is contained in:
@@ -24,6 +24,10 @@ module.exports = {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
|
||||
// ใช้ V8 built-in coverage แทน babel-plugin-istanbul
|
||||
// เพื่อหลีกเลี่ยง test-exclude@6.0.0 + minimatch incompatibility
|
||||
coverageProvider: 'v8',
|
||||
|
||||
// Coverage configuration
|
||||
collectCoverageFrom: [
|
||||
'**/*.(t|j)s',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,22 @@ import {
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Patch,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { CirculationService } from './circulation.service';
|
||||
import { CreateCirculationDto } from './dto/create-circulation.dto';
|
||||
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto';
|
||||
import { SearchCirculationDto } from './dto/search-circulation.dto';
|
||||
import { ReassignRoutingDto } from './dto/reassign-routing.dto';
|
||||
import { ForceCloseCirculationDto } from './dto/force-close-circulation.dto';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
@@ -63,4 +72,43 @@ export class CirculationController {
|
||||
) {
|
||||
return this.circulationService.updateRoutingStatus(id, updateDto, user);
|
||||
}
|
||||
|
||||
@Patch(':uuid/routing/:routingId/reassign')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Re-assign routing to new user when assignee is deactivated (EC-CIRC-001)',
|
||||
})
|
||||
@ApiParam({ name: 'uuid', description: 'Circulation publicId' })
|
||||
@ApiParam({ name: 'routingId', description: 'CirculationRouting INT id' })
|
||||
@ApiBody({ type: ReassignRoutingDto })
|
||||
@RequirePermission('circulation.manage')
|
||||
@Audit('circulation.reassign', 'circulation')
|
||||
reassignRouting(
|
||||
@Param('routingId', ParseIntPipe) routingId: number,
|
||||
@Body() dto: ReassignRoutingDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.circulationService.reassignRouting(
|
||||
routingId,
|
||||
dto.newAssigneeId,
|
||||
user
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':uuid/force-close')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Force close a Circulation with mandatory reason (EC-CIRC-002)',
|
||||
})
|
||||
@ApiParam({ name: 'uuid', description: 'Circulation publicId' })
|
||||
@ApiBody({ type: ForceCloseCirculationDto })
|
||||
@RequirePermission('circulation.manage')
|
||||
@Audit('circulation.force_close', 'circulation')
|
||||
forceClose(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() dto: ForceCloseCirculationDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.circulationService.forceClose(uuid, dto.reason, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CirculationService } from './circulation.service';
|
||||
import { Circulation } from './entities/circulation.entity';
|
||||
import { CirculationRouting } from './entities/circulation-routing.entity';
|
||||
import { CirculationStatusCode } from './entities/circulation-status-code.entity';
|
||||
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
import {
|
||||
ValidationException,
|
||||
NotFoundException,
|
||||
} from '../../common/exceptions';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
describe('CirculationService', () => {
|
||||
let service: CirculationService;
|
||||
let circulationRepo: { findOne: jest.Mock; save: jest.Mock };
|
||||
let routingRepo: { findOne: jest.Mock; save: jest.Mock };
|
||||
let dataSource: { createQueryRunner: jest.Mock };
|
||||
let uuidResolver: { resolveUserId: jest.Mock };
|
||||
let workflowEngine: { getInstanceByEntity: jest.Mock };
|
||||
|
||||
const mockUser: Partial<User> = { user_id: 1, username: 'admin' };
|
||||
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: { save: jest.fn() },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
circulationRepo = { findOne: jest.fn(), save: jest.fn() };
|
||||
routingRepo = { findOne: jest.fn(), save: jest.fn() };
|
||||
uuidResolver = { resolveUserId: jest.fn() };
|
||||
workflowEngine = { getInstanceByEntity: jest.fn() };
|
||||
dataSource = { createQueryRunner: jest.fn(() => mockQueryRunner) };
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CirculationService,
|
||||
{ provide: getRepositoryToken(Circulation), useValue: circulationRepo },
|
||||
{
|
||||
provide: getRepositoryToken(CirculationRouting),
|
||||
useValue: routingRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CirculationStatusCode),
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: DocumentNumberingService, useValue: {} },
|
||||
{ provide: UuidResolverService, useValue: uuidResolver },
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{ provide: WorkflowEngineService, useValue: workflowEngine },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CirculationService>(CirculationService);
|
||||
});
|
||||
|
||||
describe('reassignRouting() - EC-CIRC-001', () => {
|
||||
it('reassigns a PENDING routing to a new user by UUID', async () => {
|
||||
const mockRouting = {
|
||||
id: 5,
|
||||
status: 'PENDING',
|
||||
assignedTo: 10,
|
||||
circulation: {},
|
||||
};
|
||||
routingRepo.findOne.mockResolvedValue(mockRouting);
|
||||
uuidResolver.resolveUserId.mockResolvedValue(99);
|
||||
routingRepo.save.mockResolvedValue({ ...mockRouting, assignedTo: 99 });
|
||||
|
||||
const result = await service.reassignRouting(
|
||||
5,
|
||||
'new-user-uuid',
|
||||
mockUser as User
|
||||
);
|
||||
|
||||
expect(uuidResolver.resolveUserId).toHaveBeenCalledWith('new-user-uuid');
|
||||
expect(routingRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedTo: 99 })
|
||||
);
|
||||
expect(result.assignedTo).toBe(99);
|
||||
});
|
||||
|
||||
it('throws ValidationException when routing is not in PENDING status', async () => {
|
||||
routingRepo.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
status: 'COMPLETED',
|
||||
circulation: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.reassignRouting(5, 'new-user-uuid', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when routing does not exist', async () => {
|
||||
routingRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.reassignRouting(999, 'new-user-uuid', mockUser as User)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceClose() - EC-CIRC-002', () => {
|
||||
const uuid = '019circ-0000-7000-8000-000000000001';
|
||||
|
||||
const buildMockCirculation = () => ({
|
||||
id: 100,
|
||||
publicId: uuid,
|
||||
circulationNo: 'CIRC-2026-001',
|
||||
statusCode: 'OPEN',
|
||||
routings: [
|
||||
{ id: 1, status: 'PENDING', comments: null, completedAt: null },
|
||||
{
|
||||
id: 2,
|
||||
status: 'COMPLETED',
|
||||
comments: 'done',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
{ id: 3, status: 'IN_PROGRESS', comments: null, completedAt: null },
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
circulationRepo.findOne.mockResolvedValue(buildMockCirculation());
|
||||
});
|
||||
|
||||
it('saves rejected routings and commits the transaction', async () => {
|
||||
await service.forceClose(uuid, 'Budget cut', mockUser as User);
|
||||
|
||||
expect(mockQueryRunner.manager.save).toHaveBeenCalledTimes(3);
|
||||
expect(mockQueryRunner.commitTransaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns success=true and affectedRoutings count of 2', async () => {
|
||||
const result = await service.forceClose(
|
||||
uuid,
|
||||
'Cost savings',
|
||||
mockUser as User
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.affectedRoutings).toBe(2);
|
||||
});
|
||||
|
||||
it('throws ValidationException when reason is an empty string', async () => {
|
||||
await expect(
|
||||
service.forceClose(uuid, '', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when reason is only whitespace', async () => {
|
||||
await expect(
|
||||
service.forceClose(uuid, ' ', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when circulation is already COMPLETED', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
...buildMockCirculation(),
|
||||
statusCode: 'COMPLETED',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.forceClose(uuid, 'Trying to close completed', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when circulation is already CANCELLED', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
...buildMockCirculation(),
|
||||
statusCode: 'CANCELLED',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.forceClose(uuid, 'Already cancelled', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when circulation is not found', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.forceClose(uuid, 'Not found', mockUser as User)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneByUuid() - EC-CIRC-003 workflowInstanceId + deadlineDate', () => {
|
||||
it('exposes workflowInstanceId and deadlineDate when a workflow instance exists', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
id: 100,
|
||||
publicId: '019circ-test-uuid',
|
||||
circulationNo: 'CIRC-001',
|
||||
subject: 'Test',
|
||||
statusCode: 'OPEN',
|
||||
routings: [],
|
||||
deadlineDate: '2026-04-20',
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue({
|
||||
id: 'wf-circ-uuid-001',
|
||||
currentState: 'OPEN',
|
||||
availableActions: [],
|
||||
});
|
||||
|
||||
const result = await service.findOneByUuid('019circ-test-uuid');
|
||||
|
||||
expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith(
|
||||
'circulation',
|
||||
'100'
|
||||
);
|
||||
expect(result.workflowInstanceId).toBe('wf-circ-uuid-001');
|
||||
expect(result.workflowState).toBe('OPEN');
|
||||
expect((result as { deadlineDate?: string }).deadlineDate).toBe(
|
||||
'2026-04-20'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty availableActions and undefined workflowInstanceId in draft state', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
publicId: '019circ-draft-uuid',
|
||||
circulationNo: 'CIRC-002',
|
||||
statusCode: 'DRAFT',
|
||||
routings: [],
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findOneByUuid('019circ-draft-uuid');
|
||||
|
||||
expect(result.workflowInstanceId).toBeUndefined();
|
||||
expect(result.availableActions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotFoundException,
|
||||
PermissionException,
|
||||
@@ -16,9 +16,12 @@ import { SearchCirculationDto } from './dto/search-circulation.dto';
|
||||
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
|
||||
@Injectable()
|
||||
export class CirculationService {
|
||||
private readonly logger = new Logger(CirculationService.name);
|
||||
|
||||
private async hasSystemManageAllPermission(userId: number): Promise<boolean> {
|
||||
const permissions = await this.userService.getUserPermissions(userId);
|
||||
return permissions.includes('system.manage_all');
|
||||
@@ -32,7 +35,8 @@ export class CirculationService {
|
||||
private numberingService: DocumentNumberingService,
|
||||
private dataSource: DataSource,
|
||||
private uuidResolver: UuidResolverService,
|
||||
private userService: UserService
|
||||
private userService: UserService,
|
||||
private workflowEngine: WorkflowEngineService
|
||||
) {}
|
||||
|
||||
async create(createDto: CreateCirculationDto, user: User) {
|
||||
@@ -184,7 +188,115 @@ export class CirculationService {
|
||||
});
|
||||
if (!circulation)
|
||||
throw new NotFoundException(`Circulation publicId ${publicId} not found`);
|
||||
return circulation;
|
||||
|
||||
// v1.8.7: ดึง Workflow Instance สำหรับ Circulation นี้ (nullable — ก่อน Submit ไม่มี Instance)
|
||||
const wfInstance = await this.workflowEngine.getInstanceByEntity(
|
||||
'circulation',
|
||||
circulation.id.toString()
|
||||
);
|
||||
|
||||
return {
|
||||
...circulation,
|
||||
workflowInstanceId: wfInstance?.id,
|
||||
workflowState: wfInstance?.currentState,
|
||||
availableActions: wfInstance?.availableActions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EC-CIRC-001: Re-assign routing เมื่อ Assignee ถูก Deactivate (v1.8.7)
|
||||
* ต้องมีสิทธิ์ circulation.manage
|
||||
*/
|
||||
async reassignRouting(
|
||||
routingId: number,
|
||||
newAssigneePublicId: string,
|
||||
user: User
|
||||
) {
|
||||
const routing = await this.routingRepo.findOne({
|
||||
where: { id: routingId },
|
||||
relations: ['circulation'],
|
||||
});
|
||||
if (!routing)
|
||||
throw new NotFoundException('Circulation Routing', String(routingId));
|
||||
|
||||
if (routing.status !== 'PENDING') {
|
||||
throw new ValidationException(
|
||||
`Routing ID ${routingId} ไม่ได้อยู่ใน PENDING จึงไม่สามารถ Re-assign ได้`
|
||||
);
|
||||
}
|
||||
|
||||
const newAssigneeId =
|
||||
await this.uuidResolver.resolveUserId(newAssigneePublicId);
|
||||
routing.assignedTo = newAssigneeId;
|
||||
const saved = await this.routingRepo.save(routing);
|
||||
|
||||
this.logger.log(
|
||||
`Circulation routing ${routingId} reassigned to user ${newAssigneeId} by ${user.user_id}`
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* EC-CIRC-002: Force Close Circulation พร้อม reason บังคับ (v1.8.7)
|
||||
* ปิด routing ที่ PENDING ทั้งหมด + เปลี่ยน statusCode เป็น CANCELLED
|
||||
* ต้องมีสิทธิ์ circulation.manage
|
||||
*/
|
||||
async forceClose(publicId: string, reason: string, user: User) {
|
||||
if (!reason || reason.trim().length === 0) {
|
||||
throw new ValidationException('กรุณาระบุเหตุผลในการปิดใบเวียนแบบบังคับ');
|
||||
}
|
||||
|
||||
const circulation = await this.circulationRepo.findOne({
|
||||
where: { publicId },
|
||||
relations: ['routings'],
|
||||
});
|
||||
if (!circulation)
|
||||
throw new NotFoundException(`Circulation publicId ${publicId}`);
|
||||
|
||||
if (
|
||||
circulation.statusCode === 'COMPLETED' ||
|
||||
circulation.statusCode === 'CANCELLED'
|
||||
) {
|
||||
throw new ValidationException(
|
||||
`ใบเวียน ${circulation.circulationNo} ปิดไปแล้ว (${circulation.statusCode})`
|
||||
);
|
||||
}
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// ปิด routing ที่ยัง PENDING ทั้งหมด
|
||||
const pendingRoutings = circulation.routings.filter(
|
||||
(r) => r.status === 'PENDING' || r.status === 'IN_PROGRESS'
|
||||
);
|
||||
for (const routing of pendingRoutings) {
|
||||
routing.status = 'REJECTED';
|
||||
routing.comments = `Force closed by user ${user.user_id}: ${reason}`;
|
||||
routing.completedAt = new Date();
|
||||
await queryRunner.manager.save(routing);
|
||||
}
|
||||
|
||||
// อัปเดตสถานะ Circulation เป็น CANCELLED
|
||||
circulation.statusCode = 'CANCELLED';
|
||||
circulation.closedAt = new Date();
|
||||
await queryRunner.manager.save(circulation);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
this.logger.log(
|
||||
`Circulation ${publicId} force-closed by user ${user.user_id}. Reason: ${reason}`
|
||||
);
|
||||
return { success: true, affectedRoutings: pendingRoutings.length };
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Force close failed for ${publicId}: ${(err as Error).message}`
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Logic อัปเดตสถานะและปิดงาน
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsNotEmpty, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ForceCloseCirculationDto {
|
||||
@ApiProperty({ description: 'เหตุผลการปิดใบเวียนแบบบังคับ (บังคับกรอก)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(5)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ReassignRoutingDto {
|
||||
@ApiProperty({
|
||||
description: 'publicId (UUID) ของผู้ใช้คนใหม่ที่ได้รับมอบหมาย',
|
||||
})
|
||||
@IsUUID('all')
|
||||
@IsNotEmpty()
|
||||
newAssigneeId!: string;
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export class Circulation extends UuidBaseEntity {
|
||||
@Column({ name: 'closed_at', type: 'timestamp', nullable: true })
|
||||
closedAt?: Date;
|
||||
|
||||
@Column({ name: 'deadline_date', type: 'date', nullable: true })
|
||||
deadlineDate?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Param,
|
||||
UseGuards,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { TransmittalService } from './transmittal.service';
|
||||
import { CreateTransmittalDto } from './dto/create-transmittal.dto';
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
|
||||
@ApiTags('Transmittals')
|
||||
@ApiBearerAuth()
|
||||
@@ -61,11 +64,29 @@ export class TransmittalController {
|
||||
@Get(':uuid')
|
||||
@ApiOperation({ summary: 'Get Transmittal details' })
|
||||
@ApiParam({
|
||||
name: 'publicId',
|
||||
name: 'uuid',
|
||||
description: 'Transmittal publicId (from correspondences.publicId)',
|
||||
})
|
||||
@RequirePermission('document.view')
|
||||
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
|
||||
return this.transmittalService.findOneByUuid(uuid);
|
||||
}
|
||||
|
||||
@Post(':uuid/submit')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Submit Transmittal to Workflow (with EC-RFA-004 validation)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'uuid',
|
||||
description: 'Transmittal publicId (from correspondences.publicId)',
|
||||
})
|
||||
@RequirePermission('document.manage')
|
||||
@Audit('transmittal.submit', 'transmittal')
|
||||
submit(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.transmittalService.submit(uuid, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { TransmittalItem } from './entities/transmittal-item.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
import { TransmittalService } from './transmittal.service';
|
||||
import { TransmittalController } from './transmittal.controller';
|
||||
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
|
||||
import { ProjectModule } from '../project/project.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,11 +22,13 @@ import { SearchModule } from '../search/search.module';
|
||||
Correspondence,
|
||||
CorrespondenceType,
|
||||
CorrespondenceStatus,
|
||||
CorrespondenceRevision,
|
||||
]),
|
||||
DocumentNumberingModule,
|
||||
ProjectModule,
|
||||
UserModule,
|
||||
SearchModule,
|
||||
WorkflowEngineModule,
|
||||
],
|
||||
controllers: [TransmittalController],
|
||||
providers: [TransmittalService],
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TransmittalService } from './transmittal.service';
|
||||
import { Transmittal } from './entities/transmittal.entity';
|
||||
import { TransmittalItem } from './entities/transmittal-item.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
import {
|
||||
ValidationException,
|
||||
NotFoundException,
|
||||
} from '../../common/exceptions';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
describe('TransmittalService', () => {
|
||||
let service: TransmittalService;
|
||||
let transmittalRepo: { findOne: jest.Mock };
|
||||
let revisionRepo: {
|
||||
findOne: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
let statusRepo: { findOne: jest.Mock };
|
||||
let dataSource: {
|
||||
manager: { findOne: jest.Mock };
|
||||
createQueryRunner: jest.Mock;
|
||||
};
|
||||
let workflowEngine: {
|
||||
getInstanceByEntity: jest.Mock;
|
||||
createInstance: jest.Mock;
|
||||
processTransition: jest.Mock;
|
||||
};
|
||||
|
||||
const mockUser: Partial<User> = {
|
||||
user_id: 1,
|
||||
username: 'testuser',
|
||||
primaryOrganizationId: 10,
|
||||
};
|
||||
|
||||
const mockTransmittal = {
|
||||
correspondenceId: 99,
|
||||
items: [{ itemCorrespondenceId: 201 }, { itemCorrespondenceId: 202 }],
|
||||
};
|
||||
|
||||
const mockQB = {
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: { save: jest.fn() },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
transmittalRepo = { findOne: jest.fn() };
|
||||
revisionRepo = {
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQB),
|
||||
save: jest.fn(),
|
||||
};
|
||||
statusRepo = { findOne: jest.fn() };
|
||||
dataSource = {
|
||||
manager: { findOne: jest.fn() },
|
||||
createQueryRunner: jest.fn(() => mockQueryRunner),
|
||||
};
|
||||
workflowEngine = {
|
||||
getInstanceByEntity: jest.fn(),
|
||||
createInstance: jest.fn(),
|
||||
processTransition: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TransmittalService,
|
||||
{ provide: getRepositoryToken(Transmittal), useValue: transmittalRepo },
|
||||
{
|
||||
provide: getRepositoryToken(TransmittalItem),
|
||||
useValue: { find: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceType),
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceStatus),
|
||||
useValue: statusRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceRevision),
|
||||
useValue: revisionRepo,
|
||||
},
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: DocumentNumberingService, useValue: {} },
|
||||
{ provide: UuidResolverService, useValue: {} },
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{ provide: WorkflowEngineService, useValue: workflowEngine },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TransmittalService>(TransmittalService);
|
||||
});
|
||||
|
||||
describe('submit() - EC-RFA-004', () => {
|
||||
const uuid = '019abc01-0000-7000-8000-000000000001';
|
||||
|
||||
beforeEach(() => {
|
||||
dataSource.manager.findOne.mockResolvedValue({
|
||||
id: 99,
|
||||
correspondenceNumber: 'TRN-2026-001',
|
||||
});
|
||||
transmittalRepo.findOne.mockResolvedValue(mockTransmittal);
|
||||
});
|
||||
|
||||
it('throws ValidationException when an item correspondence is in DRAFT state (EC-RFA-004)', async () => {
|
||||
mockQB.getMany.mockResolvedValue([
|
||||
{ correspondence: { correspondenceNumber: 'RFA-2026-001' } },
|
||||
]);
|
||||
|
||||
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
|
||||
ValidationException
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the draft document number in the error response', async () => {
|
||||
mockQB.getMany.mockResolvedValue([
|
||||
{ correspondence: { correspondenceNumber: 'RFA-2026-001' } },
|
||||
]);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
await service.submit(uuid, mockUser as User);
|
||||
} catch (e) {
|
||||
thrownError = e;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ValidationException);
|
||||
const res = (thrownError as ValidationException).getResponse();
|
||||
const resStr = typeof res === 'string' ? res : JSON.stringify(res);
|
||||
expect(resStr).toContain('RFA-2026-001');
|
||||
});
|
||||
|
||||
it('creates a workflow instance when no items are in DRAFT state', async () => {
|
||||
mockQB.getMany.mockResolvedValue([]);
|
||||
workflowEngine.createInstance.mockResolvedValue({
|
||||
id: 'wf-instance-uuid-001',
|
||||
});
|
||||
workflowEngine.processTransition.mockResolvedValue({
|
||||
nextState: 'IN_REVIEW',
|
||||
});
|
||||
revisionRepo.findOne.mockResolvedValue({
|
||||
id: 55,
|
||||
correspondenceId: 99,
|
||||
isCurrent: true,
|
||||
statusId: 1,
|
||||
});
|
||||
statusRepo.findOne
|
||||
.mockResolvedValueOnce({ id: 1, statusCode: 'DRAFT' })
|
||||
.mockResolvedValueOnce({ id: 2, statusCode: 'SUBMITTED' });
|
||||
|
||||
const result = await service.submit(uuid, mockUser as User);
|
||||
|
||||
expect(workflowEngine.createInstance).toHaveBeenCalledWith(
|
||||
'TRANSMITTAL_FLOW_V1',
|
||||
'transmittal',
|
||||
'99',
|
||||
expect.objectContaining({ ownerId: 1 })
|
||||
);
|
||||
expect(result).toEqual({
|
||||
instanceId: 'wf-instance-uuid-001',
|
||||
currentState: 'IN_REVIEW',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException when correspondence publicId is not found', async () => {
|
||||
dataSource.manager.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when transmittal record is not found', async () => {
|
||||
transmittalRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneByUuid() - workflowInstanceId exposure (ADR-021)', () => {
|
||||
const uuid = '019abc02-0000-7000-8000-000000000002';
|
||||
|
||||
it('returns workflowInstanceId and workflowState when a workflow instance exists', async () => {
|
||||
dataSource.manager.findOne.mockResolvedValue({ id: 99 });
|
||||
transmittalRepo.findOne.mockResolvedValue({
|
||||
correspondenceId: 99,
|
||||
transmittalNo: 'TRN-001',
|
||||
subject: 'Test',
|
||||
correspondence: {
|
||||
id: 99,
|
||||
publicId: uuid,
|
||||
correspondenceNumber: 'TRN-001',
|
||||
},
|
||||
items: [],
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue({
|
||||
id: 'wf-uuid-123',
|
||||
currentState: 'IN_REVIEW',
|
||||
availableActions: ['APPROVE', 'REJECT'],
|
||||
});
|
||||
|
||||
const result = await service.findOneByUuid(uuid);
|
||||
|
||||
expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith(
|
||||
'transmittal',
|
||||
'99'
|
||||
);
|
||||
expect(result.workflowInstanceId).toBe('wf-uuid-123');
|
||||
expect(result.workflowState).toBe('IN_REVIEW');
|
||||
expect(result.availableActions).toEqual(['APPROVE', 'REJECT']);
|
||||
});
|
||||
|
||||
it('returns undefined workflowInstanceId when no workflow instance exists (Draft state)', async () => {
|
||||
dataSource.manager.findOne.mockResolvedValue({ id: 99 });
|
||||
transmittalRepo.findOne.mockResolvedValue({
|
||||
correspondenceId: 99,
|
||||
transmittalNo: 'TRN-001',
|
||||
items: [],
|
||||
correspondence: {
|
||||
id: 99,
|
||||
publicId: uuid,
|
||||
correspondenceNumber: 'TRN-001',
|
||||
},
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findOneByUuid(uuid);
|
||||
|
||||
expect(result.workflowInstanceId).toBeUndefined();
|
||||
expect(result.workflowState).toBeUndefined();
|
||||
expect(result.availableActions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence-
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransmittalService {
|
||||
@@ -42,10 +43,13 @@ export class TransmittalService {
|
||||
private typeRepo: Repository<CorrespondenceType>,
|
||||
@InjectRepository(CorrespondenceStatus)
|
||||
private statusRepo: Repository<CorrespondenceStatus>,
|
||||
@InjectRepository(CorrespondenceRevision)
|
||||
private revisionRepo: Repository<CorrespondenceRevision>,
|
||||
private numberingService: DocumentNumberingService,
|
||||
private dataSource: DataSource,
|
||||
private uuidResolver: UuidResolverService,
|
||||
private userService: UserService
|
||||
private userService: UserService,
|
||||
private workflowEngine: WorkflowEngineService
|
||||
) {}
|
||||
|
||||
async create(
|
||||
@@ -192,9 +196,15 @@ export class TransmittalService {
|
||||
|
||||
/**
|
||||
* ADR-019: Find Transmittal by parent Correspondence publicId (public identifier).
|
||||
* Resolves correspondence.publicId → internal correspondenceId (INT)
|
||||
* v1.8.7: Exposes workflowInstanceId, workflowState, availableActions via WorkflowEngineService
|
||||
*/
|
||||
async findOneByUuid(publicId: string): Promise<Transmittal> {
|
||||
async findOneByUuid(publicId: string): Promise<
|
||||
Transmittal & {
|
||||
workflowInstanceId?: string;
|
||||
workflowState?: string;
|
||||
availableActions?: string[];
|
||||
}
|
||||
> {
|
||||
const correspondence = await this.dataSource.manager.findOne(
|
||||
Correspondence,
|
||||
{ where: { publicId }, select: ['id'] }
|
||||
@@ -204,7 +214,20 @@ export class TransmittalService {
|
||||
`Transmittal with publicId ${publicId} not found`
|
||||
);
|
||||
}
|
||||
return this.findOne(correspondence.id);
|
||||
const transmittal = await this.findOne(correspondence.id);
|
||||
|
||||
// v1.8.7: ดึง Workflow Instance สำหรับ Transmittal นี้ (nullable — Draft ไม่มี Instance)
|
||||
const wfInstance = await this.workflowEngine.getInstanceByEntity(
|
||||
'transmittal',
|
||||
correspondence.id.toString()
|
||||
);
|
||||
|
||||
return {
|
||||
...transmittal,
|
||||
workflowInstanceId: wfInstance?.id,
|
||||
workflowState: wfInstance?.currentState,
|
||||
availableActions: wfInstance?.availableActions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Transmittal> {
|
||||
@@ -217,6 +240,86 @@ export class TransmittalService {
|
||||
return transmittal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit Transmittal — ตรวจสอบ EC-RFA-004 ก่อนเริ่ม Workflow (v1.8.7)
|
||||
* EC-RFA-004: ทุก item ต้องไม่อยู่ใน DRAFT ก่อน Submit
|
||||
*/
|
||||
async submit(
|
||||
uuid: string,
|
||||
user: User
|
||||
): Promise<{ instanceId: string; currentState: string }> {
|
||||
const correspondence = await this.dataSource.manager.findOne(
|
||||
Correspondence,
|
||||
{ where: { publicId: uuid }, select: ['id', 'correspondenceNumber'] }
|
||||
);
|
||||
if (!correspondence)
|
||||
throw new NotFoundException(`Transmittal publicId ${uuid}`);
|
||||
|
||||
const transmittal = await this.transmittalRepo.findOne({
|
||||
where: { correspondenceId: correspondence.id },
|
||||
relations: ['items'],
|
||||
});
|
||||
if (!transmittal) throw new NotFoundException('Transmittal', uuid);
|
||||
|
||||
// EC-RFA-004: ตรวจสอบว่า item ทุกชิ้นไม่อยู่ใน DRAFT
|
||||
if (transmittal.items && transmittal.items.length > 0) {
|
||||
const itemCorrIds = transmittal.items.map((i) => i.itemCorrespondenceId);
|
||||
const draftRevisions = await this.revisionRepo
|
||||
.createQueryBuilder('rev')
|
||||
.innerJoin('rev.status', 'status')
|
||||
.where('rev.correspondenceId IN (:...ids)', { ids: itemCorrIds })
|
||||
.andWhere('rev.isCurrent = :isCurrent', { isCurrent: true })
|
||||
.andWhere('status.statusCode = :code', { code: 'DRAFT' })
|
||||
.leftJoinAndSelect('rev.correspondence', 'corr')
|
||||
.getMany();
|
||||
|
||||
if (draftRevisions.length > 0) {
|
||||
const draftDocNo =
|
||||
draftRevisions[0]?.correspondence?.correspondenceNumber ?? 'Unknown';
|
||||
throw new ValidationException(
|
||||
`RFA ${draftDocNo} ยังอยู่ใน Draft กรุณา Submit ก่อน`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// เริ่ม Workflow Instance สำหรับ Transmittal
|
||||
const statusDraft = await this.statusRepo.findOne({
|
||||
where: { statusCode: 'DRAFT' },
|
||||
});
|
||||
const instance = await this.workflowEngine.createInstance(
|
||||
'TRANSMITTAL_FLOW_V1',
|
||||
'transmittal',
|
||||
correspondence.id.toString(),
|
||||
{ ownerId: user.user_id }
|
||||
);
|
||||
|
||||
const result = await this.workflowEngine.processTransition(
|
||||
instance.id,
|
||||
'SUBMIT',
|
||||
user.user_id,
|
||||
'Transmittal Submitted'
|
||||
);
|
||||
|
||||
// Sync สถานะกลับที่ Correspondence Revision
|
||||
if (statusDraft) {
|
||||
const revision = await this.revisionRepo.findOne({
|
||||
where: { correspondenceId: correspondence.id, isCurrent: true },
|
||||
});
|
||||
if (revision) {
|
||||
const submittedStatus = await this.statusRepo.findOne({
|
||||
where: { statusCode: 'SUBMITTED' },
|
||||
});
|
||||
if (submittedStatus) {
|
||||
revision.statusId = submittedStatus.id;
|
||||
await this.revisionRepo.save(revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Transmittal ${uuid} submitted — instance ${instance.id}`);
|
||||
return { instanceId: instance.id, currentState: result.nextState };
|
||||
}
|
||||
|
||||
async findAll(query: SearchTransmittalDto) {
|
||||
const { page = 1, limit = 20, projectId, search } = query;
|
||||
const skip = ((page ?? 1) - 1) * (limit ?? 20);
|
||||
@@ -239,6 +342,13 @@ export class TransmittalService {
|
||||
});
|
||||
}
|
||||
|
||||
// B3: purpose filter (EC-RFA-004 aligned)
|
||||
if (query.purpose) {
|
||||
queryBuilder.andWhere('transmittal.purpose = :purpose', {
|
||||
purpose: query.purpose,
|
||||
});
|
||||
}
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(correspondence.correspondenceNumber LIKE :search OR revision.title LIKE :search)',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// ADR-021: Response DTOs สำหรับ GET /instances/:id/history
|
||||
export class AttachmentSummaryDto {
|
||||
publicId!: string;
|
||||
originalFilename!: string;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
export class WorkflowHistoryItemDto {
|
||||
id!: string;
|
||||
fromState!: string;
|
||||
toState!: string;
|
||||
action!: string;
|
||||
actionByUserId?: number;
|
||||
comment?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
attachments!: AttachmentSummaryDto[];
|
||||
createdAt!: string;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
// File: src/modules/workflow-engine/dto/workflow-transition.dto.ts
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class WorkflowTransitionDto {
|
||||
@ApiProperty({
|
||||
@@ -27,4 +35,16 @@ export class WorkflowTransitionDto {
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
payload?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'รายการ publicId ของไฟล์แนบ (ต้องอัปโหลดผ่าน Two-Phase ก่อน — ADR-016)',
|
||||
example: ['019505a1-7c3e-7000-8000-abc123def456'],
|
||||
type: [String],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
@ArrayMaxSize(20)
|
||||
@IsOptional()
|
||||
attachmentPublicIds?: string[];
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||
import { WorkflowInstance } from './workflow-instance.entity';
|
||||
|
||||
/**
|
||||
@@ -58,4 +60,12 @@ export class WorkflowHistory {
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// ADR-021: ไฟล์แนบที่อัปโหลดพร้อมขั้นตอนนี้ — Lazy โหลดเฉพาะเมื่อต้องการ (ป้องกัน N+1)
|
||||
@OneToMany(
|
||||
() => Attachment,
|
||||
(attachment: Attachment) => attachment.workflowHistory,
|
||||
{ lazy: true }
|
||||
)
|
||||
attachments?: Promise<Attachment[]>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// File: src/modules/workflow-engine/guards/workflow-transition.guard.ts
|
||||
// Guard ตรวจสอบสิทธิ์ 4-Level RBAC สำหรับ Workflow Transition ตาม ADR-021 §6
|
||||
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { WorkflowInstance } from '../entities/workflow-instance.entity';
|
||||
import { UserService } from '../../../modules/user/user.service';
|
||||
import type { RequestWithUser } from '../../../common/interfaces/request-with-user.interface';
|
||||
|
||||
/**
|
||||
* WorkflowTransitionGuard — ตรวจสอบสิทธิ์ 4 ระดับก่อนอนุญาตให้เปลี่ยนสถานะ Workflow
|
||||
*
|
||||
* Level 1: system.manage_all (Superadmin) → ผ่านทันที
|
||||
* Level 2: organization.manage_users + สังกัดองค์กรเดียวกับเอกสาร → ผ่าน
|
||||
* Level 3: Assigned Handler (context.assignedUserId === req.user.user_id) → ผ่าน
|
||||
* Level 4: ผู้ใช้ทั่วไป → ForbiddenException
|
||||
*/
|
||||
@Injectable()
|
||||
export class WorkflowTransitionGuard implements CanActivate {
|
||||
private readonly logger = new Logger(WorkflowTransitionGuard.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkflowInstance)
|
||||
private readonly instanceRepo: Repository<WorkflowInstance>,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const instanceId = request.params['id'];
|
||||
const user = request.user;
|
||||
|
||||
// ดึงสิทธิ์ทั้งหมดของ User จาก DB (ตาม pattern เดียวกับ RbacGuard)
|
||||
const userPermissions = await this.userService.getUserPermissions(
|
||||
user.user_id
|
||||
);
|
||||
|
||||
// Level 1: Superadmin — ผ่านทุกการตรวจสอบ
|
||||
if (userPermissions.includes('system.manage_all')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ดึง Instance เพื่อตรวจสอบ Context
|
||||
const instance = await this.instanceRepo.findOne({
|
||||
where: { id: instanceId },
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
throw new NotFoundException('Workflow Instance', instanceId);
|
||||
}
|
||||
|
||||
// Level 2: Org Admin — organization.manage_users + สังกัดองค์กรเดียวกับเอกสาร
|
||||
const docOrgId = instance.context?.organizationId as number | undefined;
|
||||
if (
|
||||
userPermissions.includes('organization.manage_users') &&
|
||||
docOrgId !== undefined &&
|
||||
user.primaryOrganizationId === docOrgId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Level 3: Assigned Handler — User นี้ถูก Assign มาให้ทำ Step นี้โดยตรง
|
||||
const assignedUserId = instance.context?.assignedUserId as
|
||||
| number
|
||||
| undefined;
|
||||
if (assignedUserId !== undefined && user.user_id === assignedUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Unauthorized transition attempt: User ${user.user_id} on Instance ${instanceId}`
|
||||
);
|
||||
throw new ForbiddenException({
|
||||
userMessage: 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้',
|
||||
recoveryAction: 'ติดต่อผู้รับผิดชอบหรือ Admin หากคิดว่านี่เป็นข้อผิดพลาด',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
// File: src/modules/workflow-engine/workflow-engine.controller.ts
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
@@ -27,10 +32,11 @@ import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import { WorkflowTransitionDto } from './dto/workflow-transition.dto';
|
||||
|
||||
// Guards & Decorators (อ้างอิงตามโครงสร้าง src/common ในแผนงาน)
|
||||
// Guards & Decorators
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { WorkflowTransitionGuard } from './guards/workflow-transition.guard';
|
||||
import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface';
|
||||
|
||||
@ApiTags('Workflow Engine')
|
||||
@@ -38,7 +44,10 @@ import type { RequestWithUser } from '../../common/interfaces/request-with-user.
|
||||
@Controller('workflow-engine')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard) // บังคับ Login และตรวจสอบสิทธิ์ทุก Request
|
||||
export class WorkflowEngineController {
|
||||
constructor(private readonly workflowService: WorkflowEngineService) {}
|
||||
constructor(
|
||||
private readonly workflowService: WorkflowEngineService,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache
|
||||
) {}
|
||||
|
||||
// =================================================================
|
||||
// Definition Management (Admin / Developer)
|
||||
@@ -89,25 +98,56 @@ export class WorkflowEngineController {
|
||||
// =================================================================
|
||||
|
||||
@Post('instances/:id/transition')
|
||||
@ApiOperation({ summary: 'สั่งเปลี่ยนสถานะเอกสาร (User Action)' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'สั่งเปลี่ยนสถานะเอกสาร (User Action) — ADR-021: 4-Level RBAC + Idempotency',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' })
|
||||
// Permission จะถูกตรวจสอบ Dynamic ภายใน Service ตาม State ของ Workflow แต่ขั้นต้นต้องมีสิทธิ์ทำงาน Workflow
|
||||
@RequirePermission('workflow.action_review')
|
||||
// ADR-021: แทนที่ @RequirePermission สามัญใช้ WorkflowTransitionGuard (4-Level RBAC เต็มรูปแบบ)
|
||||
@UseGuards(WorkflowTransitionGuard)
|
||||
async processTransition(
|
||||
@Param('id') instanceId: string,
|
||||
@Body() dto: WorkflowTransitionDto,
|
||||
@Request() req: RequestWithUser
|
||||
@Request() req: RequestWithUser,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
// ดึง User ID จาก Token (req.user มาจาก JwtStrategy)
|
||||
// ADR-016: Idempotency-Key ต้องมีทุก Request
|
||||
if (!idempotencyKey) {
|
||||
throw new BadRequestException('Idempotency-Key header is required');
|
||||
}
|
||||
|
||||
// ตรวจ Redis ว่า Request นี้ถูกส่งมาแล้วหรือไม่
|
||||
const cacheKey = `idempotency:wf:${idempotencyKey}`;
|
||||
const cached = await this.cacheManager.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached; // คืนผลเดิม (Idempotent Response)
|
||||
}
|
||||
|
||||
const userId = req.user?.user_id;
|
||||
|
||||
return this.workflowService.processTransition(
|
||||
const result = await this.workflowService.processTransition(
|
||||
instanceId,
|
||||
dto.action,
|
||||
userId,
|
||||
dto.comment,
|
||||
dto.payload
|
||||
dto.payload,
|
||||
dto.attachmentPublicIds // ADR-021: step-specific attachments
|
||||
);
|
||||
|
||||
// เก็บใน Redis 24 ชั่วโมง (86400 วินาที = 86400000 ms ใน cache-manager v7)
|
||||
await this.cacheManager.set(cacheKey, result, 86_400_000);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('instances/:id/history')
|
||||
@ApiOperation({
|
||||
summary: 'ดึงประวัติ Workflow พร้อมไฟล์แนบประจำแต่ละ Step (ADR-021)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' })
|
||||
@RequirePermission('document.view')
|
||||
async getHistory(@Param('id') instanceId: string) {
|
||||
return this.workflowService.getHistoryWithAttachments(instanceId);
|
||||
}
|
||||
|
||||
@Get('instances/:id/actions')
|
||||
|
||||
@@ -7,26 +7,37 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
import { WorkflowInstance } from './entities/workflow-instance.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
|
||||
// Services
|
||||
import { WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
import { WorkflowEventService } from './workflow-event.service'; // [NEW]
|
||||
|
||||
// Guards
|
||||
import { WorkflowTransitionGuard } from './guards/workflow-transition.guard';
|
||||
|
||||
// Controllers
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WorkflowEngineController } from './workflow-engine.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance,
|
||||
WorkflowHistory,
|
||||
Attachment, // ADR-021: ใช้ link attachments ประจำ Step
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [WorkflowEngineController],
|
||||
providers: [WorkflowEngineService, WorkflowDslService, WorkflowEventService],
|
||||
providers: [
|
||||
WorkflowEngineService,
|
||||
WorkflowDslService,
|
||||
WorkflowEventService,
|
||||
WorkflowTransitionGuard,
|
||||
],
|
||||
exports: [WorkflowEngineService], // Export Service ให้ Module อื่น (Correspondence, RFA) เรียกใช้
|
||||
})
|
||||
export class WorkflowEngineModule {}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
WorkflowStatus,
|
||||
} from './entities/workflow-instance.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
import { WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEventService } from './workflow-event.service';
|
||||
import { NotFoundException } from '../../common/exceptions';
|
||||
@@ -30,6 +31,7 @@ describe('WorkflowEngineService', () => {
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,6 +83,14 @@ describe('WorkflowEngineService', () => {
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Attachment),
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: WorkflowDslService, useValue: mockDslService },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotFoundException, WorkflowException } from '../../common/exceptions';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
// Entities
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
WorkflowInstance,
|
||||
WorkflowStatus,
|
||||
} from './entities/workflow-instance.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
|
||||
// Services & Interfaces
|
||||
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
||||
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import { WorkflowHistoryItemDto } from './dto/workflow-history-item.dto';
|
||||
import {
|
||||
CompiledWorkflow,
|
||||
RawEvent,
|
||||
@@ -48,6 +50,9 @@ export class WorkflowEngineService {
|
||||
private readonly instanceRepo: Repository<WorkflowInstance>,
|
||||
@InjectRepository(WorkflowHistory)
|
||||
private readonly historyRepo: Repository<WorkflowHistory>,
|
||||
// ADR-021: Repository สำหรับ Link Attachments ประจำ Step
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>,
|
||||
private readonly dslService: WorkflowDslService,
|
||||
private readonly eventService: WorkflowEventService, // [NEW] Inject Service
|
||||
private readonly dataSource: DataSource // ใช้สำหรับ Transaction
|
||||
@@ -243,6 +248,42 @@ export class WorkflowEngineService {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหา Workflow Instance จาก entityType + entityId (ADR-021 / v1.8.7)
|
||||
* ใช้โดย TransmittalService และ CirculationService เพื่อ expose workflowInstanceId ใน response
|
||||
* คืนค่า null ถ้าไม่มี Instance (เช่น เอกสาร Draft ที่ยังไม่เริ่ม Workflow)
|
||||
*/
|
||||
async getInstanceByEntity(
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
currentState: string;
|
||||
availableActions: string[];
|
||||
} | null> {
|
||||
const instance = await this.instanceRepo.findOne({
|
||||
where: { entityType, entityId, status: WorkflowStatus.ACTIVE },
|
||||
relations: ['definition'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!instance) return null;
|
||||
|
||||
const compiled = instance.definition?.compiled as unknown as
|
||||
| CompiledWorkflow
|
||||
| undefined;
|
||||
const stateConfig = compiled?.states?.[instance.currentState];
|
||||
const availableActions = stateConfig?.transitions
|
||||
? Object.keys(stateConfig.transitions)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
currentState: instance.currentState,
|
||||
availableActions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ดำเนินการเปลี่ยนสถานะ (Transition) ของ Instance จริงแบบ Transactional
|
||||
*/
|
||||
@@ -251,7 +292,9 @@ export class WorkflowEngineService {
|
||||
action: string,
|
||||
userId: number,
|
||||
comment?: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
payload: Record<string, unknown> = {},
|
||||
// ADR-021: publicIds ของไฟล์แนบประจำ Step นี้ (Two-Phase upload ก่อนแล้ว)
|
||||
attachmentPublicIds?: string[]
|
||||
) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
@@ -323,6 +366,15 @@ export class WorkflowEngineService {
|
||||
});
|
||||
await queryRunner.manager.save(history);
|
||||
|
||||
// ADR-021: ผูกไฟล์แนบประจำ Step นี้ (ทำในตัว Transaction เดียวกัน)
|
||||
if (attachmentPublicIds && attachmentPublicIds.length > 0) {
|
||||
await queryRunner.manager.update(
|
||||
Attachment,
|
||||
{ publicId: In(attachmentPublicIds) },
|
||||
{ workflowHistoryId: history.id }
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// [NEW] เก็บค่าไว้ Dispatch หลัง Commit
|
||||
@@ -380,6 +432,66 @@ export class WorkflowEngineService {
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// [PART 2.5] ADR-021: Workflow History with Step Attachments
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* ดึงประวัติ Workflow พร้อมไฟล์แนบประจำแต่ละ Step (2-query, ไม่มี N+1)
|
||||
* GET /instances/:id/history
|
||||
*/
|
||||
async getHistoryWithAttachments(
|
||||
instanceId: string
|
||||
): Promise<WorkflowHistoryItemDto[]> {
|
||||
const histories = await this.historyRepo.find({
|
||||
where: { instanceId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
if (histories.length === 0) return [];
|
||||
|
||||
// Batch-load attachments ครั้งเดียวเพื่อป้องกัน N+1
|
||||
const historyIds = histories.map((h) => h.id);
|
||||
const attachments = await this.attachmentRepo.find({
|
||||
where: { workflowHistoryId: In(historyIds) },
|
||||
select: [
|
||||
'publicId',
|
||||
'originalFilename',
|
||||
'mimeType',
|
||||
'fileSize',
|
||||
'workflowHistoryId',
|
||||
],
|
||||
});
|
||||
|
||||
// Group attachments ตาม workflowHistoryId
|
||||
const attByHistoryId = attachments.reduce<Record<string, Attachment[]>>(
|
||||
(acc, att) => {
|
||||
const key = att.workflowHistoryId!;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(att);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return histories.map((h) => ({
|
||||
id: h.id,
|
||||
fromState: h.fromState,
|
||||
toState: h.toState,
|
||||
action: h.action,
|
||||
actionByUserId: h.actionByUserId,
|
||||
comment: h.comment,
|
||||
metadata: h.metadata,
|
||||
attachments: (attByHistoryId[h.id] ?? []).map((att) => ({
|
||||
publicId: att.publicId,
|
||||
originalFilename: att.originalFilename,
|
||||
mimeType: att.mimeType,
|
||||
fileSize: att.fileSize,
|
||||
})),
|
||||
createdAt: h.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// [PART 3] Legacy Support (Backward Compatibility)
|
||||
// รักษา Logic เดิมไว้เพื่อให้ Module อื่น (Correspondence/RFA) ทำงานต่อได้
|
||||
|
||||
Reference in New Issue
Block a user