feat(rfa-ai): Complete RFA Approval Refactor and AI Model Revision
CI / CD Pipeline / build (push) Successful in 4m54s
CI / CD Pipeline / deploy (push) Failing after 12m9s

This commit is contained in:
2026-05-16 10:59:53 +07:00
parent 6cb3ae10ee
commit 1a162bf320
105 changed files with 5088 additions and 1083 deletions
@@ -1,60 +1,42 @@
// File: tests/unit/response-code/response-code.service.spec.ts
// Unit tests สำหรับ ResponseCodeService (T074)
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ResponseCodeService } from '../../../src/modules/response-code/response-code.service';
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity';
import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums';
import { BadRequestException, ConflictException } from '@nestjs/common';
const mockCode: Partial<ResponseCode> = {
id: 1,
publicId: 'test-uuid-1',
code: '1A',
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ผ่าน — ไม่มีเงื่อนไข',
descriptionEn: 'Approved — No Comments',
isActive: true,
isSystem: true,
};
const mockCodeRepo = {
find: jest.fn().mockResolvedValue([mockCode]),
findOne: jest.fn().mockResolvedValue(mockCode),
create: jest.fn(
(payload: Partial<ResponseCode>): Partial<ResponseCode> => payload
),
save: jest.fn(
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
Promise.resolve(payload)
),
};
const mockRuleRepo = {
find: jest.fn().mockResolvedValue([]),
};
import {
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { CreateResponseCodeDto } from '../../../src/modules/response-code/dto/create-response-code.dto';
describe('ResponseCodeService', () => {
let service: ResponseCodeService;
let repo: Repository<ResponseCode>;
let _ruleRepo: Repository<ResponseCodeRule>;
const mockRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockRuleRepo = {
find: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
mockCodeRepo.find.mockResolvedValue([mockCode]);
mockCodeRepo.findOne.mockResolvedValue(mockCode);
mockCodeRepo.create.mockImplementation(
(payload: Partial<ResponseCode>): Partial<ResponseCode> => payload
);
mockCodeRepo.save.mockImplementation(
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
Promise.resolve(payload)
);
mockRuleRepo.find.mockResolvedValue([]);
const module: TestingModule = await Test.createTestingModule({
providers: [
ResponseCodeService,
{ provide: getRepositoryToken(ResponseCode), useValue: mockCodeRepo },
{
provide: getRepositoryToken(ResponseCode),
useValue: mockRepo,
},
{
provide: getRepositoryToken(ResponseCodeRule),
useValue: mockRuleRepo,
@@ -63,100 +45,209 @@ describe('ResponseCodeService', () => {
}).compile();
service = module.get<ResponseCodeService>(ResponseCodeService);
repo = module.get<Repository<ResponseCode>>(
getRepositoryToken(ResponseCode)
);
_ruleRepo = module.get<Repository<ResponseCodeRule>>(
getRepositoryToken(ResponseCodeRule)
);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should return all active codes', async () => {
const mockCodes = [{ code: '1A', isActive: true }];
mockRepo.find.mockResolvedValue(mockCodes);
const result = await service.findAll();
expect(result).toEqual(mockCodes);
expect(repo.find).toHaveBeenCalledWith(
expect.objectContaining({ where: { isActive: true } })
);
});
});
describe('findByCategory', () => {
it('should return codes filtered by category', async () => {
it('should filter by category', async () => {
const mockCodes = [
{ code: '1A', category: ResponseCodeCategory.ENGINEERING },
];
mockRepo.find.mockResolvedValue(mockCodes);
const result = await service.findByCategory(
ResponseCodeCategory.ENGINEERING
);
expect(mockCodeRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
category: ResponseCodeCategory.ENGINEERING,
}),
})
);
expect(result).toEqual([mockCode]);
expect(result).toEqual(mockCodes);
});
});
describe('findByDocumentType', () => {
it('should return enabled codes for document type', async () => {
const result = await service.findByDocumentType(1, 1);
expect(result).toBeDefined();
it('should handle global and project rules with overrides and sorting', async () => {
const globalRule1 = {
responseCodeId: 2,
projectId: null,
responseCode: { id: 2, code: '2', isActive: true },
};
const globalRule2 = {
responseCodeId: 1,
projectId: null,
responseCode: { id: 1, code: '1A', isActive: true },
};
const projectRule = {
responseCodeId: 1,
projectId: 10,
responseCode: { id: 1, code: '1A_OVERRIDE', isActive: true },
};
mockRuleRepo.find.mockResolvedValue([
globalRule1,
globalRule2,
projectRule,
]);
const result = await service.findByDocumentType(1, 10);
expect(result).toHaveLength(2);
expect(result[0].code).toBe('1A_OVERRIDE');
expect(result[1].code).toBe('2');
});
it('should ignore inactive codes from rules', async () => {
const rule = {
responseCodeId: 1,
responseCode: { id: 1, code: '1A', isActive: false },
};
mockRuleRepo.find.mockResolvedValue([rule]);
const result = await service.findByDocumentType(1);
expect(result).toHaveLength(0);
});
});
describe('findByPublicId', () => {
it('should throw NotFoundException if not found', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.findByPublicId('none')).rejects.toThrow(
NotFoundException
);
});
it('should return code if found', async () => {
const mockCode = { publicId: 'uuid' };
mockRepo.findOne.mockResolvedValue(mockCode);
const result = await service.findByPublicId('uuid');
expect(result).toEqual(mockCode);
});
});
describe('create', () => {
it('should create a non-system response code when code/category is unique', async () => {
mockCodeRepo.findOne.mockResolvedValueOnce(null);
const result = await service.create({
code: '9A',
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ทดสอบ',
descriptionEn: 'Test',
});
expect(mockCodeRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
code: '9A',
category: ResponseCodeCategory.ENGINEERING,
isSystem: false,
isActive: true,
})
);
expect(result).toEqual(
expect.objectContaining({
code: '9A',
category: ResponseCodeCategory.ENGINEERING,
isSystem: false,
})
);
});
it('should reject duplicate code/category pairs', async () => {
it('should throw ConflictException if already exists', async () => {
mockRepo.findOne.mockResolvedValue({ id: 1 });
await expect(
service.create({
code: '1A',
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ซ้ำ',
descriptionEn: 'Duplicate',
})
).rejects.toBeInstanceOf(ConflictException);
} as unknown as CreateResponseCodeDto)
).rejects.toThrow(ConflictException);
});
it('should create and save new code', async () => {
mockRepo.findOne.mockResolvedValue(null);
mockRepo.create.mockReturnValue({ code: '1A' });
mockRepo.save.mockResolvedValue({ id: 1, code: '1A' });
const result = await service.create({
code: '1A',
category: ResponseCodeCategory.ENGINEERING,
isActive: true,
} as unknown as CreateResponseCodeDto);
expect(result.code).toBe('1A');
expect(repo.save).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update an existing response code by publicId', async () => {
const result = await service.update('test-uuid-1', {
descriptionEn: 'Updated Description',
});
it('should throw ConflictException if update creates a duplicate', async () => {
const existing = {
id: 1,
publicId: 'uuid1',
code: '1A',
category: ResponseCodeCategory.ENGINEERING,
};
const duplicate = {
id: 2,
publicId: 'uuid2',
code: '1B',
category: ResponseCodeCategory.ENGINEERING,
};
expect(mockCodeRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
publicId: 'test-uuid-1',
descriptionEn: 'Updated Description',
})
);
expect(result).toEqual(
expect.objectContaining({
descriptionEn: 'Updated Description',
})
mockRepo.findOne.mockResolvedValueOnce(existing); // findByPublicId
mockRepo.findOne.mockResolvedValueOnce(duplicate); // check existing duplicate
await expect(service.update('uuid1', { code: '1B' })).rejects.toThrow(
ConflictException
);
});
it('should update and save when no duplicate exists', async () => {
const existing = { id: 1, publicId: 'uuid1', code: '1A' };
mockRepo.findOne.mockResolvedValueOnce(existing);
mockRepo.findOne.mockResolvedValueOnce(null); // No duplicate
mockRepo.save.mockImplementation((d) => Promise.resolve(d));
const result = await service.update('uuid1', { descriptionEn: 'New' });
expect(result.descriptionEn).toBe('New');
});
it('should handle update with same code and category (self-match)', async () => {
const existing = {
id: 1,
publicId: 'uuid1',
code: '1A',
category: ResponseCodeCategory.ENGINEERING,
};
mockRepo.findOne.mockResolvedValueOnce(existing); // findByPublicId
mockRepo.findOne.mockResolvedValueOnce(existing); // self match in check existing
mockRepo.save.mockImplementation((d) => Promise.resolve(d));
const result = await service.update('uuid1', { descriptionEn: 'Same' });
expect(result.descriptionEn).toBe('Same');
});
});
describe('deactivate', () => {
it('should reject deactivation for system response codes', async () => {
await expect(service.deactivate('test-uuid-1')).rejects.toBeInstanceOf(
it('should throw BadRequestException for system codes', async () => {
mockRepo.findOne.mockResolvedValue({ publicId: 'uuid', isSystem: true });
await expect(service.deactivate('uuid')).rejects.toThrow(
BadRequestException
);
});
it('should set isActive to false and save', async () => {
const entity = { isSystem: false, isActive: true, publicId: 'uuid' };
mockRepo.findOne.mockResolvedValue(entity);
await service.deactivate('uuid');
expect(entity.isActive).toBe(false);
expect(repo.save).toHaveBeenCalledWith(entity);
});
});
describe('getNotifyRoles', () => {
it('should return notifyRoles or empty array', async () => {
mockRepo.findOne.mockResolvedValueOnce({
publicId: 'uuid',
notifyRoles: ['PM'],
});
expect(await service.getNotifyRoles('uuid')).toEqual(['PM']);
mockRepo.findOne.mockResolvedValueOnce({
publicId: 'uuid',
notifyRoles: null,
});
expect(await service.getNotifyRoles('uuid')).toEqual([]);
});
});
});
@@ -0,0 +1,181 @@
// File: tests/unit/review-team/aggregate-status.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AggregateStatusService } from '../../../src/modules/review-team/services/aggregate-status.service';
import { ReviewTask } from '../../../src/modules/review-team/entities/review-task.entity';
import {
ReviewTaskStatus,
ConsensusDecision,
} from '../../../src/modules/common/enums/review.enums';
describe('AggregateStatusService', () => {
let service: AggregateStatusService;
let _taskRepo: Repository<ReviewTask>;
const mockTaskRepo = {
find: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AggregateStatusService,
{
provide: getRepositoryToken(ReviewTask),
useValue: mockTaskRepo,
},
],
}).compile();
service = module.get<AggregateStatusService>(AggregateStatusService);
_taskRepo = module.get<Repository<ReviewTask>>(
getRepositoryToken(ReviewTask)
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getForRevision', () => {
it('should return 0 status if no tasks exist', async () => {
mockTaskRepo.find.mockResolvedValue([]);
const result = await service.getForRevision(1);
expect(result.total).toBe(0);
expect(result.completionPct).toBe(0);
expect(result.isAllComplete).toBe(false);
});
it('should calculate counts correctly', async () => {
mockTaskRepo.find.mockResolvedValue([
{ status: ReviewTaskStatus.COMPLETED },
{ status: ReviewTaskStatus.COMPLETED },
{ status: ReviewTaskStatus.PENDING },
{ status: ReviewTaskStatus.IN_PROGRESS },
{ status: ReviewTaskStatus.DELEGATED },
{ status: ReviewTaskStatus.EXPIRED },
]);
const result = await service.getForRevision(1);
expect(result.total).toBe(6);
expect(result.completed).toBe(2);
expect(result.pending).toBe(1);
expect(result.inProgress).toBe(1);
expect(result.delegated).toBe(1);
expect(result.expired).toBe(1);
expect(result.completionPct).toBe(33);
expect(result.isAllComplete).toBe(false);
expect(result.hasExpired).toBe(true);
});
it('should return isAllComplete true if all tasks are COMPLETED', async () => {
mockTaskRepo.find.mockResolvedValue([
{ status: ReviewTaskStatus.COMPLETED },
{ status: ReviewTaskStatus.COMPLETED },
]);
const result = await service.getForRevision(1);
expect(result.isAllComplete).toBe(true);
expect(result.completionPct).toBe(100);
});
});
describe('isReadyForConsensus', () => {
it('should return true if all complete', async () => {
mockTaskRepo.find.mockResolvedValue([
{ status: ReviewTaskStatus.COMPLETED },
]);
expect(await service.isReadyForConsensus(1)).toBe(true);
});
it('should return false if not all complete', async () => {
mockTaskRepo.find.mockResolvedValue([
{ status: ReviewTaskStatus.PENDING },
]);
expect(await service.isReadyForConsensus(1)).toBe(false);
});
});
describe('evaluateConsensus', () => {
it('should return PENDING if no completed tasks', async () => {
mockTaskRepo.find.mockResolvedValue([]);
expect(await service.evaluateConsensus(1)).toBe(
ConsensusDecision.PENDING
);
});
it('should return REJECTED if any Code 3 exists', async () => {
mockTaskRepo.find.mockResolvedValue([
{ responseCode: { code: '1A' } },
{ responseCode: { code: '3' } },
]);
expect(await service.evaluateConsensus(1)).toBe(
ConsensusDecision.REJECTED
);
});
it('should return APPROVED if all are 1A or 1B', async () => {
mockTaskRepo.find.mockResolvedValue([
{ responseCode: { code: '1A' } },
{ responseCode: { code: '1B' } },
]);
expect(await service.evaluateConsensus(1)).toBe(
ConsensusDecision.APPROVED
);
});
it('should return APPROVED_WITH_COMMENTS if any Code 2 exists and no Code 3', async () => {
mockTaskRepo.find.mockResolvedValue([
{ responseCode: { code: '1A' } },
{ responseCode: { code: '2' } },
]);
expect(await service.evaluateConsensus(1)).toBe(
ConsensusDecision.APPROVED_WITH_COMMENTS
);
});
});
describe('getMostRestrictiveResponseCode', () => {
it('should return 1A if no completed tasks', async () => {
mockTaskRepo.find.mockResolvedValue([]);
expect(await service.getMostRestrictiveResponseCode(1)).toBe('1A');
});
it('should return 3 if any Code 3 exists', async () => {
mockTaskRepo.find.mockResolvedValue([
{ responseCode: { code: '1A' } },
{ responseCode: { code: '2' } },
{ responseCode: { code: '3' } },
]);
expect(await service.getMostRestrictiveResponseCode(1)).toBe('3');
});
it('should return 2 if Code 2 exists and no Code 3', async () => {
mockTaskRepo.find.mockResolvedValue([
{ responseCode: { code: '1A' } },
{ responseCode: { code: '1B' } },
{ responseCode: { code: '2' } },
]);
expect(await service.getMostRestrictiveResponseCode(1)).toBe('2');
});
it('should return 1B if Code 1B exists and no Code 2 or 3', async () => {
mockTaskRepo.find.mockResolvedValue([
{ responseCode: { code: '1A' } },
{ responseCode: { code: '1B' } },
]);
expect(await service.getMostRestrictiveResponseCode(1)).toBe('1B');
});
it('should return 1A if only Code 1A exists', async () => {
mockTaskRepo.find.mockResolvedValue([{ responseCode: { code: '1A' } }]);
expect(await service.getMostRestrictiveResponseCode(1)).toBe('1A');
});
});
});
@@ -82,6 +82,7 @@ describe('TaskCreationService delegation resolution', () => {
const tasks = await service.createParallelTasks(
100,
'rfa-public-id',
team.publicId,
new Date('2026-05-20T00:00:00.000Z'),
manager as unknown as EntityManager
@@ -0,0 +1,120 @@
// File: tests/unit/review-team/veto-override.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import {
VetoOverrideService,
VetoOverrideDto,
} from '../../../src/modules/review-team/services/veto-override.service';
import { ReviewTask } from '../../../src/modules/review-team/entities/review-task.entity';
import { ApprovalListenerService } from '../../../src/modules/distribution/services/approval-listener.service';
import { ConsensusDecision } from '../../../src/modules/common/enums/review.enums';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('VetoOverrideService', () => {
let service: VetoOverrideService;
let _taskRepo: Repository<ReviewTask>;
let approvalListenerService: ApprovalListenerService;
const mockTaskRepo = {
find: jest.fn(),
};
const mockApprovalListenerService = {
onConsensusReached: jest.fn(),
};
const mockDataSource = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
VetoOverrideService,
{
provide: getRepositoryToken(ReviewTask),
useValue: mockTaskRepo,
},
{
provide: ApprovalListenerService,
useValue: mockApprovalListenerService,
},
{
provide: DataSource,
useValue: mockDataSource,
},
],
}).compile();
service = module.get<VetoOverrideService>(VetoOverrideService);
_taskRepo = module.get<Repository<ReviewTask>>(
getRepositoryToken(ReviewTask)
);
approvalListenerService = module.get<ApprovalListenerService>(
ApprovalListenerService
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('executeOverride', () => {
const validDto: VetoOverrideDto = {
rfaRevisionId: 1,
rfaPublicId: 'rfa-uuid',
rfaRevisionPublicId: 'rev-uuid',
projectId: 10,
documentTypeCode: 'SD',
overrideReason: 'This is a valid justification for override.',
overriddenByUserId: 1,
};
it('should throw NotFoundException if no tasks found', async () => {
mockTaskRepo.find.mockResolvedValue([]);
await expect(service.executeOverride(validDto)).rejects.toThrow(
NotFoundException
);
});
it('should throw ForbiddenException if no Code 3 veto found', async () => {
mockTaskRepo.find.mockResolvedValue([
{ id: 1, responseCode: { code: '1A' } },
{ id: 2, responseCode: { code: '2' } },
]);
await expect(service.executeOverride(validDto)).rejects.toThrow(
ForbiddenException
);
});
it('should throw ForbiddenException if reason is too short', async () => {
mockTaskRepo.find.mockResolvedValue([
{ id: 1, responseCode: { code: '3' } },
]);
const shortDto = { ...validDto, overrideReason: 'Too short' };
await expect(service.executeOverride(shortDto)).rejects.toThrow(
ForbiddenException
);
});
it('should successfully execute override and call approval listener', async () => {
mockTaskRepo.find.mockResolvedValue([
{ id: 1, responseCode: { code: '3' } },
]);
const result = await service.executeOverride(validDto);
expect(result.decision).toBe(ConsensusDecision.OVERRIDDEN);
expect(approvalListenerService.onConsensusReached).toHaveBeenCalledWith(
expect.objectContaining({
rfaPublicId: validDto.rfaPublicId,
decision: ConsensusDecision.OVERRIDDEN,
responseCode: '1A',
})
);
});
});
});