690514:2019 204-rfa-approval-refactor #01
CI / CD Pipeline / build (push) Successful in 6m1s
CI / CD Pipeline / deploy (push) Failing after 6m42s

This commit is contained in:
2026-05-14 20:19:21 +07:00
parent 07cc6d47b1
commit 0240d80da5
183 changed files with 20050 additions and 1017 deletions
@@ -0,0 +1,129 @@
// File: tests/unit/delegation/delegation.service.spec.ts
// Change Log
// - 2026-05-13: เพิ่ม regression test เพื่อป้องกัน multi-level delegation chain
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
import { CircularDetectionService } from '../../../src/modules/delegation/services/circular-detection.service';
import { Delegation } from '../../../src/modules/delegation/entities/delegation.entity';
import { User } from '../../../src/modules/user/entities/user.entity';
import { DelegationScope } from '../../../src/modules/common/enums/review.enums';
import { Repository } from 'typeorm';
type RepositoryMock<T extends object> = {
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
create: jest.MockedFunction<(payload: Partial<T>) => T>;
save: jest.MockedFunction<(entity: T) => Promise<T>>;
createQueryBuilder: jest.MockedFunction<(alias: string) => QueryBuilderMock>;
};
type QueryBuilderMock = {
innerJoinAndSelect: jest.MockedFunction<
(relation: string, alias: string) => QueryBuilderMock
>;
where: jest.MockedFunction<
(
condition: string,
parameters?: Record<string, unknown>
) => QueryBuilderMock
>;
andWhere: jest.MockedFunction<
(
condition: string,
parameters?: Record<string, unknown>
) => QueryBuilderMock
>;
orderBy: jest.MockedFunction<
(sort: string, order: 'ASC' | 'DESC') => QueryBuilderMock
>;
getOne: jest.MockedFunction<() => Promise<Delegation | null>>;
};
const createQueryBuilderMock = (
delegation: Delegation | null
): QueryBuilderMock => {
const queryBuilder = {} as QueryBuilderMock;
queryBuilder.innerJoinAndSelect = jest.fn(
(_relation: string, _alias: string): QueryBuilderMock => queryBuilder
);
queryBuilder.where = jest.fn(
(
_condition: string,
_parameters?: Record<string, unknown>
): QueryBuilderMock => queryBuilder
);
queryBuilder.andWhere = jest.fn(
(
_condition: string,
_parameters?: Record<string, unknown>
): QueryBuilderMock => queryBuilder
);
queryBuilder.orderBy = jest.fn(
(_sort: string, _order: 'ASC' | 'DESC'): QueryBuilderMock => queryBuilder
);
queryBuilder.getOne = jest.fn(
(): Promise<Delegation | null> => Promise.resolve(delegation)
);
return queryBuilder;
};
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
findOne: jest.fn(),
create: jest.fn((payload: Partial<T>): T => payload as T),
save: jest.fn((entity: T): Promise<T> => Promise.resolve(entity)),
createQueryBuilder: jest.fn(),
});
describe('DelegationService', () => {
const delegationRepo = createRepositoryMock<Delegation>();
const userRepo = createRepositoryMock<User>();
const circularDetectionService = {
wouldCreateCircle: jest.fn(),
} as unknown as CircularDetectionService;
beforeEach(() => {
jest.clearAllMocks();
delegationRepo.createQueryBuilder.mockReturnValue(
createQueryBuilderMock(null)
);
(circularDetectionService.wouldCreateCircle as jest.Mock).mockResolvedValue(
false
);
});
it('rejects delegated user who already delegates onward', async () => {
const service = new DelegationService(
delegationRepo as unknown as Repository<Delegation>,
userRepo as unknown as Repository<User>,
circularDetectionService
);
const delegator = {
user_id: 1,
publicId: '019505a1-7c3e-7000-8000-000000000001',
} as User;
const delegate = {
user_id: 2,
publicId: '019505a1-7c3e-7000-8000-000000000002',
} as User;
const onwardDelegation = {
publicId: '019505a1-7c3e-7000-8000-000000000003',
delegatorUserId: delegate.user_id,
delegateUserId: 3,
delegate: { user_id: 3 } as User,
} as Delegation;
(userRepo.findOne as jest.Mock)
.mockResolvedValueOnce(delegator)
.mockResolvedValueOnce(delegate);
delegationRepo.createQueryBuilder.mockReturnValue(
createQueryBuilderMock(onwardDelegation)
);
await expect(
service.create(delegator.publicId, {
delegateUserPublicId: delegate.publicId,
scope: DelegationScope.RFA_ONLY,
startDate: new Date('2026-05-20T00:00:00.000Z'),
endDate: new Date('2026-05-27T00:00:00.000Z'),
})
).rejects.toThrow('Nested delegation is not allowed');
});
});
@@ -0,0 +1,109 @@
// File: tests/unit/distribution/distribution-matrix.service.spec.ts
// Change Log
// - 2026-05-14: Add regression coverage for Distribution Matrix public-ID handling.
import { DistributionMatrixService } from '../../../src/modules/distribution/distribution-matrix.service';
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
import { Project } from '../../../src/modules/project/entities/project.entity';
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
import {
DeliveryMethod,
RecipientType,
} from '../../../src/modules/common/enums/review.enums';
import { Repository } from 'typeorm';
type RepositoryMock<T extends object> = {
find: jest.MockedFunction<(options: unknown) => Promise<T[]>>;
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
create: jest.MockedFunction<(payload: Partial<T>) => T>;
save: jest.MockedFunction<(payload: T) => Promise<T>>;
remove: jest.MockedFunction<(payload: T) => Promise<T>>;
};
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn((payload: Partial<T>): T => payload as T),
save: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
remove: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
});
describe('DistributionMatrixService', () => {
const matrixRepo = createRepositoryMock<DistributionMatrix>();
const recipientRepo = createRepositoryMock<DistributionRecipient>();
const projectRepo = createRepositoryMock<Project>();
const responseCodeRepo = createRepositoryMock<ResponseCode>();
beforeEach(() => {
jest.clearAllMocks();
});
it('creates a schema-aligned matrix by resolving public IDs internally', async () => {
const service = new DistributionMatrixService(
matrixRepo as unknown as Repository<DistributionMatrix>,
recipientRepo as unknown as Repository<DistributionRecipient>,
projectRepo as unknown as Repository<Project>,
responseCodeRepo as unknown as Repository<ResponseCode>
);
matrixRepo.findOne.mockResolvedValue({
id: 7,
publicId: '019505a1-7c3e-7000-8000-000000000001',
} as unknown as DistributionMatrix);
projectRepo.findOne.mockResolvedValue({
id: 7,
publicId: '019505a1-7c3e-7000-8000-000000000001',
} as Project);
responseCodeRepo.findOne.mockResolvedValue({
id: 9,
publicId: '019505a1-7c3e-7000-8000-000000000002',
} as ResponseCode);
await service.create({
name: 'Shop Drawing Distribution',
projectPublicId: '019505a1-7c3e-7000-8000-000000000001',
documentTypeId: 3,
responseCodePublicId: '019505a1-7c3e-7000-8000-000000000002',
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
});
expect(matrixRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Shop Drawing Distribution',
projectId: 7,
documentTypeId: 3,
responseCodeId: 9,
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
})
);
});
it('adds recipients with recipientPublicId instead of internal recipient ids', async () => {
const service = new DistributionMatrixService(
matrixRepo as unknown as Repository<DistributionMatrix>,
recipientRepo as unknown as Repository<DistributionRecipient>,
projectRepo as unknown as Repository<Project>,
responseCodeRepo as unknown as Repository<ResponseCode>
);
matrixRepo.findOne.mockResolvedValue({
id: 11,
publicId: '019505a1-7c3e-7000-8000-000000000003',
} as DistributionMatrix);
await service.addRecipient('019505a1-7c3e-7000-8000-000000000003', {
recipientType: RecipientType.ORGANIZATION,
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
deliveryMethod: DeliveryMethod.BOTH,
sequence: 1,
});
expect(recipientRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
matrixId: 11,
recipientType: RecipientType.ORGANIZATION,
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
deliveryMethod: DeliveryMethod.BOTH,
sequence: 1,
})
);
});
});
@@ -0,0 +1,59 @@
// File: tests/unit/distribution/transmittal-creator.service.spec.ts
// Change Log
// - 2026-05-14: Add regression coverage for Distribution Matrix response-code filtering.
import { TransmittalCreatorService } from '../../../src/modules/distribution/services/transmittal-creator.service';
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
import { DocumentNumberingService } from '../../../src/modules/document-numbering/services/document-numbering.service';
import { DataSource, Repository } from 'typeorm';
type MatrixRepositoryMock = {
findOne: jest.MockedFunction<
(options: unknown) => Promise<DistributionMatrix | null>
>;
};
describe('TransmittalCreatorService', () => {
const matrixRepo: MatrixRepositoryMock = {
findOne: jest.fn(),
};
const dataSource = {
manager: {
findOne: jest.fn(),
},
query: jest.fn(),
createQueryRunner: jest.fn(),
};
const numberingService = {
generateNextNumber: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('skips distribution when the response code is excluded', async () => {
const service = new TransmittalCreatorService(
matrixRepo as unknown as Repository<DistributionMatrix>,
dataSource as unknown as DataSource,
numberingService as unknown as DocumentNumberingService
);
matrixRepo.findOne.mockResolvedValue({
conditions: { excludeCodes: ['3', '4'] },
recipients: [{} as DistributionRecipient],
} as DistributionMatrix);
const result = await service.createFromDistribution({
rfaPublicId: '019505a1-7c3e-7000-8000-000000000001',
rfaRevisionPublicId: '019505a1-7c3e-7000-8000-000000000002',
projectId: 1,
documentTypeId: 2,
responseCode: '3',
});
expect(result).toEqual({
transmittalPublicIds: [],
notificationTargets: [],
});
});
});
@@ -6,6 +6,7 @@ import { ResponseCodeService } from '../../../src/modules/response-code/response
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,
@@ -21,6 +22,13 @@ const mockCode: Partial<ResponseCode> = {
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 = {
@@ -31,6 +39,18 @@ describe('ResponseCodeService', () => {
let service: ResponseCodeService;
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,
@@ -71,4 +91,72 @@ describe('ResponseCodeService', () => {
expect(result).toBeDefined();
});
});
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 () => {
await expect(
service.create({
code: '1A',
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ซ้ำ',
descriptionEn: 'Duplicate',
})
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('update', () => {
it('should update an existing response code by publicId', async () => {
const result = await service.update('test-uuid-1', {
descriptionEn: 'Updated Description',
});
expect(mockCodeRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
publicId: 'test-uuid-1',
descriptionEn: 'Updated Description',
})
);
expect(result).toEqual(
expect.objectContaining({
descriptionEn: 'Updated Description',
})
);
});
});
describe('deactivate', () => {
it('should reject deactivation for system response codes', async () => {
await expect(service.deactivate('test-uuid-1')).rejects.toBeInstanceOf(
BadRequestException
);
});
});
});
@@ -0,0 +1,100 @@
// File: tests/unit/review-team/task-creation-delegation.service.spec.ts
// Change Log
// - 2026-05-13: เพิ่ม regression test สำหรับการมอบหมาย Review Task ผ่าน Delegation
import { EntityManager, Repository } from 'typeorm';
import { TaskCreationService } from '../../../src/modules/review-team/services/task-creation.service';
import { ReviewTeam } from '../../../src/modules/review-team/entities/review-team.entity';
import { ReviewTeamMember } from '../../../src/modules/review-team/entities/review-team-member.entity';
import { ReviewTask } from '../../../src/modules/review-team/entities/review-task.entity';
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
import { SchedulerService } from '../../../src/modules/reminder/services/scheduler.service';
import {
DelegationScope,
ReviewTeamMemberRole,
} from '../../../src/modules/common/enums/review.enums';
import { User } from '../../../src/modules/user/entities/user.entity';
type RepositoryMock<T extends object> = Pick<Repository<T>, 'findOne' | 'find'>;
const createRepositoryMock = <T extends object>(): jest.Mocked<
RepositoryMock<T>
> => ({
findOne: jest.fn(),
find: jest.fn(),
});
const createManagerMock = (): { create: jest.Mock; save: jest.Mock } => ({
create: jest.fn(
(_entity: unknown, payload: Partial<ReviewTask>): ReviewTask =>
payload as ReviewTask
),
save: jest.fn(
(_entity: unknown, payload: ReviewTask): Promise<ReviewTask> =>
Promise.resolve(payload)
),
});
describe('TaskCreationService delegation resolution', () => {
const reviewTeamRepo = createRepositoryMock<ReviewTeam>();
const memberRepo = createRepositoryMock<ReviewTeamMember>();
const reviewTaskRepo = createRepositoryMock<ReviewTask>();
const delegationService = {
findActiveDelegate: jest.fn(),
} as unknown as DelegationService;
const schedulerService = {
scheduleForTask: jest.fn(),
} as unknown as SchedulerService;
beforeEach(() => {
jest.clearAllMocks();
});
it('assigns delegated review task to active delegate and preserves original reviewer', async () => {
const service = new TaskCreationService(
reviewTeamRepo as unknown as Repository<ReviewTeam>,
memberRepo as unknown as Repository<ReviewTeamMember>,
reviewTaskRepo as unknown as Repository<ReviewTask>,
delegationService,
schedulerService
);
const manager = createManagerMock();
const originalReviewerId = 10;
const delegateReviewer = { user_id: 20 } as User;
const team = {
id: 1,
publicId: '019505a1-7c3e-7000-8000-000000000001',
isActive: true,
members: [
{
userId: originalReviewerId,
disciplineId: 3,
role: ReviewTeamMemberRole.LEAD,
},
],
} as ReviewTeam;
(reviewTeamRepo.findOne as jest.Mock).mockResolvedValue(team);
(delegationService.findActiveDelegate as jest.Mock).mockResolvedValue(
delegateReviewer
);
const tasks = await service.createParallelTasks(
100,
team.publicId,
new Date('2026-05-20T00:00:00.000Z'),
manager as unknown as EntityManager
);
expect(tasks).toHaveLength(1);
expect(tasks[0].assignedToUserId).toBe(delegateReviewer.user_id);
expect(tasks[0].delegatedFromUserId).toBe(originalReviewerId);
expect(delegationService.findActiveDelegate).toHaveBeenCalledWith(
originalReviewerId,
expect.any(Date),
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
);
expect(schedulerService.scheduleForTask).toHaveBeenCalled();
});
});