690522:0554 227 #01
CI / CD Pipeline / build (push) Successful in 5m25s
CI / CD Pipeline / deploy (push) Successful in 8m59s

This commit is contained in:
2026-05-22 05:54:34 +07:00
parent a2952a32a4
commit f47363c24a
15 changed files with 2653 additions and 0 deletions
@@ -0,0 +1,137 @@
// File: src/common/guards/maintenance-mode.guard.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ MaintenanceModeGuard (T1.1)
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, ServiceUnavailableException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { MaintenanceModeGuard } from './maintenance-mode.guard';
import { BYPASS_MAINTENANCE_KEY } from '../decorators/bypass-maintenance.decorator';
// Helper สร้าง mock ExecutionContext
const makeContext = (url = '/api/test'): ExecutionContext =>
({
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({ url }),
}),
}) as unknown as ExecutionContext;
describe('MaintenanceModeGuard', () => {
let guard: MaintenanceModeGuard;
const mockReflector = { getAllAndOverride: jest.fn() };
const mockCacheManager = { get: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MaintenanceModeGuard,
{ provide: Reflector, useValue: mockReflector },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
}).compile();
guard = module.get<MaintenanceModeGuard>(MaintenanceModeGuard);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(guard).toBeDefined();
});
describe('Bypass decorator', () => {
it('ควร allow request เมื่อ route มี @BypassMaintenance() decorator', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(true);
const ctx = makeContext();
const result = await guard.canActivate(ctx);
expect(result).toBe(true);
expect(mockCacheManager.get).not.toHaveBeenCalled();
});
});
describe('Maintenance mode OFF', () => {
it('ควร allow request เมื่อ Redis คืน null (ไม่ได้เปิด maintenance)', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
mockCacheManager.get.mockResolvedValueOnce(null);
const ctx = makeContext();
const result = await guard.canActivate(ctx);
expect(result).toBe(true);
});
it('ควร allow request เมื่อ Redis คืน false', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
mockCacheManager.get.mockResolvedValueOnce(false);
const ctx = makeContext();
const result = await guard.canActivate(ctx);
expect(result).toBe(true);
});
it('ควร allow request เมื่อ Redis คืน undefined', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
mockCacheManager.get.mockResolvedValueOnce(undefined);
const ctx = makeContext();
const result = await guard.canActivate(ctx);
expect(result).toBe(true);
});
});
describe('Maintenance mode ON', () => {
it('ควร throw ServiceUnavailableException เมื่อ Redis คืน true (boolean)', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
mockCacheManager.get.mockResolvedValueOnce(true);
const ctx = makeContext('/api/correspondences');
await expect(guard.canActivate(ctx)).rejects.toThrow(
ServiceUnavailableException
);
});
it('ควร throw ServiceUnavailableException เมื่อ Redis คืน "true" (string)', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
mockCacheManager.get.mockResolvedValueOnce('true');
const ctx = makeContext();
await expect(guard.canActivate(ctx)).rejects.toThrow(
ServiceUnavailableException
);
});
});
describe('Fail Open — Redis Error', () => {
it('ควร allow request (Fail Open) เมื่อ Redis ล่ม', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
mockCacheManager.get.mockRejectedValueOnce(
new Error('Redis connection lost')
);
const ctx = makeContext();
// ไม่ throw, ให้ผ่านไป (Fail Open policy)
const result = await guard.canActivate(ctx);
expect(result).toBe(true);
});
it('ควร re-throw ServiceUnavailableException (ไม่ swallow มัน)', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
// จำลองกรณีที่ ServiceUnavailableException ถูก throw ใน catch block
mockCacheManager.get.mockRejectedValueOnce(
new ServiceUnavailableException('Already thrown')
);
const ctx = makeContext();
await expect(guard.canActivate(ctx)).rejects.toThrow(
ServiceUnavailableException
);
});
});
describe('Reflector key check', () => {
it('ควรเช็ค BYPASS_MAINTENANCE_KEY ด้วย getAllAndOverride', async () => {
mockReflector.getAllAndOverride.mockReturnValueOnce(false);
mockCacheManager.get.mockResolvedValueOnce(null);
const ctx = makeContext();
await guard.canActivate(ctx);
expect(mockReflector.getAllAndOverride).toHaveBeenCalledWith(
BYPASS_MAINTENANCE_KEY,
expect.any(Array)
);
});
});
});
@@ -0,0 +1,281 @@
// File: src/modules/json-schema/services/schema-migration.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ SchemaMigrationService
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { SchemaMigrationService } from './schema-migration.service';
import { JsonSchemaService } from '../json-schema.service';
// Helper สร้าง mock QueryRunner
const makeQueryRunner = () => ({
connect: jest.fn().mockResolvedValue(undefined),
startTransaction: jest.fn().mockResolvedValue(undefined),
commitTransaction: jest.fn().mockResolvedValue(undefined),
rollbackTransaction: jest.fn().mockResolvedValue(undefined),
release: jest.fn().mockResolvedValue(undefined),
manager: {
query: jest.fn(),
},
});
describe('SchemaMigrationService', () => {
let service: SchemaMigrationService;
let mockQR: ReturnType<typeof makeQueryRunner>;
const mockJsonSchemaService = {
findOneByCodeAndVersion: jest.fn(),
findLatestByCode: jest.fn(),
validateData: jest.fn(),
};
beforeEach(async () => {
mockQR = makeQueryRunner();
const mockDataSource = {
createQueryRunner: jest.fn().mockReturnValue(mockQR),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SchemaMigrationService,
{ provide: DataSource, useValue: mockDataSource },
{ provide: JsonSchemaService, useValue: mockJsonSchemaService },
],
}).compile();
service = module.get<SchemaMigrationService>(SchemaMigrationService);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('migrateData', () => {
const targetSchema = {
version: 2,
schemaCode: 'RFA_FORM',
migrationScript: null,
};
it('ควรคืน success=true ทันทีเมื่อ currentVersion >= targetVersion', async () => {
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce({
...targetSchema,
version: 1,
});
mockQR.manager.query.mockResolvedValueOnce([
{ details: { title: 'test' }, schema_version: 1 },
]);
const result = await service.migrateData('rfa_revisions', 1, 'RFA_FORM');
expect(result.success).toBe(true);
expect(result.migratedFields).toHaveLength(0);
expect(result.fromVersion).toBe(1);
expect(result.toVersion).toBe(1);
});
it('ควร throw NotFoundException เมื่อ entity ไม่พบ', async () => {
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([]); // ไม่มี record
await expect(
service.migrateData('rfa_revisions', 999, 'RFA_FORM')
).rejects.toThrow();
expect(mockQR.rollbackTransaction).toHaveBeenCalled();
expect(mockQR.release).toHaveBeenCalled();
});
it('ควร migrate ข้ามไปยัง targetVersion พร้อม commit', async () => {
// Target = v2, current entity = v1
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: { title: 'old' }, schema_version: 1 },
]);
// v2 migration script
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce({
version: 2,
schemaCode: 'RFA_FORM',
migrationScript: {
steps: [
{
type: 'FIELD_RENAME',
config: { old_field: 'title', new_field: 'subject' },
},
],
},
});
mockJsonSchemaService.validateData.mockResolvedValueOnce({
isValid: true,
sanitizedData: { subject: 'old' },
});
mockQR.manager.query.mockResolvedValueOnce(undefined); // UPDATE
const result = await service.migrateData('rfa_revisions', 1, 'RFA_FORM');
expect(result.success).toBe(true);
expect(result.fromVersion).toBe(1);
expect(result.toVersion).toBe(2);
expect(result.migratedFields).toContain('subject');
expect(mockQR.commitTransaction).toHaveBeenCalled();
});
it('ควร rollback และ throw เมื่อ validation ล้มเหลว', async () => {
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: { title: 'old' }, schema_version: 1 },
]);
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce({
version: 2,
migrationScript: { steps: [] },
});
mockJsonSchemaService.validateData.mockResolvedValueOnce({
isValid: false,
sanitizedData: null,
});
let error: any;
try {
await service.migrateData('rfa_revisions', 1, 'RFA_FORM');
} catch (err) {
error = err;
}
expect(error).toBeDefined();
expect(error.code).toBe('SCHEMA_MIGRATION_VALIDATION_FAILED');
expect(mockQR.rollbackTransaction).toHaveBeenCalled();
});
it('ควรดึง schema ด้วย version ที่ระบุ เมื่อส่ง targetVersion', async () => {
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: {}, schema_version: 2 },
]); // already up-to-date
await service.migrateData('rfa_revisions', 1, 'RFA_FORM', 2);
expect(
mockJsonSchemaService.findOneByCodeAndVersion
).toHaveBeenCalledWith('RFA_FORM', 2);
});
});
describe('applyMigrationStep (private — tested via migrateData)', () => {
const runStep = async (
step: Record<string, unknown>,
data: Record<string, unknown>
) => {
const targetSchema = {
version: 2,
schemaCode: 'TEST',
migrationScript: { steps: [step] },
};
mockJsonSchemaService.findLatestByCode.mockResolvedValueOnce(
targetSchema
);
mockQR.manager.query.mockResolvedValueOnce([
{ details: data, schema_version: 1 },
]);
mockJsonSchemaService.findOneByCodeAndVersion.mockResolvedValueOnce(
targetSchema
);
let capturedData: Record<string, unknown> = {};
mockJsonSchemaService.validateData.mockImplementationOnce(
(_code: string, d: Record<string, unknown>) => {
capturedData = d;
return Promise.resolve({ isValid: true, sanitizedData: d });
}
);
mockQR.manager.query.mockResolvedValueOnce(undefined);
await service.migrateData('test_table', 1, 'TEST');
return capturedData;
};
it('FIELD_RENAME: ควรเปลี่ยนชื่อ field', async () => {
const result = await runStep(
{
type: 'FIELD_RENAME',
config: { old_field: 'title', new_field: 'subject' },
},
{ title: 'Hello' }
);
expect(result['subject']).toBe('Hello');
expect(result['title']).toBeUndefined();
});
it('FIELD_ADD: ควรเพิ่ม field ด้วย default value', async () => {
const result = await runStep(
{
type: 'FIELD_ADD',
config: { field: 'newField', default_value: 'N/A' },
},
{ existing: 'data' }
);
expect(result['newField']).toBe('N/A');
});
it('FIELD_ADD: ควรไม่ overwrite field ที่มีอยู่แล้ว', async () => {
const result = await runStep(
{
type: 'FIELD_ADD',
config: { field: 'existing', default_value: 'N/A' },
},
{ existing: 'original' }
);
expect(result['existing']).toBe('original');
});
it('FIELD_REMOVE: ควรลบ field', async () => {
const result = await runStep(
{ type: 'FIELD_REMOVE', config: { field: 'toRemove' } },
{ toRemove: 'bye', keep: 'yes' }
);
expect(result['toRemove']).toBeUndefined();
expect(result['keep']).toBe('yes');
});
it('FIELD_TRANSFORM MAP_VALUES: ควร map ค่าตาม mapping', async () => {
const result = await runStep(
{
type: 'FIELD_TRANSFORM',
config: {
field: 'status',
transform: 'MAP_VALUES',
mapping: { DRAFT: 'IN_DRAFT' },
},
},
{ status: 'DRAFT' }
);
expect(result['status']).toBe('IN_DRAFT');
});
it('FIELD_TRANSFORM TO_NUMBER: ควรแปลง string เป็น number', async () => {
const result = await runStep(
{
type: 'FIELD_TRANSFORM',
config: { field: 'amount', transform: 'TO_NUMBER' },
},
{ amount: '42' }
);
expect(result['amount']).toBe(42);
});
it('FIELD_TRANSFORM TO_STRING: ควรแปลง number เป็น string', async () => {
const result = await runStep(
{
type: 'FIELD_TRANSFORM',
config: { field: 'code', transform: 'TO_STRING' },
},
{ code: 123 }
);
expect(result['code']).toBe('123');
});
it('STRUCTURE_CHANGE (unknown step type): ควร warn และไม่ crash', async () => {
const result = await runStep(
{ type: 'STRUCTURE_CHANGE', config: {} },
{ key: 'value' }
);
expect(result['key']).toBe('value'); // ไม่ถูกแตะ
});
});
});
@@ -0,0 +1,200 @@
// File: src/modules/json-schema/services/ui-schema.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ UiSchemaService
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */
import { Test, TestingModule } from '@nestjs/testing';
import { UiSchemaService } from './ui-schema.service';
import { UiSchema } from '../interfaces/ui-schema.interface';
// Helper สร้าง UiSchema ที่ valid
const makeValidUiSchema = (): UiSchema => ({
layout: {
type: 'stack',
groups: [
{
id: 'g1',
title: 'General',
type: 'section',
fields: ['title', 'status'],
},
],
},
fields: {
title: { type: 'string', title: 'Title', widget: 'text' },
status: {
type: 'string',
title: 'Status',
widget: 'select',
enum: ['DRAFT', 'SUBMITTED'],
},
},
});
describe('UiSchemaService', () => {
let service: UiSchemaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UiSchemaService],
}).compile();
service = module.get<UiSchemaService>(UiSchemaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('validateUiSchema', () => {
it('ควรคืน true เมื่อ uiSchema เป็น null/undefined (Optional)', () => {
expect(service.validateUiSchema(null as any, {})).toBe(true);
});
it('ควรคืน true เมื่อ UI Schema ถูกต้อง', () => {
const result = service.validateUiSchema(makeValidUiSchema(), {
properties: { title: { type: 'string' }, status: { type: 'string' } },
});
expect(result).toBe(true);
});
it('ควร throw ValidationException เมื่อขาด layout', () => {
const badSchema = {
fields: { title: { type: 'string', title: 'Title' } },
} as unknown as UiSchema;
// ValidationException expose เฉพาะ class — เช็ค class instance
expect(() => service.validateUiSchema(badSchema, {})).toThrow(
/Validation/
);
});
it('ควร throw ValidationException เมื่อขาด fields', () => {
const badSchema = {
layout: { type: 'stack', groups: [] },
} as unknown as UiSchema;
expect(() => service.validateUiSchema(badSchema, {})).toThrow(
/Validation/
);
});
it('ควร throw ValidationException เมื่อ field ใน layout ไม่มีนิยามใน fields', () => {
const schema: UiSchema = {
layout: {
type: 'stack',
groups: [
{
id: 'g1',
title: 'G',
type: 'section',
fields: ['missing_field'],
},
],
},
fields: {},
};
// ValidationException expose class message — เช็ค class instance
expect(() => service.validateUiSchema(schema, {})).toThrow(/Validation/);
});
it('ควรไม่ throw แม้ dataSchema มี field ที่ไม่มีใน UI Schema (warn เฉยๆ)', () => {
const result = service.validateUiSchema(makeValidUiSchema(), {
properties: {
title: { type: 'string' },
status: { type: 'string' },
extra_field_not_in_ui: { type: 'string' }, // ไม่ throw
},
});
expect(result).toBe(true);
});
it('ควรคืน true เมื่อ dataSchema ไม่มี properties', () => {
const result = service.validateUiSchema(makeValidUiSchema(), {});
expect(result).toBe(true);
});
});
describe('generateDefaultUiSchema', () => {
it('ควรสร้าง UI Schema พื้นฐานจาก dataSchema', () => {
const dataSchema = {
properties: {
title: { type: 'string', title: 'Document Title' },
dueDate: { type: 'string', format: 'date' },
isPublic: { type: 'boolean' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
},
required: ['title'],
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.layout.groups).toHaveLength(1);
expect(result.layout.groups[0].title).toBe('General Information');
expect(Object.keys(result.fields)).toHaveLength(4);
// Widget guessing
expect(result.fields['dueDate'].widget).toBe('date');
expect(result.fields['isPublic'].widget).toBe('checkbox');
expect(result.fields['priority'].widget).toBe('select');
expect(result.fields['title'].widget).toBe('text');
});
it('ควรกำหนด required=true สำหรับ field ที่อยู่ใน required array', () => {
const dataSchema = {
properties: { title: { type: 'string' }, note: { type: 'string' } },
required: ['title'],
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['title'].required).toBe(true);
expect(result.fields['note'].required).toBe(false);
});
it('ควรคืน empty schema เมื่อ dataSchema ไม่มี properties', () => {
const result = service.generateDefaultUiSchema({});
expect(result.layout.groups).toHaveLength(0);
expect(result.fields).toEqual({});
});
it('ควรคืน empty schema เมื่อ dataSchema เป็น null/undefined', () => {
const result = service.generateDefaultUiSchema(null as any);
expect(result.fields).toEqual({});
});
it('ควร guess widget=datetime สำหรับ format=date-time', () => {
const dataSchema = {
properties: { createdAt: { type: 'string', format: 'date-time' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['createdAt'].widget).toBe('datetime');
});
it('ควร guess widget=file-upload สำหรับ format=binary', () => {
const dataSchema = {
properties: { attachment: { type: 'string', format: 'binary' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['attachment'].widget).toBe('file-upload');
});
it('ควร humanize field name สำหรับ camelCase', () => {
const dataSchema = {
properties: { documentTitle: { type: 'string' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
// humanize: "documentTitle" → "Document Title"
expect(result.fields['documentTitle'].title).toBe('Document Title');
});
it('ควรใช้ title จาก property ถ้ามี', () => {
const dataSchema = {
properties: { myField: { type: 'string', title: 'My Custom Title' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['myField'].title).toBe('My Custom Title');
});
it('ควรกำหนด colSpan=12 เป็น default', () => {
const dataSchema = {
properties: { note: { type: 'string' } },
};
const result = service.generateDefaultUiSchema(dataSchema);
expect(result.fields['note'].colSpan).toBe(12);
});
});
});
@@ -0,0 +1,170 @@
// File: src/modules/json-schema/services/virtual-column.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ VirtualColumnService
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { VirtualColumnService } from './virtual-column.service';
import { VirtualColumnConfig } from '../entities/json-schema.entity';
// Helper สร้าง mock QueryRunner
const makeQueryRunner = (tableExists = true, hasColumn = false) => ({
connect: jest.fn().mockResolvedValue(undefined),
release: jest.fn().mockResolvedValue(undefined),
hasTable: jest.fn().mockResolvedValue(tableExists),
hasColumn: jest.fn().mockResolvedValue(hasColumn),
query: jest.fn().mockResolvedValue([{ count: 0 }]),
});
const makeDataSource = (qr: ReturnType<typeof makeQueryRunner>) =>
({
createQueryRunner: jest.fn().mockReturnValue(qr),
}) as unknown as DataSource;
const baseConfig: VirtualColumnConfig = {
columnName: 'vc_discipline_code',
jsonPath: '$.disciplineCode',
dataType: 'VARCHAR',
indexType: undefined,
};
describe('VirtualColumnService', () => {
let service: VirtualColumnService;
const buildService = async (ds: DataSource) => {
const module: TestingModule = await Test.createTestingModule({
providers: [VirtualColumnService, { provide: DataSource, useValue: ds }],
}).compile();
return module.get<VirtualColumnService>(VirtualColumnService);
};
describe('setupVirtualColumns', () => {
it('ควร return ทันทีเมื่อ configs ว่าง', async () => {
const qr = makeQueryRunner();
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', []);
expect(qr.connect).not.toHaveBeenCalled();
});
it('ควร return ทันทีเมื่อ configs เป็น null/undefined', async () => {
const qr = makeQueryRunner();
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', null as any);
expect(qr.connect).not.toHaveBeenCalled();
});
it('ควร skip เมื่อ table ไม่มีอยู่ใน DB', async () => {
const qr = makeQueryRunner(false); // tableExists=false
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('nonexistent_table', [baseConfig]);
expect(qr.hasTable).toHaveBeenCalledWith('nonexistent_table');
expect(qr.hasColumn).not.toHaveBeenCalled();
expect(qr.release).toHaveBeenCalled();
});
it('ควรสร้าง virtual column เมื่อ column ยังไม่มี', async () => {
const qr = makeQueryRunner(true, false); // column ยังไม่มี
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [baseConfig]);
// ควรเรียก query สร้าง column
expect(qr.query).toHaveBeenCalledWith(
expect.stringContaining('ADD COLUMN vc_discipline_code')
);
expect(qr.release).toHaveBeenCalled();
});
it('ควรไม่สร้าง column ซ้ำเมื่อ column มีอยู่แล้ว', async () => {
const qr = makeQueryRunner(true, true); // column มีอยู่แล้ว
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [baseConfig]);
// query ถูกเรียก 0 ครั้ง (ไม่สร้างซ้ำ)
expect(qr.query).not.toHaveBeenCalled();
});
it('ควรสร้าง index เมื่อ config มี indexType และ index ยังไม่มี', async () => {
const qr = makeQueryRunner(true, false);
qr.query
.mockResolvedValueOnce(undefined) // ADD COLUMN
.mockResolvedValueOnce([{ count: 0 }]) // check index — ยังไม่มี
.mockResolvedValueOnce(undefined); // CREATE INDEX
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [
{ ...baseConfig, indexType: 'BTREE' },
]);
// ควรเรียก query 3 ครั้ง: ADD COLUMN, check index, CREATE INDEX
expect(qr.query).toHaveBeenCalledTimes(3);
const lastCall = qr.query.mock.calls[2][0] as string;
expect(lastCall).toContain('CREATE');
expect(lastCall).toContain('INDEX');
});
it('ควรสร้าง UNIQUE index เมื่อ indexType=UNIQUE', async () => {
const qr = makeQueryRunner(true, false);
qr.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce(undefined);
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [
{ ...baseConfig, indexType: 'UNIQUE' },
]);
const indexCall = qr.query.mock.calls[2][0] as string;
expect(indexCall).toContain('UNIQUE');
});
it('ควรไม่สร้าง index ซ้ำเมื่อ index มีอยู่แล้ว', async () => {
const qr = makeQueryRunner(true, false);
qr.query
.mockResolvedValueOnce(undefined) // ADD COLUMN
.mockResolvedValueOnce([{ count: 1 }]); // index มีอยู่แล้ว
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('rfa_revisions', [
{ ...baseConfig, indexType: 'BTREE' },
]);
// ไม่ควรเรียก CREATE INDEX
expect(qr.query).toHaveBeenCalledTimes(2);
});
it('ควร release queryRunner แม้จะ throw error', async () => {
const qr = makeQueryRunner(true, false);
qr.query.mockRejectedValueOnce(new Error('DB Error'));
service = await buildService(makeDataSource(qr));
await expect(
service.setupVirtualColumns('rfa_revisions', [baseConfig])
).rejects.toThrow('DB Error');
expect(qr.release).toHaveBeenCalled();
});
});
describe('SQL generation — data type mapping', () => {
const dataTypes: Array<[string, string]> = [
['INT', 'INT'],
['VARCHAR', 'VARCHAR(255)'],
['BOOLEAN', 'TINYINT(1)'],
['DATE', 'DATE'],
['DATETIME', 'DATETIME'],
['DECIMAL', 'DECIMAL(10,2)'],
['UNKNOWN_TYPE', 'VARCHAR(255)'], // default fallback
];
for (const [input, expected] of dataTypes) {
it(`ควร map dataType=${input} เป็น SQL type ${expected}`, async () => {
const qr = makeQueryRunner(true, false);
service = await buildService(makeDataSource(qr));
await service.setupVirtualColumns('t', [
{
columnName: 'col',
jsonPath: '$.x',
dataType: input,
indexType: undefined,
},
]);
const addColSql = qr.query.mock.calls[0][0] as string;
expect(addColSql).toContain(expected);
});
}
});
});
@@ -0,0 +1,44 @@
// File: src/modules/monitoring/services/metrics.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ MetricsService
import { Test, TestingModule } from '@nestjs/testing';
import { MetricsService } from './metrics.service';
import { getToken } from '@willsoto/nestjs-prometheus';
describe('MetricsService', () => {
let service: MetricsService;
const mockCounter = {
inc: jest.fn(),
};
const mockHistogram = {
observe: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MetricsService,
{
provide: getToken('http_requests_total'),
useValue: mockCounter,
},
{
provide: getToken('http_request_duration_seconds'),
useValue: mockHistogram,
},
],
}).compile();
service = module.get<MetricsService>(MetricsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
expect(service.httpRequestsTotal).toBeDefined();
expect(service.httpRequestDuration).toBeDefined();
});
});
@@ -0,0 +1,278 @@
// File: src/modules/reminder/services/escalation.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ EscalationService (FR-015, FR-016)
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { EscalationService } from './escalation.service';
import { ReviewTask } from '../../review-team/entities/review-task.entity';
import {
ReviewTaskStatus,
ReminderType,
} from '../../common/enums/review.enums';
import { NotificationService } from '../../notification/notification.service';
import { ReminderRule } from '../entities/reminder-rule.entity';
import { ReminderHistory } from '../entities/reminder-history.entity';
import { UserAssignment } from '../../user/entities/user-assignment.entity';
// Helper สร้าง mock ReviewTask
const makeTask = (
publicId: string,
status: ReviewTaskStatus = ReviewTaskStatus.IN_PROGRESS,
assignedToUserId = 10
): Partial<ReviewTask> => ({
id: 1,
publicId,
status,
assignedToUserId,
assignedToUser: {
firstName: 'John',
lastName: 'Doe',
} as ReviewTask['assignedToUser'],
discipline: { codeNameEn: 'STRUC' } as ReviewTask['discipline'],
team: {} as ReviewTask['team'],
dueDate: new Date('2026-01-01'),
});
describe('EscalationService', () => {
let service: EscalationService;
let mockTaskRepo: any;
let mockReminderRuleRepo: any;
let mockHistoryRepo: any;
let mockAssignmentRepo: any;
let mockNotificationService: any;
beforeEach(async () => {
mockTaskRepo = { findOne: jest.fn(), find: jest.fn() };
mockReminderRuleRepo = { find: jest.fn() };
mockHistoryRepo = {
create: jest.fn(),
save: jest.fn().mockResolvedValue({}),
count: jest.fn(),
findOne: jest.fn(),
};
mockAssignmentRepo = { findOne: jest.fn() };
mockNotificationService = { send: jest.fn().mockResolvedValue(undefined) };
const module: TestingModule = await Test.createTestingModule({
providers: [
EscalationService,
{ provide: getRepositoryToken(ReviewTask), useValue: mockTaskRepo },
{
provide: getRepositoryToken(ReminderRule),
useValue: mockReminderRuleRepo,
},
{
provide: getRepositoryToken(ReminderHistory),
useValue: mockHistoryRepo,
},
{
provide: getRepositoryToken(UserAssignment),
useValue: mockAssignmentRepo,
},
{ provide: NotificationService, useValue: mockNotificationService },
],
}).compile();
service = module.get<EscalationService>(EscalationService);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('recordHistory', () => {
it('ควรบันทึก ReminderHistory', async () => {
const task = makeTask('task-001') as ReviewTask;
const historyRecord = {
taskId: 1,
userId: 10,
reminderType: ReminderType.ESCALATION_L1,
escalationLevel: 1,
};
mockHistoryRepo.create.mockReturnValueOnce(historyRecord);
await service.recordHistory(task, ReminderType.ESCALATION_L1, 1);
expect(mockHistoryRepo.save).toHaveBeenCalledWith(historyRecord);
});
});
describe('getStrikeCount', () => {
it('ควรนับจำนวน history ตาม taskId และ level', async () => {
mockHistoryRepo.count.mockResolvedValueOnce(2);
const count = await service.getStrikeCount(1, 1);
expect(count).toBe(2);
expect(mockHistoryRepo.count).toHaveBeenCalledWith({
where: { taskId: 1, escalationLevel: 1 },
});
});
});
describe('escalateLevel1', () => {
it('ควร return ทันทีเมื่อ task ไม่พบ', async () => {
mockTaskRepo.findOne.mockResolvedValueOnce(null);
await service.escalateLevel1('task-none');
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควร return ทันทีเมื่อ task.status=COMPLETED', async () => {
mockTaskRepo.findOne.mockResolvedValueOnce(
makeTask('task-done', ReviewTaskStatus.COMPLETED)
);
await service.escalateLevel1('task-done');
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควร escalate ไปยัง L2 เมื่อ strikes >= 3', async () => {
mockTaskRepo.findOne
.mockResolvedValueOnce(makeTask('task-001')) // L1 call
.mockResolvedValueOnce(makeTask('task-001')); // L2 call
mockHistoryRepo.count
.mockResolvedValueOnce(3) // L1 strikes = 3 → go to L2
.mockResolvedValueOnce(0); // L2 strikes
mockAssignmentRepo.findOne.mockResolvedValueOnce(null); // PM ไม่พบ
mockHistoryRepo.create.mockReturnValue({});
await service.escalateLevel1('task-001');
// L2 ถูกเรียก → notification ถูกส่งถึง assignedToUser
expect(mockNotificationService.send).toHaveBeenCalledTimes(1);
});
it('ควรส่ง L1 notification และบันทึก history เมื่อ strikes < 3', async () => {
mockTaskRepo.findOne.mockResolvedValueOnce(makeTask('task-001'));
mockHistoryRepo.count.mockResolvedValueOnce(1); // strikes=1
mockHistoryRepo.create.mockReturnValueOnce({});
await service.escalateLevel1('task-001');
expect(mockNotificationService.send).toHaveBeenCalledWith(
expect.objectContaining({
userId: 10,
type: 'SYSTEM',
entityType: 'review_task',
})
);
expect(mockHistoryRepo.save).toHaveBeenCalled();
});
it('ควรไม่ส่ง notification เมื่อ assignedToUserId เป็น null', async () => {
const task = makeTask('task-001', ReviewTaskStatus.IN_PROGRESS);
task.assignedToUserId = null as any;
mockTaskRepo.findOne.mockResolvedValueOnce(task);
mockHistoryRepo.count.mockResolvedValueOnce(0);
await service.escalateLevel1('task-001');
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
});
describe('escalateLevel2', () => {
it('ควร return ทันทีเมื่อ task ไม่พบ', async () => {
mockTaskRepo.findOne.mockResolvedValueOnce(null);
await service.escalateLevel2('task-none');
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควร return ทันทีเมื่อ task.status=COMPLETED', async () => {
mockTaskRepo.findOne.mockResolvedValueOnce(
makeTask('task-done', ReviewTaskStatus.COMPLETED)
);
await service.escalateLevel2('task-done');
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควรส่งแจ้งเตือนถึง PM และ assignee (2 calls)', async () => {
mockTaskRepo.findOne
.mockResolvedValueOnce(makeTask('task-001'))
.mockResolvedValueOnce({
// full task with relations
rfaRevision: {
correspondenceRevision: {
correspondence: { projectId: 5 },
},
},
});
mockHistoryRepo.count.mockResolvedValueOnce(0);
mockAssignmentRepo.findOne.mockResolvedValueOnce({ userId: 99 }); // PM
mockHistoryRepo.create.mockReturnValue({});
await service.escalateLevel2('task-001');
expect(mockNotificationService.send).toHaveBeenCalledTimes(2); // PM + assignee
const calls = mockNotificationService.send.mock.calls;
expect(calls[0][0].userId).toBe(99); // PM
expect(calls[1][0].userId).toBe(10); // assignee
});
it('ควรส่งแจ้งเตือนถึง assignee เท่านั้น เมื่อหา PM ไม่เจอ', async () => {
mockTaskRepo.findOne
.mockResolvedValueOnce(makeTask('task-002'))
.mockResolvedValueOnce({ rfaRevision: null }); // ไม่มี correspondence
mockHistoryRepo.count.mockResolvedValueOnce(0);
mockAssignmentRepo.findOne.mockResolvedValueOnce(null); // PM ไม่พบ
mockHistoryRepo.create.mockReturnValue({});
await service.escalateLevel2('task-002');
expect(mockNotificationService.send).toHaveBeenCalledTimes(1); // assignee only
});
});
describe('processOverdueTasks', () => {
it('ควรไม่ทำอะไรเมื่อไม่มี overdue tasks', async () => {
mockTaskRepo.find.mockResolvedValueOnce([]);
await service.processOverdueTasks();
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควร escalateLevel1 เมื่อ task ไม่มี history ก่อนหน้า', async () => {
const task = makeTask('task-new');
mockTaskRepo.find.mockResolvedValueOnce([task]);
mockHistoryRepo.findOne.mockResolvedValueOnce(null); // ไม่มี history
// escalateLevel1 mock:
mockTaskRepo.findOne.mockResolvedValueOnce(task);
mockHistoryRepo.count.mockResolvedValueOnce(0);
mockHistoryRepo.create.mockReturnValue({});
await service.processOverdueTasks();
expect(mockNotificationService.send).toHaveBeenCalledTimes(1);
});
it('ควร escalateLevel2 เมื่อ last history level=1 และ strikes >= 3', async () => {
const task = makeTask('task-l2');
mockTaskRepo.find.mockResolvedValueOnce([task]);
mockHistoryRepo.findOne.mockResolvedValueOnce({ escalationLevel: 1 });
mockHistoryRepo.count.mockResolvedValueOnce(3); // strikes for level 1 = 3
// escalateLevel2 mock:
mockTaskRepo.findOne
.mockResolvedValueOnce(task)
.mockResolvedValueOnce({ rfaRevision: null });
mockHistoryRepo.count.mockResolvedValueOnce(0); // L2 strikes
mockAssignmentRepo.findOne.mockResolvedValueOnce(null);
mockHistoryRepo.create.mockReturnValue({});
await service.processOverdueTasks();
// assignee notification
expect(mockNotificationService.send).toHaveBeenCalledTimes(1);
});
it('ควร re-escalateLevel1 เมื่อ last history level=1 แต่ strikes < 3', async () => {
const task = makeTask('task-l1-again');
mockTaskRepo.find.mockResolvedValueOnce([task]);
mockHistoryRepo.findOne.mockResolvedValueOnce({ escalationLevel: 1 });
mockHistoryRepo.count.mockResolvedValueOnce(1); // strikes=1 < 3
mockTaskRepo.findOne.mockResolvedValueOnce(task);
mockHistoryRepo.count.mockResolvedValueOnce(1); // L1 strikes again
mockHistoryRepo.create.mockReturnValue({});
await service.processOverdueTasks();
expect(mockNotificationService.send).toHaveBeenCalledTimes(1); // L1 notification
});
it('ควร escalateLevel2 รายวัน เมื่อ last history level=2', async () => {
const task = makeTask('task-daily-l2');
mockTaskRepo.find.mockResolvedValueOnce([task]);
mockHistoryRepo.findOne.mockResolvedValueOnce({ escalationLevel: 2 });
// escalateLevel2:
mockTaskRepo.findOne
.mockResolvedValueOnce(task)
.mockResolvedValueOnce({ rfaRevision: null });
mockHistoryRepo.count.mockResolvedValueOnce(2); // L2 strike
mockAssignmentRepo.findOne.mockResolvedValueOnce(null);
mockHistoryRepo.create.mockReturnValue({});
await service.processOverdueTasks();
expect(mockNotificationService.send).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,206 @@
// File: src/modules/reminder/services/scheduler.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ SchedulerService (FR-013)
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getQueueToken } from '@nestjs/bullmq';
import { SchedulerService, ScheduleReminderPayload } from './scheduler.service';
import { ReminderRule } from '../entities/reminder-rule.entity';
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
import { ReminderType } from '../../common/enums/review.enums';
// Helper สร้าง mock Job
const makeJob = (taskPublicId: string) => ({
data: { taskPublicId },
remove: jest.fn().mockResolvedValue(undefined),
});
// Helper สร้าง payload
const makePayload = (daysUntilDue = 5): ScheduleReminderPayload => {
const dueDate = new Date(Date.now() + daysUntilDue * 24 * 60 * 60 * 1000);
return {
taskPublicId: 'task-001',
rfaPublicId: 'rfa-001',
assigneeUserId: 10,
dueDate,
reminderType: ReminderType.DUE_SOON,
projectId: 1,
documentTypeCode: 'SHOP_DRAWING',
};
};
describe('SchedulerService', () => {
let service: SchedulerService;
const mockReminderQueue = {
add: jest.fn().mockResolvedValue({}),
getDelayed: jest.fn().mockResolvedValue([]),
};
const mockRuleRepo = { find: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulerService,
{
provide: getQueueToken(QUEUE_REMINDERS),
useValue: mockReminderQueue,
},
{
provide: getRepositoryToken(ReminderRule),
useValue: mockRuleRepo,
},
],
}).compile();
service = module.get<SchedulerService>(SchedulerService);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('scheduleForTask', () => {
it('ควร return ทันทีเมื่อไม่มี ReminderRule ที่ match', async () => {
mockRuleRepo.find.mockResolvedValueOnce([]);
await service.scheduleForTask(makePayload());
expect(mockReminderQueue.add).not.toHaveBeenCalled();
});
it('ควรเพิ่ม job ลง queue ตาม rules ที่พบ', async () => {
const rules = [
{
id: 1,
daysBeforeDue: 3,
reminderType: ReminderType.DUE_SOON,
isActive: true,
},
{
id: 2,
daysBeforeDue: 1,
reminderType: ReminderType.ON_DUE,
isActive: true,
},
];
mockRuleRepo.find.mockResolvedValueOnce(rules);
const payload = makePayload(5); // 5 วันจนถึง due date
await service.scheduleForTask(payload);
expect(mockReminderQueue.add).toHaveBeenCalledTimes(2);
});
it('ควร skip rule ที่ trigger time ผ่านไปแล้วและไม่ใช่ OVERDUE', async () => {
// payload due date อยู่ใน 1 วัน แต่ rule บอก 3 วันก่อน (ผ่านไปแล้ว)
const rules = [
{
id: 1,
daysBeforeDue: 3,
reminderType: ReminderType.DUE_SOON,
isActive: true,
},
];
mockRuleRepo.find.mockResolvedValueOnce(rules);
const payload = makePayload(1); // due in 1 day → 3 days ago trigger has passed
await service.scheduleForTask(payload);
expect(mockReminderQueue.add).not.toHaveBeenCalled();
});
it('ควรไม่ skip OVERDUE rule แม้ trigger time ผ่านไปแล้ว (delay=0)', async () => {
const rules = [
{
id: 1,
daysBeforeDue: 0,
reminderType: ReminderType.OVERDUE,
isActive: true,
},
];
mockRuleRepo.find.mockResolvedValueOnce(rules);
const pastDuePayload: ScheduleReminderPayload = {
...makePayload(0),
dueDate: new Date(Date.now() - 24 * 60 * 60 * 1000), // ผ่าน due date ไปแล้ว
};
await service.scheduleForTask(pastDuePayload);
expect(mockReminderQueue.add).toHaveBeenCalledTimes(1);
expect(mockReminderQueue.add).toHaveBeenCalledWith(
'send-reminder',
expect.objectContaining({ reminderType: ReminderType.OVERDUE }),
expect.objectContaining({ delay: 0 }) // Math.max(-x, 0) = 0
);
});
it('ควรตั้งค่า jobId ให้ unique ต่อ task + type + rule', async () => {
const rules = [
{
id: 1,
daysBeforeDue: 2,
reminderType: ReminderType.DUE_SOON,
isActive: true,
},
];
mockRuleRepo.find.mockResolvedValueOnce(rules);
await service.scheduleForTask(makePayload(5));
const addCall = mockReminderQueue.add.mock.calls[0];
const options = addCall[2] as { jobId: string };
expect(options.jobId).toBe(`task-001-${ReminderType.DUE_SOON}-1`);
});
it('ควรตั้งค่า removeOnComplete=true บน job', async () => {
const rules = [
{
id: 1,
daysBeforeDue: 2,
reminderType: ReminderType.DUE_SOON,
isActive: true,
},
];
mockRuleRepo.find.mockResolvedValueOnce(rules);
await service.scheduleForTask(makePayload(5));
const options = mockReminderQueue.add.mock.calls[0][2];
expect(options.removeOnComplete).toBe(true);
});
it('ควรส่ง payload ที่มี reminderType ถูกต้องตาม rule', async () => {
const rules = [
{
id: 1,
daysBeforeDue: 2,
reminderType: ReminderType.ESCALATION_L1,
isActive: true,
},
];
mockRuleRepo.find.mockResolvedValueOnce(rules);
const payload = makePayload(5);
await service.scheduleForTask(payload);
const jobData = mockReminderQueue.add.mock.calls[0][1];
expect(jobData.reminderType).toBe(ReminderType.ESCALATION_L1);
expect(jobData.taskPublicId).toBe('task-001');
});
});
describe('cancelForTask', () => {
it('ควร remove jobs ที่ตรงกับ taskPublicId', async () => {
const job1 = makeJob('task-001');
const job2 = makeJob('task-001');
const job3 = makeJob('task-002'); // ต่าง task
mockReminderQueue.getDelayed.mockResolvedValueOnce([job1, job2, job3]);
await service.cancelForTask('task-001');
expect(job1.remove).toHaveBeenCalled();
expect(job2.remove).toHaveBeenCalled();
expect(job3.remove).not.toHaveBeenCalled();
});
it('ควรไม่ error เมื่อไม่มี delayed jobs', async () => {
mockReminderQueue.getDelayed.mockResolvedValueOnce([]);
await expect(service.cancelForTask('task-999')).resolves.not.toThrow();
});
it('ควรไม่ remove job ของ task อื่น', async () => {
const otherJob = makeJob('task-XYZ');
mockReminderQueue.getDelayed.mockResolvedValueOnce([otherJob]);
await service.cancelForTask('task-001');
expect(otherJob.remove).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,122 @@
// File: src/modules/response-code/services/audit.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ ResponseCodeAuditService
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ResponseCodeAuditService } from './audit.service';
import { AuditLog } from '../../../common/entities/audit-log.entity';
describe('ResponseCodeAuditService', () => {
let service: ResponseCodeAuditService;
const mockAuditLog: Partial<AuditLog> = {
userId: 1,
action: 'response_code.change',
severity: 'INFO',
entityType: 'review_task',
entityId: 'task-uuid-001',
detailsJson: {},
};
const mockAuditLogRepo = {
create: jest.fn().mockReturnValue(mockAuditLog),
save: jest.fn().mockResolvedValue(mockAuditLog),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ResponseCodeAuditService,
{
provide: getRepositoryToken(AuditLog),
useValue: mockAuditLogRepo,
},
],
}).compile();
service = module.get<ResponseCodeAuditService>(ResponseCodeAuditService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('logReviewTaskResponseCodeChange', () => {
it('ควรบันทึก audit log พร้อมข้อมูลครบถ้วน (Happy Path)', async () => {
await service.logReviewTaskResponseCodeChange({
reviewTaskPublicId: 'task-uuid-001',
responseCodePublicId: 'rc-uuid-001',
previousResponseCodeId: 1,
currentResponseCodeId: 2,
comments: 'Changed from 1A to 2',
userId: 10,
});
expect(mockAuditLogRepo.create).toHaveBeenCalledWith({
userId: 10,
action: 'response_code.change',
severity: 'INFO',
entityType: 'review_task',
entityId: 'task-uuid-001',
detailsJson: {
previousResponseCodeId: 1,
currentResponseCodeId: 2,
responseCodePublicId: 'rc-uuid-001',
comments: 'Changed from 1A to 2',
},
});
expect(mockAuditLogRepo.save).toHaveBeenCalledTimes(1);
});
it('ควร default userId เป็น null เมื่อไม่ระบุ', async () => {
await service.logReviewTaskResponseCodeChange({
reviewTaskPublicId: 'task-uuid-002',
responseCodePublicId: 'rc-uuid-002',
currentResponseCodeId: 3,
});
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: null })
);
});
it('ควร default previousResponseCodeId เป็น null เมื่อไม่ระบุ', async () => {
await service.logReviewTaskResponseCodeChange({
reviewTaskPublicId: 'task-uuid-003',
responseCodePublicId: 'rc-uuid-003',
currentResponseCodeId: 1,
});
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
detailsJson: expect.objectContaining({
previousResponseCodeId: null,
}),
})
);
});
it('ควร default comments เป็น null เมื่อไม่ระบุ', async () => {
await service.logReviewTaskResponseCodeChange({
reviewTaskPublicId: 'task-uuid-004',
responseCodePublicId: 'rc-uuid-004',
currentResponseCodeId: 2,
});
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
detailsJson: expect.objectContaining({ comments: null }),
})
);
});
it('ควร throw เมื่อ repo.save ล้มเหลว', async () => {
mockAuditLogRepo.save.mockRejectedValueOnce(new Error('DB Error'));
await expect(
service.logReviewTaskResponseCodeChange({
reviewTaskPublicId: 'task-uuid-005',
responseCodePublicId: 'rc-uuid-005',
currentResponseCodeId: 1,
})
).rejects.toThrow('DB Error');
});
});
});
@@ -0,0 +1,180 @@
// File: src/modules/response-code/services/implications.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ ImplicationsService (FR-007)
import { Test, TestingModule } from '@nestjs/testing';
import { ImplicationsService } from './implications.service';
import { ResponseCode } from '../entities/response-code.entity';
// Helper สร้าง mock ResponseCode
const makeCode = (
code: string,
overrides: Partial<ResponseCode> = {}
): ResponseCode =>
({
id: 1,
code,
descriptionTh: 'ทดสอบ',
descriptionEn: 'Test',
category: 'ENGINEERING',
isSystem: false,
isActive: true,
implications: {},
notifyRoles: [],
...overrides,
}) as unknown as ResponseCode;
describe('ImplicationsService', () => {
let service: ImplicationsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ImplicationsService],
}).compile();
service = module.get<ImplicationsService>(ImplicationsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('evaluate — severity', () => {
it('ควรคืน CRITICAL เมื่อ code=3 (Rejected)', () => {
const result = service.evaluate(makeCode('3'));
expect(result.severity).toBe('CRITICAL');
});
it('ควรคืน HIGH เมื่อ code=1C', () => {
const result = service.evaluate(makeCode('1C'));
expect(result.severity).toBe('HIGH');
});
it('ควรคืน HIGH เมื่อ code=1D', () => {
const result = service.evaluate(makeCode('1D'));
expect(result.severity).toBe('HIGH');
});
it('ควรคืน HIGH เมื่อ affectsSchedule=true และ affectsCost=true', () => {
const result = service.evaluate(
makeCode('2', {
implications: { affectsSchedule: true, affectsCost: true },
} as Partial<ResponseCode>)
);
expect(result.severity).toBe('HIGH');
});
it('ควรคืน MEDIUM เมื่อ requiresContractReview=true', () => {
const result = service.evaluate(
makeCode('1B', {
implications: { requiresContractReview: true },
} as Partial<ResponseCode>)
);
expect(result.severity).toBe('MEDIUM');
});
it('ควรคืน MEDIUM เมื่อ affectsSchedule=true', () => {
const result = service.evaluate(
makeCode('1B', {
implications: { affectsSchedule: true },
} as Partial<ResponseCode>)
);
expect(result.severity).toBe('MEDIUM');
});
it('ควรคืน MEDIUM เมื่อ affectsCost=true', () => {
const result = service.evaluate(
makeCode('1B', {
implications: { affectsCost: true },
} as Partial<ResponseCode>)
);
expect(result.severity).toBe('MEDIUM');
});
it('ควรคืน LOW เมื่อไม่มีผลกระทบใดๆ', () => {
const result = service.evaluate(makeCode('1A'));
expect(result.severity).toBe('LOW');
});
});
describe('evaluate — actionRequired', () => {
it('ควรเพิ่ม action สำหรับ code=3', () => {
const result = service.evaluate(makeCode('3'));
expect(result.actionRequired).toContain(
'Document rejected — originator must revise and resubmit'
);
});
it('ควรเพิ่ม action สำหรับ requiresContractReview', () => {
const result = service.evaluate(
makeCode('1C', {
implications: { requiresContractReview: true },
} as Partial<ResponseCode>)
);
expect(result.actionRequired).toContain(
'Contract review required — notify Contract Manager'
);
});
it('ควรเพิ่ม action สำหรับ affectsCost', () => {
const result = service.evaluate(
makeCode('1B', {
implications: { affectsCost: true },
} as Partial<ResponseCode>)
);
expect(result.actionRequired).toContain(
'Cost impact assessment required — notify QS Manager'
);
});
it('ควรเพิ่ม action สำหรับ requiresEiaAmendment', () => {
const result = service.evaluate(
makeCode('1B', {
implications: { requiresEiaAmendment: true },
} as Partial<ResponseCode>)
);
expect(result.actionRequired).toContain(
'EIA amendment may be required — notify EIA Officer'
);
});
it('ควรเพิ่ม action สำหรับ code=2', () => {
const result = service.evaluate(makeCode('2'));
expect(result.actionRequired).toContain(
'Minor comments — originator to revise and resubmit'
);
});
it('ควรคืน actionRequired ว่างเมื่อ code=1A ไม่มี implications', () => {
const result = service.evaluate(makeCode('1A'));
expect(result.actionRequired).toHaveLength(0);
});
});
describe('evaluate — flags', () => {
it('ควรคืน affectsSchedule=true จาก implications', () => {
const result = service.evaluate(
makeCode('1B', {
implications: { affectsSchedule: true },
} as Partial<ResponseCode>)
);
expect(result.affectsSchedule).toBe(true);
});
it('ควร default ทุก flag เป็น false เมื่อ implications ว่าง', () => {
const result = service.evaluate(makeCode('1A'));
expect(result.affectsSchedule).toBe(false);
expect(result.affectsCost).toBe(false);
expect(result.requiresContractReview).toBe(false);
expect(result.requiresEiaAmendment).toBe(false);
});
it('ควรคืน notifyRoles จาก responseCode', () => {
const result = service.evaluate(
makeCode('3', {
notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER'],
} as Partial<ResponseCode>)
);
expect(result.notifyRoles).toEqual(['CONTRACT_MANAGER', 'QS_MANAGER']);
});
});
});
@@ -0,0 +1,122 @@
// File: src/modules/response-code/services/inheritance.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ InheritanceService (T062, FR-021)
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { InheritanceService } from './inheritance.service';
import { ResponseCodeRule } from '../entities/response-code-rule.entity';
// Helper สร้าง mock rule
const makeRule = (
id: number,
responseCodeId: number,
publicId: string,
projectId?: number,
overrides: Record<string, unknown> = {}
): Partial<ResponseCodeRule> => ({
id,
responseCodeId,
documentTypeId: 1,
projectId,
isEnabled: true,
requiresComments: false,
triggersNotification: false,
responseCode: { publicId } as unknown as ResponseCodeRule['responseCode'],
...overrides,
});
describe('InheritanceService', () => {
let service: InheritanceService;
const mockRuleRepo = {
find: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
InheritanceService,
{
provide: getRepositoryToken(ResponseCodeRule),
useValue: mockRuleRepo,
},
],
}).compile();
service = module.get<InheritanceService>(InheritanceService);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('resolveMatrix — global only', () => {
it('ควรคืน global rules เมื่อไม่ระบุ projectId', async () => {
const globalRules = [makeRule(1, 10, 'rc-1A'), makeRule(2, 20, 'rc-2')];
mockRuleRepo.find.mockResolvedValueOnce(globalRules);
const result = await service.resolveMatrix(1);
expect(result).toHaveLength(2);
expect(result[0].isOverridden).toBe(false);
expect(result[0].responseCodePublicId).toBe('rc-1A');
});
it('ควรคืน array ว่างเมื่อไม่มี global rules', async () => {
mockRuleRepo.find.mockResolvedValueOnce([]);
const result = await service.resolveMatrix(99);
expect(result).toHaveLength(0);
});
});
describe('resolveMatrix — with project overrides', () => {
it('ควร merge: project rule ชนะ global rule ของ responseCode เดียวกัน', async () => {
const globalRules = [makeRule(1, 10, 'rc-1A')];
const projectRules = [
makeRule(2, 10, 'rc-1A-override', 5, {
isEnabled: false,
requiresComments: true,
}),
];
// เรียก find สองครั้ง: global, project
mockRuleRepo.find
.mockResolvedValueOnce(globalRules)
.mockResolvedValueOnce(projectRules);
const result = await service.resolveMatrix(1, 5);
expect(result).toHaveLength(1);
expect(result[0].isOverridden).toBe(true);
expect(result[0].isEnabled).toBe(false);
expect(result[0].requiresComments).toBe(true);
expect(result[0].parentRuleId).toBe(1); // global rule id
});
it('ควรใช้ global rule เมื่อ project ไม่ override', async () => {
const globalRules = [makeRule(1, 10, 'rc-1A'), makeRule(2, 20, 'rc-2')];
const projectRules: Partial<ResponseCodeRule>[] = []; // ไม่มี override
mockRuleRepo.find
.mockResolvedValueOnce(globalRules)
.mockResolvedValueOnce(projectRules);
const result = await service.resolveMatrix(1, 5);
expect(result).toHaveLength(2);
expect(result[0].isOverridden).toBe(false);
expect(result[0].parentRuleId).toBeUndefined();
});
it('ควรเพิ่ม project-only rule ที่ไม่มี global parent', async () => {
const globalRules = [makeRule(1, 10, 'rc-1A')];
const projectRules = [
makeRule(1, 10, 'rc-1A'), // overlap กับ global
makeRule(3, 30, 'rc-extra', 5), // project-only (responseCodeId=30 ไม่มีใน global)
];
mockRuleRepo.find
.mockResolvedValueOnce(globalRules)
.mockResolvedValueOnce(projectRules);
const result = await service.resolveMatrix(1, 5);
// 1 merged + 1 project-only = 2
expect(result).toHaveLength(2);
const extra = result.find((r) => r.responseCodeId === 30);
expect(extra).toBeDefined();
expect(extra?.isOverridden).toBe(true);
expect(extra?.parentRuleId).toBeUndefined();
});
});
});
@@ -0,0 +1,193 @@
// File: src/modules/response-code/services/matrix-management.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ MatrixManagementService (T061, FR-022)
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { MatrixManagementService } from './matrix-management.service';
import { ResponseCodeRule } from '../entities/response-code-rule.entity';
import { ResponseCode } from '../entities/response-code.entity';
const mockCode = {
id: 1,
publicId: 'rc-uuid-1A',
code: '1A',
isSystem: false,
};
const mockSystemCode = { id: 2, publicId: 'rc-sys', code: '0', isSystem: true };
const mockExistingRule = {
id: 10,
publicId: 'rule-uuid-001',
documentTypeId: 1,
responseCodeId: 1,
projectId: undefined,
isEnabled: true,
requiresComments: false,
triggersNotification: false,
};
describe('MatrixManagementService', () => {
let service: MatrixManagementService;
const mockRuleRepo = {
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
};
const mockCodeRepo = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixManagementService,
{
provide: getRepositoryToken(ResponseCodeRule),
useValue: mockRuleRepo,
},
{
provide: getRepositoryToken(ResponseCode),
useValue: mockCodeRepo,
},
],
}).compile();
service = module.get<MatrixManagementService>(MatrixManagementService);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('upsertRule', () => {
it('ควร throw NotFoundException เมื่อ ResponseCode ไม่พบ', async () => {
mockCodeRepo.findOne.mockResolvedValueOnce(null);
await expect(
service.upsertRule({
documentTypeId: 1,
responseCodePublicId: 'not-found',
isEnabled: true,
})
).rejects.toThrow(NotFoundException);
});
it('ควร throw BadRequestException เมื่อพยายาม disable system code', async () => {
mockCodeRepo.findOne.mockResolvedValueOnce(mockSystemCode);
await expect(
service.upsertRule({
documentTypeId: 1,
responseCodePublicId: 'rc-sys',
isEnabled: false,
})
).rejects.toThrow(BadRequestException);
});
it('ควรอัปเดต existing rule (isEnabled, requiresComments)', async () => {
mockCodeRepo.findOne.mockResolvedValueOnce(mockCode);
mockRuleRepo.findOne.mockResolvedValueOnce({ ...mockExistingRule });
mockRuleRepo.save.mockResolvedValueOnce({
...mockExistingRule,
isEnabled: false,
});
const result = await service.upsertRule({
documentTypeId: 1,
responseCodePublicId: 'rc-uuid-1A',
isEnabled: false,
requiresComments: true,
});
expect(mockRuleRepo.save).toHaveBeenCalledTimes(1);
expect(result.isEnabled).toBe(false);
});
it('ควรสร้าง rule ใหม่เมื่อยังไม่มี', async () => {
mockCodeRepo.findOne.mockResolvedValueOnce(mockCode);
mockRuleRepo.findOne.mockResolvedValueOnce(null); // ไม่มี existing
const createdRule = {
documentTypeId: 1,
responseCodeId: 1,
isEnabled: true,
requiresComments: false,
triggersNotification: false,
};
mockRuleRepo.create.mockReturnValueOnce(createdRule);
mockRuleRepo.save.mockResolvedValueOnce(createdRule);
const result = await service.upsertRule({
documentTypeId: 1,
responseCodePublicId: 'rc-uuid-1A',
isEnabled: true,
});
expect(mockRuleRepo.create).toHaveBeenCalledTimes(1);
expect(result.isEnabled).toBe(true);
});
it('ควร default requiresComments=false และ triggersNotification=false เมื่อสร้างใหม่', async () => {
mockCodeRepo.findOne.mockResolvedValueOnce(mockCode);
mockRuleRepo.findOne.mockResolvedValueOnce(null);
mockRuleRepo.create.mockImplementation(
(v: Partial<ResponseCodeRule>) => v
);
mockRuleRepo.save.mockImplementation((v: Partial<ResponseCodeRule>) =>
Promise.resolve(v)
);
const result = await service.upsertRule({
documentTypeId: 1,
responseCodePublicId: 'rc-uuid-1A',
isEnabled: true,
});
expect(result.requiresComments).toBe(false);
expect(result.triggersNotification).toBe(false);
});
});
describe('getRulesByDocType', () => {
it('ควรดึง rules ของ documentType + projectId ที่ระบุ', async () => {
mockRuleRepo.find.mockResolvedValueOnce([mockExistingRule]);
const result = await service.getRulesByDocType(1, 5);
expect(mockRuleRepo.find).toHaveBeenCalledWith({
where: { documentTypeId: 1, projectId: 5 },
relations: ['responseCode'],
});
expect(result).toHaveLength(1);
});
it('ควรดึง global rules เมื่อไม่ระบุ projectId', async () => {
mockRuleRepo.find.mockResolvedValueOnce([mockExistingRule]);
await service.getRulesByDocType(1);
expect(mockRuleRepo.find).toHaveBeenCalledWith({
where: { documentTypeId: 1, projectId: undefined },
relations: ['responseCode'],
});
});
});
describe('deleteProjectOverride', () => {
it('ควร throw NotFoundException เมื่อ rule ไม่พบ', async () => {
mockRuleRepo.findOne.mockResolvedValueOnce(null);
await expect(
service.deleteProjectOverride('nonexistent-rule')
).rejects.toThrow(NotFoundException);
});
it('ควร throw BadRequestException เมื่อพยายามลบ global rule', async () => {
mockRuleRepo.findOne.mockResolvedValueOnce({
...mockExistingRule,
projectId: undefined,
});
await expect(
service.deleteProjectOverride('rule-uuid-001')
).rejects.toThrow(BadRequestException);
});
it('ควรลบ project override สำเร็จ', async () => {
const projectRule = { ...mockExistingRule, projectId: 5 };
mockRuleRepo.findOne.mockResolvedValueOnce(projectRule);
mockRuleRepo.remove.mockResolvedValueOnce(undefined);
await service.deleteProjectOverride('rule-uuid-001');
expect(mockRuleRepo.remove).toHaveBeenCalledWith(projectRule);
});
});
});
@@ -0,0 +1,140 @@
// File: src/modules/response-code/services/notification-trigger.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ NotificationTriggerService (FR-007)
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotificationTriggerService } from './notification-trigger.service';
import { ResponseCode } from '../entities/response-code.entity';
import { User } from '../../user/entities/user.entity';
import { NotificationService } from '../../notification/notification.service';
import { ImplicationsService } from './implications.service';
const mockResponseCode = {
id: 1,
publicId: 'rc-3',
code: '3',
descriptionEn: 'Rejected',
notifyRoles: ['CONTRACT_MANAGER'],
implications: {},
};
describe('NotificationTriggerService', () => {
let service: NotificationTriggerService;
const mockRcRepo = { findOne: jest.fn() };
const mockUserRepo = {
createQueryBuilder: jest.fn(),
};
const mockNotificationService = {
send: jest.fn().mockResolvedValue(undefined),
};
const mockImplicationsService = { evaluate: jest.fn() };
// Helper สำหรับ query builder chain
const makeQB = (users: Partial<User>[]) => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(users),
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
NotificationTriggerService,
{ provide: getRepositoryToken(ResponseCode), useValue: mockRcRepo },
{ provide: getRepositoryToken(User), useValue: mockUserRepo },
{ provide: NotificationService, useValue: mockNotificationService },
{ provide: ImplicationsService, useValue: mockImplicationsService },
],
}).compile();
service = module.get<NotificationTriggerService>(
NotificationTriggerService
);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('triggerIfRequired', () => {
it('ควร return ทันทีเมื่อ ResponseCode ไม่พบ (warn, no throw)', async () => {
mockRcRepo.findOne.mockResolvedValueOnce(null);
await expect(
service.triggerIfRequired('not-found', 'rfa-1', 'DOC-001', 1)
).resolves.not.toThrow();
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควร return ทันทีเมื่อ severity=LOW', async () => {
mockRcRepo.findOne.mockResolvedValueOnce(mockResponseCode);
mockImplicationsService.evaluate.mockReturnValueOnce({
severity: 'LOW',
notifyRoles: [],
actionRequired: [],
});
await service.triggerIfRequired('rc-3', 'rfa-1', 'DOC-001', 1);
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควร return ทันทีเมื่อ notifyRoles ว่าง (severity != LOW)', async () => {
mockRcRepo.findOne.mockResolvedValueOnce(mockResponseCode);
mockImplicationsService.evaluate.mockReturnValueOnce({
severity: 'CRITICAL',
notifyRoles: [],
actionRequired: [],
});
await service.triggerIfRequired('rc-3', 'rfa-1', 'DOC-001', 1);
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
it('ควรส่งแจ้งเตือนถึง user ที่มี role ที่เกี่ยวข้อง', async () => {
mockRcRepo.findOne.mockResolvedValueOnce(mockResponseCode);
mockImplicationsService.evaluate.mockReturnValueOnce({
severity: 'CRITICAL',
notifyRoles: ['CONTRACT_MANAGER'],
actionRequired: ['Contract review required'],
});
const targetUser = { user_id: 99 } as User;
const qb = makeQB([targetUser]);
mockUserRepo.createQueryBuilder.mockReturnValueOnce(qb);
await service.triggerIfRequired('rc-3', 'rfa-001', 'DOC-001', 1);
expect(mockNotificationService.send).toHaveBeenCalledTimes(1);
expect(mockNotificationService.send).toHaveBeenCalledWith(
expect.objectContaining({
userId: 99,
type: 'SYSTEM',
entityType: 'rfa',
})
);
});
it('ควรส่งแจ้งเตือนแบบ parallel ถึงหลาย users', async () => {
mockRcRepo.findOne.mockResolvedValueOnce(mockResponseCode);
mockImplicationsService.evaluate.mockReturnValueOnce({
severity: 'HIGH',
notifyRoles: ['CONTRACT_MANAGER'],
actionRequired: [],
});
const users = [{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }] as User[];
const qb = makeQB(users);
mockUserRepo.createQueryBuilder.mockReturnValueOnce(qb);
await service.triggerIfRequired('rc-1C', 'rfa-002', 'DOC-002', 5);
expect(mockNotificationService.send).toHaveBeenCalledTimes(3);
});
it('ควร return ทันทีเมื่อไม่พบ users ที่ match roles', async () => {
mockRcRepo.findOne.mockResolvedValueOnce(mockResponseCode);
mockImplicationsService.evaluate.mockReturnValueOnce({
severity: 'HIGH',
notifyRoles: ['CONTRACT_MANAGER'],
actionRequired: [],
});
const qb = makeQB([]); // ไม่มี users
mockUserRepo.createQueryBuilder.mockReturnValueOnce(qb);
await service.triggerIfRequired('rc-1C', 'rfa-003', 'DOC-003', 5);
expect(mockNotificationService.send).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,180 @@
// File: src/modules/review-team/services/consensus.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ ConsensusService (T068, FR-010)
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConsensusService } from './consensus.service';
import { ReviewTask } from '../entities/review-task.entity';
import { AggregateStatusService } from './aggregate-status.service';
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
import { ConsensusDecision } from '../../common/enums/review.enums';
// Context ใช้ซ้ำในหลาย tests
const baseContext = {
rfaPublicId: 'rfa-uuid-001',
rfaRevisionPublicId: 'rev-uuid-001',
projectId: 5,
documentTypeId: 2,
documentTypeCode: 'SHOP_DRAWING',
};
describe('ConsensusService', () => {
let service: ConsensusService;
const mockTaskRepo = {}; // ConsensusService ไม่ใช้ repo โดยตรง
const mockAggregateStatusService = {
isReadyForConsensus: jest.fn(),
getForRevision: jest.fn(),
evaluateConsensus: jest.fn(),
getMostRestrictiveResponseCode: jest.fn(),
};
const mockApprovalListenerService = {
onConsensusReached: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ConsensusService,
{ provide: getRepositoryToken(ReviewTask), useValue: mockTaskRepo },
{
provide: AggregateStatusService,
useValue: mockAggregateStatusService,
},
{
provide: ApprovalListenerService,
useValue: mockApprovalListenerService,
},
],
}).compile();
service = module.get<ConsensusService>(ConsensusService);
});
afterEach(() => jest.clearAllMocks());
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('evaluateAfterTaskComplete — NOT READY', () => {
it('ควรคืน PENDING เมื่อยังไม่ครบทุก discipline', async () => {
mockAggregateStatusService.isReadyForConsensus.mockResolvedValueOnce(
false
);
mockAggregateStatusService.getForRevision.mockResolvedValueOnce({
completed: 2,
total: 4,
});
const result = await service.evaluateAfterTaskComplete(1, baseContext);
expect(result.decision).toBe(ConsensusDecision.PENDING);
expect(result.completedTasks).toBe(2);
expect(result.totalTasks).toBe(4);
expect(result.triggeredDistribution).toBe(false);
expect(
mockApprovalListenerService.onConsensusReached
).not.toHaveBeenCalled();
});
});
describe('evaluateAfterTaskComplete — READY: APPROVED', () => {
it('ควร trigger distribution เมื่อ decision=APPROVED', async () => {
mockAggregateStatusService.isReadyForConsensus.mockResolvedValueOnce(
true
);
mockAggregateStatusService.getForRevision.mockResolvedValueOnce({
completed: 3,
total: 3,
});
mockAggregateStatusService.evaluateConsensus.mockResolvedValueOnce(
ConsensusDecision.APPROVED
);
mockAggregateStatusService.getMostRestrictiveResponseCode.mockResolvedValueOnce(
'1A'
);
const result = await service.evaluateAfterTaskComplete(1, baseContext);
expect(result.decision).toBe(ConsensusDecision.APPROVED);
expect(result.triggeredDistribution).toBe(true);
expect(
mockApprovalListenerService.onConsensusReached
).toHaveBeenCalledWith(
expect.objectContaining({
rfaPublicId: 'rfa-uuid-001',
decision: ConsensusDecision.APPROVED,
responseCode: '1A',
})
);
});
});
describe('evaluateAfterTaskComplete — READY: APPROVED_WITH_COMMENTS', () => {
it('ควร trigger distribution เมื่อ decision=APPROVED_WITH_COMMENTS', async () => {
mockAggregateStatusService.isReadyForConsensus.mockResolvedValueOnce(
true
);
mockAggregateStatusService.getForRevision.mockResolvedValueOnce({
completed: 3,
total: 3,
});
mockAggregateStatusService.evaluateConsensus.mockResolvedValueOnce(
ConsensusDecision.APPROVED_WITH_COMMENTS
);
mockAggregateStatusService.getMostRestrictiveResponseCode.mockResolvedValueOnce(
'2'
);
const result = await service.evaluateAfterTaskComplete(1, baseContext);
expect(result.decision).toBe(ConsensusDecision.APPROVED_WITH_COMMENTS);
expect(result.triggeredDistribution).toBe(true);
expect(
mockApprovalListenerService.onConsensusReached
).toHaveBeenCalledWith(expect.objectContaining({ responseCode: '2' }));
});
});
describe('evaluateAfterTaskComplete — READY: REJECTED', () => {
it('ควรไม่ trigger distribution เมื่อ decision=REJECTED', async () => {
mockAggregateStatusService.isReadyForConsensus.mockResolvedValueOnce(
true
);
mockAggregateStatusService.getForRevision.mockResolvedValueOnce({
completed: 3,
total: 3,
});
mockAggregateStatusService.evaluateConsensus.mockResolvedValueOnce(
ConsensusDecision.REJECTED
);
const result = await service.evaluateAfterTaskComplete(1, baseContext);
expect(result.decision).toBe(ConsensusDecision.REJECTED);
expect(result.triggeredDistribution).toBe(false);
expect(
mockApprovalListenerService.onConsensusReached
).not.toHaveBeenCalled();
});
});
describe('evaluateAfterTaskComplete — context propagation', () => {
it('ควรส่ง context ทั้งหมดไปยัง onConsensusReached', async () => {
mockAggregateStatusService.isReadyForConsensus.mockResolvedValueOnce(
true
);
mockAggregateStatusService.getForRevision.mockResolvedValueOnce({
completed: 2,
total: 2,
});
mockAggregateStatusService.evaluateConsensus.mockResolvedValueOnce(
ConsensusDecision.APPROVED
);
mockAggregateStatusService.getMostRestrictiveResponseCode.mockResolvedValueOnce(
'1B'
);
await service.evaluateAfterTaskComplete(10, baseContext);
const callArgs =
mockApprovalListenerService.onConsensusReached.mock.calls[0][0];
expect(callArgs.projectId).toBe(5);
expect(callArgs.documentTypeCode).toBe('SHOP_DRAWING');
expect(callArgs.rfaRevisionPublicId).toBe('rev-uuid-001');
expect(callArgs.approvedAt).toBeInstanceOf(Date);
});
});
});
@@ -0,0 +1,298 @@
// File: src/modules/review-team/services/task-creation.service.spec.ts
// Change Log:
// - 2026-05-21: เพิ่ม unit tests สำหรับ TaskCreationService
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { TaskCreationService } from './task-creation.service';
import { ReviewTeam } from '../entities/review-team.entity';
import { ReviewTeamMember } from '../entities/review-team-member.entity';
import { ReviewTask } from '../entities/review-task.entity';
import { DelegationService } from '../../delegation/delegation.service';
import { SchedulerService } from '../../reminder/services/scheduler.service';
import {
ReviewTeamMemberRole,
ReviewTaskStatus,
ReminderType,
} from '../../common/enums/review.enums';
describe('TaskCreationService', () => {
let service: TaskCreationService;
let mockReviewTeamRepo: Record<string, jest.Mock>;
let mockMemberRepo: Record<string, jest.Mock>;
let mockReviewTaskRepo: Record<string, jest.Mock>;
let mockDelegationService: Record<string, jest.Mock>;
let mockSchedulerService: Record<string, jest.Mock>;
let mockEntityManager: Record<string, jest.Mock>;
beforeEach(async () => {
mockReviewTeamRepo = {
findOne: jest.fn(),
};
mockMemberRepo = {};
mockReviewTaskRepo = {
find: jest.fn(),
};
mockDelegationService = {
findActiveDelegate: jest.fn(),
};
mockSchedulerService = {
scheduleForTask: jest.fn(),
};
mockEntityManager = {
create: jest.fn(),
save: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
TaskCreationService,
{
provide: getRepositoryToken(ReviewTeam),
useValue: mockReviewTeamRepo,
},
{
provide: getRepositoryToken(ReviewTeamMember),
useValue: mockMemberRepo,
},
{
provide: getRepositoryToken(ReviewTask),
useValue: mockReviewTaskRepo,
},
{
provide: DelegationService,
useValue: mockDelegationService,
},
{
provide: SchedulerService,
useValue: mockSchedulerService,
},
],
}).compile();
service = module.get<TaskCreationService>(TaskCreationService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createParallelTasks', () => {
const mockDueDate = new Date();
const rfaRevisionId = 100;
const rfaPublicId = 'rfa-uuid-001';
const reviewTeamPublicId = 'team-uuid-001';
it('ควรคืนค่า empty array เมื่อไม่พบ ReviewTeam', async () => {
mockReviewTeamRepo.findOne.mockResolvedValueOnce(null);
const result = await service.createParallelTasks(
rfaRevisionId,
rfaPublicId,
reviewTeamPublicId,
mockDueDate,
mockEntityManager as unknown as EntityManager
);
expect(result).toEqual([]);
expect(mockReviewTeamRepo.findOne).toHaveBeenCalledWith({
where: { publicId: reviewTeamPublicId },
relations: ['members'],
});
});
it('ควรคืนค่า empty array เมื่อ ReviewTeam is_active=false', async () => {
mockReviewTeamRepo.findOne.mockResolvedValueOnce({
id: 1,
publicId: reviewTeamPublicId,
isActive: false,
});
const result = await service.createParallelTasks(
rfaRevisionId,
rfaPublicId,
reviewTeamPublicId,
mockDueDate,
mockEntityManager as unknown as EntityManager
);
expect(result).toEqual([]);
});
it('ควรคืนค่า empty array เมื่อ ReviewTeam ไม่มี members', async () => {
mockReviewTeamRepo.findOne.mockResolvedValueOnce({
id: 1,
publicId: reviewTeamPublicId,
isActive: true,
members: [],
});
const result = await service.createParallelTasks(
rfaRevisionId,
rfaPublicId,
reviewTeamPublicId,
mockDueDate,
mockEntityManager as unknown as EntityManager
);
expect(result).toEqual([]);
});
it('ควรสร้าง parallel review tasks ตาม disciplines และกรองลำดับ LEAD/REVIEWER (Happy Path)', async () => {
const mockMembers: Partial<ReviewTeamMember>[] = [
{
id: 10,
userId: 1,
disciplineId: 101,
role: ReviewTeamMemberRole.REVIEWER,
},
{
id: 11,
userId: 2,
disciplineId: 101,
role: ReviewTeamMemberRole.LEAD,
},
{
id: 12,
userId: 3,
disciplineId: 102,
role: ReviewTeamMemberRole.REVIEWER,
},
];
mockReviewTeamRepo.findOne.mockResolvedValueOnce({
id: 5,
projectId: 50,
publicId: reviewTeamPublicId,
isActive: true,
members: mockMembers as ReviewTeamMember[],
});
mockDelegationService.findActiveDelegate.mockResolvedValue(null);
const createdTask1: Partial<ReviewTask> = {
id: 201,
publicId: 'task-uuid-201',
rfaRevisionId,
teamId: 5,
disciplineId: 101,
assignedToUserId: 2, // หยิบคนที่เป็น LEAD ใน discipline 101
status: ReviewTaskStatus.PENDING,
dueDate: mockDueDate,
};
const createdTask2: Partial<ReviewTask> = {
id: 202,
publicId: 'task-uuid-202',
rfaRevisionId,
teamId: 5,
disciplineId: 102,
assignedToUserId: 3,
status: ReviewTaskStatus.PENDING,
dueDate: mockDueDate,
};
mockEntityManager.create
.mockReturnValueOnce(createdTask1)
.mockReturnValueOnce(createdTask2);
mockEntityManager.save
.mockResolvedValueOnce(createdTask1)
.mockResolvedValueOnce(createdTask2);
const result = await service.createParallelTasks(
rfaRevisionId,
rfaPublicId,
reviewTeamPublicId,
mockDueDate,
mockEntityManager as unknown as EntityManager,
50,
'SDW'
);
expect(result).toHaveLength(2);
expect(mockEntityManager.create).toHaveBeenCalledTimes(2);
expect(mockEntityManager.save).toHaveBeenCalledTimes(2);
expect(mockSchedulerService.scheduleForTask).toHaveBeenCalledTimes(2);
expect(mockSchedulerService.scheduleForTask).toHaveBeenNthCalledWith(1, {
taskPublicId: 'task-uuid-201',
rfaPublicId: rfaPublicId,
assigneeUserId: 2,
dueDate: mockDueDate,
reminderType: ReminderType.DUE_SOON,
projectId: 50,
documentTypeCode: 'SDW',
});
});
it('ควรดึงข้อมูล delegation เมื่อสมาชิกคนนั้นมี active delegate และเซ็ต assignedToUserId เป็นผู้รับมอบสิทธิ์', async () => {
const mockMembers: Partial<ReviewTeamMember>[] = [
{
id: 10,
userId: 1,
disciplineId: 101,
role: ReviewTeamMemberRole.LEAD,
},
];
mockReviewTeamRepo.findOne.mockResolvedValueOnce({
id: 5,
projectId: 50,
publicId: reviewTeamPublicId,
isActive: true,
members: mockMembers as ReviewTeamMember[],
});
mockDelegationService.findActiveDelegate.mockResolvedValueOnce({
user_id: 99,
});
const createdTask: Partial<ReviewTask> = {
id: 201,
publicId: 'task-uuid-201',
rfaRevisionId,
teamId: 5,
disciplineId: 101,
assignedToUserId: 99,
delegatedFromUserId: 1,
status: ReviewTaskStatus.PENDING,
dueDate: mockDueDate,
};
mockEntityManager.create.mockReturnValueOnce(createdTask);
mockEntityManager.save.mockResolvedValueOnce(createdTask);
const result = await service.createParallelTasks(
rfaRevisionId,
rfaPublicId,
reviewTeamPublicId,
mockDueDate,
mockEntityManager as unknown as EntityManager
);
expect(result).toHaveLength(1);
expect(result[0].assignedToUserId).toBe(99);
expect(mockDelegationService.findActiveDelegate).toHaveBeenCalledWith(
1,
mockDueDate,
expect.arrayContaining(['ALL', 'RFA_ONLY'])
);
});
});
describe('areAllTasksCompleted', () => {
const rfaRevisionId = 100;
it('ควรคืนค่า false เมื่อไม่มี review tasks ในระบบสำหรับ revision นั้น', async () => {
mockReviewTaskRepo.find.mockResolvedValueOnce([]);
const result = await service.areAllTasksCompleted(rfaRevisionId);
expect(result).toBe(false);
expect(mockReviewTaskRepo.find).toHaveBeenCalledWith({
where: { rfaRevisionId },
});
});
it('ควรคืนค่า false เมื่อบาง task ยังมีสถานะ PENDING', async () => {
const mockTasks: Partial<ReviewTask>[] = [
{ id: 1, status: ReviewTaskStatus.COMPLETED },
{ id: 2, status: ReviewTaskStatus.PENDING },
];
mockReviewTaskRepo.find.mockResolvedValueOnce(mockTasks as ReviewTask[]);
const result = await service.areAllTasksCompleted(rfaRevisionId);
expect(result).toBe(false);
});
it('ควรคืนค่า true เมื่อทุก tasks มีสถานะ COMPLETED หรือ CANCELLED', async () => {
const mockTasks: Partial<ReviewTask>[] = [
{ id: 1, status: ReviewTaskStatus.COMPLETED },
{ id: 2, status: ReviewTaskStatus.CANCELLED },
];
mockReviewTaskRepo.find.mockResolvedValueOnce(mockTasks as ReviewTask[]);
const result = await service.areAllTasksCompleted(rfaRevisionId);
expect(result).toBe(true);
});
});
});