531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
// ADR-021 Clarify Q2 (C1): Mock Redlock ก่อน import service
|
|
// ใช้ module-level mock เพื่อบังคับให้ constructor `new Redlock(...)` ในการสร้าง service
|
|
const mockRedlockAcquire = jest.fn();
|
|
const mockRedlockRelease = jest.fn().mockResolvedValue(undefined);
|
|
jest.mock('redlock', () =>
|
|
jest.fn().mockImplementation(() => ({
|
|
acquire: mockRedlockAcquire,
|
|
}))
|
|
);
|
|
|
|
import { ConflictException, ServiceUnavailableException } from '@nestjs/common';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { WorkflowEngineService } from './workflow-engine.service';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { DataSource, In, Repository } from 'typeorm';
|
|
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
|
import {
|
|
WorkflowInstance,
|
|
WorkflowStatus,
|
|
} from './entities/workflow-instance.entity';
|
|
import { WorkflowHistory } from './entities/workflow-history.entity';
|
|
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
|
import { WorkflowDslService } from './workflow-dsl.service';
|
|
import { WorkflowEventService } from './workflow-event.service';
|
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
import { NotFoundException, WorkflowException } from '../../common/exceptions';
|
|
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
|
|
|
// Token ของ @nestjs-modules/ioredis — default Redis connection
|
|
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
|
|
|
describe('WorkflowEngineService', () => {
|
|
let service: WorkflowEngineService;
|
|
let defRepo: Repository<WorkflowDefinition>;
|
|
let instanceRepo: Repository<WorkflowInstance>;
|
|
let dslService: WorkflowDslService;
|
|
let eventService: WorkflowEventService;
|
|
|
|
// Mock Objects
|
|
const mockQueryRunner = {
|
|
connect: jest.fn(),
|
|
startTransaction: jest.fn(),
|
|
commitTransaction: jest.fn(),
|
|
rollbackTransaction: jest.fn(),
|
|
release: jest.fn(),
|
|
manager: {
|
|
findOne: jest.fn(),
|
|
save: jest.fn(),
|
|
update: jest.fn(),
|
|
},
|
|
};
|
|
|
|
const mockDataSource = {
|
|
createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner),
|
|
};
|
|
|
|
const mockDslService = {
|
|
compile: jest.fn(),
|
|
evaluate: jest.fn(),
|
|
};
|
|
|
|
const mockEventService = {
|
|
dispatchEvents: jest.fn(),
|
|
};
|
|
|
|
const mockCompiledWorkflow = {
|
|
initialState: 'START',
|
|
states: {
|
|
START: { transitions: { SUBMIT: 'PENDING' } },
|
|
PENDING: { transitions: { APPROVE: 'APPROVED', REJECT: 'REJECTED' } },
|
|
APPROVED: { terminal: true },
|
|
REJECTED: { terminal: true },
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
// ADR-021 C1: default Redlock behavior = acquire สำเร็จ
|
|
mockRedlockAcquire.mockReset().mockResolvedValue({
|
|
release: mockRedlockRelease,
|
|
});
|
|
mockRedlockRelease.mockClear();
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
WorkflowEngineService,
|
|
{
|
|
provide: getRepositoryToken(WorkflowDefinition),
|
|
useValue: {
|
|
findOne: jest.fn(),
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: getRepositoryToken(WorkflowInstance),
|
|
useValue: {
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
findOne: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: getRepositoryToken(WorkflowHistory),
|
|
useValue: {
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
find: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: getRepositoryToken(Attachment),
|
|
useValue: {
|
|
find: jest.fn(),
|
|
update: jest.fn(),
|
|
},
|
|
},
|
|
{ provide: WorkflowDslService, useValue: mockDslService },
|
|
{ provide: WorkflowEventService, useValue: mockEventService },
|
|
{ provide: DataSource, useValue: mockDataSource },
|
|
{
|
|
provide: CACHE_MANAGER,
|
|
useValue: {
|
|
get: jest.fn().mockResolvedValue(null),
|
|
set: jest.fn().mockResolvedValue(undefined),
|
|
del: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
},
|
|
// ADR-021 C1: Redis mock สำหรับ @InjectRedis()
|
|
{
|
|
provide: DEFAULT_REDIS_TOKEN,
|
|
useValue: {
|
|
// ไม่จำเป็นต้องมี method จริง เพราะ Redlock ถูก mock แล้ว
|
|
},
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<WorkflowEngineService>(WorkflowEngineService);
|
|
defRepo = module.get(getRepositoryToken(WorkflowDefinition));
|
|
instanceRepo = module.get(getRepositoryToken(WorkflowInstance));
|
|
dslService = module.get(WorkflowDslService);
|
|
eventService = module.get(WorkflowEventService);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe('createDefinition', () => {
|
|
it('should create a new definition version', async () => {
|
|
const dto = {
|
|
workflow_code: 'WF01',
|
|
dsl: {},
|
|
} as CreateWorkflowDefinitionDto;
|
|
mockDslService.compile.mockReturnValue(mockCompiledWorkflow);
|
|
(defRepo.findOne as jest.Mock).mockResolvedValue({ version: 1 });
|
|
(defRepo.create as jest.Mock).mockReturnValue({ version: 2 });
|
|
(defRepo.save as jest.Mock).mockResolvedValue({
|
|
version: 2,
|
|
workflow_code: 'WF01',
|
|
});
|
|
|
|
const result = await service.createDefinition(dto);
|
|
|
|
expect(dslService.compile).toHaveBeenCalledWith(dto.dsl);
|
|
expect(defRepo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ version: 2 })
|
|
);
|
|
expect(result).toEqual(expect.objectContaining({ version: 2 }));
|
|
});
|
|
});
|
|
|
|
describe('createInstance', () => {
|
|
it('should create a new instance with initial state', async () => {
|
|
const mockDef = {
|
|
id: 'def-1',
|
|
compiled: mockCompiledWorkflow,
|
|
};
|
|
|
|
(defRepo.findOne as jest.Mock).mockResolvedValue(mockDef);
|
|
(instanceRepo.create as jest.Mock).mockReturnValue({
|
|
id: 'inst-1',
|
|
currentState: 'START',
|
|
});
|
|
(instanceRepo.save as jest.Mock).mockResolvedValue({ id: 'inst-1' });
|
|
|
|
const result = await service.createInstance('WF01', 'DOC', '101');
|
|
|
|
expect(instanceRepo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
currentState: 'START',
|
|
entityId: '101',
|
|
})
|
|
);
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it('should throw NotFoundException if definition not found', async () => {
|
|
(defRepo.findOne as jest.Mock).mockResolvedValue(null);
|
|
await expect(
|
|
service.createInstance('WF01', 'DOC', '101')
|
|
).rejects.toThrow(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('processTransition', () => {
|
|
it('should process transition successfully and commit transaction', async () => {
|
|
const instanceId = 'inst-1';
|
|
const mockInstance = {
|
|
id: instanceId,
|
|
currentState: 'PENDING',
|
|
status: WorkflowStatus.ACTIVE,
|
|
definition: { compiled: mockCompiledWorkflow },
|
|
context: { some: 'data' },
|
|
};
|
|
|
|
// Mock Pessimistic Lock Find
|
|
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
|
|
|
// Mock DSL Evaluation
|
|
mockDslService.evaluate.mockReturnValue({
|
|
nextState: 'APPROVED',
|
|
events: [{ type: 'NOTIFY' }],
|
|
});
|
|
|
|
const result = await service.processTransition(instanceId, 'APPROVE', 1);
|
|
|
|
expect(mockQueryRunner.startTransaction).toHaveBeenCalled();
|
|
expect(mockDslService.evaluate).toHaveBeenCalled();
|
|
expect(mockQueryRunner.manager.save).toHaveBeenCalledTimes(2); // Instance + History
|
|
expect(mockQueryRunner.commitTransaction).toHaveBeenCalled();
|
|
expect(eventService.dispatchEvents).toHaveBeenCalled(); // Should dispatch events
|
|
expect(result.nextState).toBe('APPROVED');
|
|
expect(result.isCompleted).toBe(true);
|
|
});
|
|
|
|
it('should rollback transaction on error', async () => {
|
|
mockQueryRunner.manager.findOne.mockRejectedValue(new Error('DB Error'));
|
|
|
|
await expect(
|
|
service.processTransition('inst-1', 'APPROVE', 1)
|
|
).rejects.toThrow('DB Error');
|
|
|
|
expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled();
|
|
expect(mockQueryRunner.release).toHaveBeenCalled();
|
|
});
|
|
|
|
// ADR-021 T031: Tests for step-specific attachments
|
|
describe('ADR-021 Step-specific Attachments', () => {
|
|
it('should link attachments to workflow history record', async () => {
|
|
const instanceId = 'inst-1';
|
|
const attachmentPublicIds = ['att-123', 'att-456'];
|
|
const mockInstance = {
|
|
id: instanceId,
|
|
currentState: 'PENDING_REVIEW', // ADR-021 C3: allowed upload state
|
|
status: WorkflowStatus.ACTIVE,
|
|
definition: { compiled: mockCompiledWorkflow },
|
|
context: { some: 'data' },
|
|
};
|
|
|
|
// C3 pre-check ดึง instance จาก instanceRepo.findOne (ไม่ใช่ queryRunner)
|
|
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
|
id: instanceId,
|
|
currentState: 'PENDING_REVIEW',
|
|
});
|
|
|
|
// Mock the history object with an ID
|
|
const mockHistory = { id: 'history-123' };
|
|
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
|
mockQueryRunner.manager.save.mockResolvedValue(mockHistory);
|
|
// C2: update ต้องรายงาน affected = attachmentPublicIds.length
|
|
mockQueryRunner.manager.update.mockResolvedValue({
|
|
affected: attachmentPublicIds.length,
|
|
});
|
|
|
|
mockDslService.evaluate.mockReturnValue({
|
|
nextState: 'APPROVED',
|
|
events: [],
|
|
});
|
|
|
|
await service.processTransition(
|
|
instanceId,
|
|
'APPROVE',
|
|
1,
|
|
'Test comment',
|
|
{},
|
|
attachmentPublicIds
|
|
);
|
|
|
|
// C2: where clause ต้องมี guards ครบ 3 ชั้น
|
|
expect(mockQueryRunner.manager.update).toHaveBeenCalledWith(
|
|
Attachment,
|
|
{
|
|
publicId: In(attachmentPublicIds),
|
|
isTemporary: false,
|
|
uploadedByUserId: 1,
|
|
workflowHistoryId: null,
|
|
},
|
|
{ workflowHistoryId: 'history-123' }
|
|
);
|
|
});
|
|
|
|
it('should skip attachment linking when no attachmentPublicIds provided', async () => {
|
|
const instanceId = 'inst-1';
|
|
const mockInstance = {
|
|
id: instanceId,
|
|
currentState: 'PENDING',
|
|
status: WorkflowStatus.ACTIVE,
|
|
definition: { compiled: mockCompiledWorkflow },
|
|
context: { some: 'data' },
|
|
};
|
|
|
|
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
|
mockDslService.evaluate.mockReturnValue({
|
|
nextState: 'APPROVED',
|
|
events: [],
|
|
});
|
|
|
|
await service.processTransition(instanceId, 'APPROVE', 1);
|
|
|
|
expect(mockQueryRunner.manager.update).not.toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.any(Object),
|
|
expect.objectContaining({ workflowHistoryId: expect.any(String) })
|
|
);
|
|
});
|
|
|
|
it('should handle empty attachmentPublicIds array', async () => {
|
|
const instanceId = 'inst-1';
|
|
const mockInstance = {
|
|
id: instanceId,
|
|
currentState: 'PENDING',
|
|
status: WorkflowStatus.ACTIVE,
|
|
definition: { compiled: mockCompiledWorkflow },
|
|
context: { some: 'data' },
|
|
};
|
|
|
|
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
|
mockDslService.evaluate.mockReturnValue({
|
|
nextState: 'APPROVED',
|
|
events: [],
|
|
});
|
|
|
|
await service.processTransition(
|
|
instanceId,
|
|
'APPROVE',
|
|
1,
|
|
'Test comment',
|
|
{},
|
|
[] // Empty array
|
|
);
|
|
|
|
expect(mockQueryRunner.manager.update).not.toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.any(Object),
|
|
expect.objectContaining({ workflowHistoryId: expect.any(String) })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// ADR-021 T031a: Clarify Session 2026-04-19 Amendments
|
|
// ============================================================
|
|
describe('ADR-021 Clarify Q1+Q2 (T031a) — state check, Redlock, guards', () => {
|
|
const attachmentPublicIds = ['att-1'];
|
|
|
|
it('C3: should throw ConflictException (409) when uploading in APPROVED state', async () => {
|
|
// Arrange: currentState = APPROVED (terminal, ไม่อยู่ใน UPLOAD_ALLOWED_STATES)
|
|
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
|
id: 'inst-1',
|
|
currentState: 'APPROVED',
|
|
});
|
|
|
|
// Act + Assert
|
|
await expect(
|
|
service.processTransition(
|
|
'inst-1',
|
|
'APPROVE',
|
|
1,
|
|
undefined,
|
|
{},
|
|
attachmentPublicIds
|
|
)
|
|
).rejects.toThrow(ConflictException);
|
|
|
|
// Redlock ต้องไม่ถูกเรียก (pre-check บล็อกก่อน)
|
|
expect(mockRedlockAcquire).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('C3: should throw ConflictException (409) when uploading in REJECTED state', async () => {
|
|
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
|
id: 'inst-1',
|
|
currentState: 'REJECTED',
|
|
});
|
|
|
|
await expect(
|
|
service.processTransition(
|
|
'inst-1',
|
|
'APPROVE',
|
|
1,
|
|
undefined,
|
|
{},
|
|
attachmentPublicIds
|
|
)
|
|
).rejects.toThrow(ConflictException);
|
|
});
|
|
|
|
it('C3: should skip state check when attachmentPublicIds is empty', async () => {
|
|
// ถ้าไม่มี attachment ไม่ต้องตรวจ state — transition ในสถานะไหนก็ได้
|
|
const mockInstance = {
|
|
id: 'inst-1',
|
|
currentState: 'DRAFT',
|
|
status: WorkflowStatus.ACTIVE,
|
|
definition: { compiled: mockCompiledWorkflow },
|
|
context: {},
|
|
};
|
|
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
|
mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-1' });
|
|
mockDslService.evaluate.mockReturnValue({
|
|
nextState: 'PENDING',
|
|
events: [],
|
|
});
|
|
|
|
await expect(
|
|
service.processTransition('inst-1', 'SUBMIT', 1)
|
|
).resolves.toBeDefined();
|
|
|
|
// pre-check ต้องไม่ถูกเรียก (ไม่มี attachments)
|
|
expect(instanceRepo.findOne).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('C1: should throw ServiceUnavailableException (503) when Redlock acquire fails', async () => {
|
|
// Arrange: state check ผ่าน
|
|
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
|
id: 'inst-1',
|
|
currentState: 'PENDING_REVIEW',
|
|
});
|
|
// Redlock ล้มเหลว — Redis ล่ม / ไม่สามารถ acquire หลัง retry 3 ครั้ง
|
|
mockRedlockAcquire.mockRejectedValue(
|
|
new Error('ExecutionError: unable to achieve quorum')
|
|
);
|
|
|
|
// Act + Assert
|
|
await expect(
|
|
service.processTransition(
|
|
'inst-1',
|
|
'APPROVE',
|
|
1,
|
|
undefined,
|
|
{},
|
|
attachmentPublicIds
|
|
)
|
|
).rejects.toThrow(ServiceUnavailableException);
|
|
|
|
// DB transaction ต้องไม่เคยเริ่ม (fail-closed before DB work)
|
|
expect(mockQueryRunner.startTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('C2: should rollback and throw when update.affected < expected (temp/foreign attachment)', async () => {
|
|
// Arrange: state ผ่าน, Redlock ผ่าน, DB transaction เดินไปถึง update
|
|
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
|
id: 'inst-1',
|
|
currentState: 'PENDING_APPROVAL',
|
|
});
|
|
mockQueryRunner.manager.findOne.mockResolvedValue({
|
|
id: 'inst-1',
|
|
currentState: 'PENDING_APPROVAL',
|
|
status: WorkflowStatus.ACTIVE,
|
|
definition: { compiled: mockCompiledWorkflow },
|
|
context: {},
|
|
});
|
|
mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-999' });
|
|
mockDslService.evaluate.mockReturnValue({
|
|
nextState: 'APPROVED',
|
|
events: [],
|
|
});
|
|
// affected < expected — แปลว่ามีไฟล์บางไฟล์ temp / ของคนอื่น / ผูกไปแล้ว
|
|
mockQueryRunner.manager.update.mockResolvedValue({ affected: 1 });
|
|
|
|
await expect(
|
|
service.processTransition(
|
|
'inst-1',
|
|
'APPROVE',
|
|
1,
|
|
undefined,
|
|
{},
|
|
['att-1', 'att-2', 'att-3'] // ขอ 3 ไฟล์ แต่ affected = 1
|
|
)
|
|
).rejects.toThrow(WorkflowException);
|
|
|
|
// ต้อง rollback
|
|
expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled();
|
|
// ต้องไม่ commit
|
|
expect(mockQueryRunner.commitTransaction).not.toHaveBeenCalled();
|
|
// ต้อง release Redlock
|
|
expect(mockRedlockRelease).toHaveBeenCalled();
|
|
});
|
|
|
|
it('C1: should release Redlock even when transition succeeds', async () => {
|
|
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
|
id: 'inst-1',
|
|
currentState: 'PENDING_REVIEW',
|
|
});
|
|
mockQueryRunner.manager.findOne.mockResolvedValue({
|
|
id: 'inst-1',
|
|
currentState: 'PENDING_REVIEW',
|
|
status: WorkflowStatus.ACTIVE,
|
|
definition: { compiled: mockCompiledWorkflow },
|
|
context: {},
|
|
});
|
|
mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-1' });
|
|
mockQueryRunner.manager.update.mockResolvedValue({ affected: 1 });
|
|
mockDslService.evaluate.mockReturnValue({
|
|
nextState: 'APPROVED',
|
|
events: [],
|
|
});
|
|
|
|
await service.processTransition('inst-1', 'APPROVE', 1, undefined, {}, [
|
|
'att-1',
|
|
]);
|
|
|
|
expect(mockRedlockAcquire).toHaveBeenCalledWith(
|
|
['lock:wf:transition:inst-1'],
|
|
10000
|
|
);
|
|
expect(mockRedlockRelease).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
});
|