690514:2019 204-rfa-approval-refactor #01
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user