251206:1400 version 1.5.1
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Repository } from 'typeorm';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { WorkflowDslParser } from './parser.service';
|
||||
import { WorkflowDefinition } from '../entities/workflow-definition.entity';
|
||||
import { RFA_WORKFLOW_EXAMPLE } from './workflow-dsl.schema';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
describe('WorkflowDslParser', () => {
|
||||
let parser: WorkflowDslParser;
|
||||
let mockRepository: Partial<Repository<WorkflowDefinition>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRepository = {
|
||||
save: jest.fn((def) => Promise.resolve(def)),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkflowDslParser,
|
||||
{
|
||||
provide: getRepositoryToken(WorkflowDefinition),
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
parser = module.get<WorkflowDslParser>(WorkflowDslParser);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(parser).toBeDefined();
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
it('should parse valid RFA workflow DSL', async () => {
|
||||
const dslJson = JSON.stringify(RFA_WORKFLOW_EXAMPLE);
|
||||
|
||||
const result = await parser.parse(dslJson);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe('RFA_APPROVAL');
|
||||
expect(result.version).toBe('1.0.0');
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(mockRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid JSON', async () => {
|
||||
const invalidJson = '{ invalid json }';
|
||||
|
||||
await expect(parser.parse(invalidJson)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject workflow with invalid state reference', async () => {
|
||||
const invalidDsl = {
|
||||
name: 'INVALID',
|
||||
version: '1.0.0',
|
||||
states: ['DRAFT', 'APPROVED'],
|
||||
initialState: 'DRAFT',
|
||||
finalStates: ['APPROVED'],
|
||||
transitions: [
|
||||
{
|
||||
from: 'DRAFT',
|
||||
to: 'NONEXISTENT_STATE', // Invalid state
|
||||
trigger: 'SUBMIT',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject workflow with invalid initial state', async () => {
|
||||
const invalidDsl = {
|
||||
name: 'INVALID',
|
||||
version: '1.0.0',
|
||||
states: ['DRAFT', 'APPROVED'],
|
||||
initialState: 'NONEXISTENT', // Invalid
|
||||
finalStates: ['APPROVED'],
|
||||
transitions: [],
|
||||
};
|
||||
|
||||
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject workflow with invalid final state', async () => {
|
||||
const invalidDsl = {
|
||||
name: 'INVALID',
|
||||
version: '1.0.0',
|
||||
states: ['DRAFT', 'APPROVED'],
|
||||
initialState: 'DRAFT',
|
||||
finalStates: ['NONEXISTENT'], // Invalid
|
||||
transitions: [],
|
||||
};
|
||||
|
||||
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject workflow with duplicate transitions', async () => {
|
||||
const invalidDsl = {
|
||||
name: 'INVALID',
|
||||
version: '1.0.0',
|
||||
states: ['DRAFT', 'SUBMITTED'],
|
||||
initialState: 'DRAFT',
|
||||
finalStates: ['SUBMITTED'],
|
||||
transitions: [
|
||||
{ from: 'DRAFT', to: 'SUBMITTED', trigger: 'SUBMIT' },
|
||||
{ from: 'DRAFT', to: 'SUBMITTED', trigger: 'SUBMIT' }, // Duplicate
|
||||
],
|
||||
};
|
||||
|
||||
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject workflow with invalid version format', async () => {
|
||||
const invalidDsl = {
|
||||
...RFA_WORKFLOW_EXAMPLE,
|
||||
version: 'invalid-version',
|
||||
};
|
||||
|
||||
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOnly', () => {
|
||||
it('should validate correct DSL without saving', () => {
|
||||
const dslJson = JSON.stringify(RFA_WORKFLOW_EXAMPLE);
|
||||
|
||||
const result = parser.validateOnly(dslJson);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toBeUndefined();
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for invalid DSL', () => {
|
||||
const invalidDsl = {
|
||||
name: 'INVALID',
|
||||
version: '1.0.0',
|
||||
states: ['DRAFT'],
|
||||
initialState: 'NONEXISTENT',
|
||||
finalStates: [],
|
||||
transitions: [],
|
||||
};
|
||||
|
||||
const result = parser.validateOnly(JSON.stringify(invalidDsl));
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParsedDsl', () => {
|
||||
it('should retrieve and parse stored DSL', async () => {
|
||||
const storedDefinition = {
|
||||
id: 1,
|
||||
name: 'RFA_APPROVAL',
|
||||
version: '1.0.0',
|
||||
dslContent: JSON.stringify(RFA_WORKFLOW_EXAMPLE),
|
||||
};
|
||||
|
||||
mockRepository.findOne = jest.fn().mockResolvedValue(storedDefinition);
|
||||
|
||||
const result = await parser.getParsedDsl(1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe('RFA_APPROVAL');
|
||||
});
|
||||
|
||||
it('should throw error if definition not found', async () => {
|
||||
mockRepository.findOne = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await expect(parser.getParsedDsl(999)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { WorkflowDslSchema, WorkflowDsl } from './workflow-dsl.schema';
|
||||
import { WorkflowDefinition } from '../entities/workflow-definition.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowDslParser {
|
||||
private readonly logger = new Logger(WorkflowDslParser.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkflowDefinition)
|
||||
private workflowDefRepo: Repository<WorkflowDefinition>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse และ Validate Workflow DSL from JSON string
|
||||
* @param dslJson JSON string ของ Workflow DSL
|
||||
* @returns WorkflowDefinition entity พร้อมบันทึกลง database
|
||||
*/
|
||||
async parse(dslJson: string): Promise<WorkflowDefinition> {
|
||||
try {
|
||||
// Step 1: Parse JSON
|
||||
const rawDsl = JSON.parse(dslJson);
|
||||
|
||||
// Step 2: Validate with Zod schema
|
||||
const dsl = WorkflowDslSchema.parse(rawDsl);
|
||||
|
||||
// Step 3: Validate state machine integrity
|
||||
this.validateStateMachine(dsl);
|
||||
|
||||
// Step 4: Build WorkflowDefinition entity
|
||||
const definition = this.buildDefinition(dsl);
|
||||
|
||||
// Step 5: Save to database
|
||||
return await this.workflowDefRepo.save(definition);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new BadRequestException(`Invalid JSON: ${error.message}`);
|
||||
}
|
||||
if (error.name === 'ZodError') {
|
||||
throw new BadRequestException(
|
||||
`Invalid workflow DSL: ${JSON.stringify(error.errors)}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate state machine integrity
|
||||
* ตรวจสอบว่า state machine ถูกต้องตามหลักการ:
|
||||
* - All states in transitions must exist in states array
|
||||
* - Initial state must exist
|
||||
* - All final states must exist
|
||||
* - No dead-end states (states with no outgoing transitions except final states)
|
||||
*/
|
||||
private validateStateMachine(dsl: WorkflowDsl): void {
|
||||
const stateSet = new Set(dsl.states);
|
||||
const finalStateSet = new Set(dsl.finalStates);
|
||||
|
||||
// 1. Validate initial state
|
||||
if (!stateSet.has(dsl.initialState)) {
|
||||
throw new BadRequestException(
|
||||
`Initial state "${dsl.initialState}" not found in states array`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Validate final states
|
||||
dsl.finalStates.forEach((state) => {
|
||||
if (!stateSet.has(state)) {
|
||||
throw new BadRequestException(
|
||||
`Final state "${state}" not found in states array`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Validate transitions
|
||||
const statesWithOutgoing = new Set<string>();
|
||||
|
||||
dsl.transitions.forEach((transition, index) => {
|
||||
// Check 'from' state
|
||||
if (!stateSet.has(transition.from)) {
|
||||
throw new BadRequestException(
|
||||
`Transition ${index}: 'from' state "${transition.from}" not found in states array`
|
||||
);
|
||||
}
|
||||
|
||||
// Check 'to' state
|
||||
if (!stateSet.has(transition.to)) {
|
||||
throw new BadRequestException(
|
||||
`Transition ${index}: 'to' state "${transition.to}" not found in states array`
|
||||
);
|
||||
}
|
||||
|
||||
// Track states with outgoing transitions
|
||||
statesWithOutgoing.add(transition.from);
|
||||
});
|
||||
|
||||
// 4. Check for dead-end states (except final states)
|
||||
const nonFinalStates = dsl.states.filter(
|
||||
(state) => !finalStateSet.has(state)
|
||||
);
|
||||
|
||||
nonFinalStates.forEach((state) => {
|
||||
if (!statesWithOutgoing.has(state) && state !== dsl.initialState) {
|
||||
this.logger.warn(
|
||||
`Warning: State "${state}" has no outgoing transitions (potential dead-end)`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Check for duplicate transitions
|
||||
const transitionKeys = new Set<string>();
|
||||
dsl.transitions.forEach((transition) => {
|
||||
const key = `${transition.from}-${transition.trigger}-${transition.to}`;
|
||||
if (transitionKeys.has(key)) {
|
||||
throw new BadRequestException(
|
||||
`Duplicate transition: ${transition.from} --[${transition.trigger}]--> ${transition.to}`
|
||||
);
|
||||
}
|
||||
transitionKeys.add(key);
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Workflow "${dsl.name}" v${dsl.version} validated successfully`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WorkflowDefinition entity from validated DSL
|
||||
*/
|
||||
private buildDefinition(dsl: WorkflowDsl): WorkflowDefinition {
|
||||
const definition = new WorkflowDefinition();
|
||||
definition.name = dsl.name;
|
||||
definition.version = dsl.version;
|
||||
definition.description = dsl.description;
|
||||
definition.dslContent = JSON.stringify(dsl, null, 2); // Pretty print for readability
|
||||
definition.isActive = true;
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsed DSL from WorkflowDefinition
|
||||
*/
|
||||
async getParsedDsl(definitionId: number): Promise<WorkflowDsl> {
|
||||
const definition = await this.workflowDefRepo.findOne({
|
||||
where: { id: definitionId },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new BadRequestException(
|
||||
`Workflow definition ${definitionId} not found`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dsl = JSON.parse(definition.dslContent);
|
||||
return WorkflowDslSchema.parse(dsl);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to parse stored DSL for definition ${definitionId}`,
|
||||
error
|
||||
);
|
||||
throw new BadRequestException(`Invalid stored DSL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate DSL without saving (dry-run)
|
||||
*/
|
||||
validateOnly(dslJson: string): { valid: boolean; errors?: string[] } {
|
||||
try {
|
||||
const rawDsl = JSON.parse(dslJson);
|
||||
const dsl = WorkflowDslSchema.parse(rawDsl);
|
||||
this.validateStateMachine(dsl);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod Schema สำหรับ Workflow DSL
|
||||
* ตาม ADR-001: Unified Workflow Engine
|
||||
*/
|
||||
|
||||
// Guard Schema
|
||||
export const GuardSchema = z.object({
|
||||
type: z.enum(['permission', 'condition', 'script']),
|
||||
config: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
export type WorkflowGuard = z.infer<typeof GuardSchema>;
|
||||
|
||||
// Effect Schema
|
||||
export const EffectSchema = z.object({
|
||||
type: z.enum([
|
||||
'update_status',
|
||||
'send_email',
|
||||
'send_line',
|
||||
'create_notification',
|
||||
'assign_user',
|
||||
'update_field',
|
||||
]),
|
||||
config: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
export type WorkflowEffect = z.infer<typeof EffectSchema>;
|
||||
|
||||
// Transition Schema
|
||||
export const TransitionSchema = z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
trigger: z.string(),
|
||||
label: z.string().optional(),
|
||||
guards: z.array(GuardSchema).optional(),
|
||||
effects: z.array(EffectSchema).optional(),
|
||||
});
|
||||
|
||||
export type WorkflowTransition = z.infer<typeof TransitionSchema>;
|
||||
|
||||
// Main Workflow DSL Schema
|
||||
export const WorkflowDslSchema = z.object({
|
||||
name: z.string().min(1, 'Workflow name is required'),
|
||||
version: z
|
||||
.string()
|
||||
.regex(/^\d+\.\d+(\.\d+)?$/, 'Version must be semver format (e.g., 1.0.0)'),
|
||||
description: z.string().optional(),
|
||||
|
||||
states: z.array(z.string()).min(1, 'At least one state is required'),
|
||||
|
||||
initialState: z.string(),
|
||||
|
||||
finalStates: z
|
||||
.array(z.string())
|
||||
.min(1, 'At least one final state is required'),
|
||||
|
||||
transitions: z
|
||||
.array(TransitionSchema)
|
||||
.min(1, 'At least one transition is required'),
|
||||
|
||||
metadata: z.record(z.string(), z.any()).optional(),
|
||||
});
|
||||
|
||||
export type WorkflowDsl = z.infer<typeof WorkflowDslSchema>;
|
||||
|
||||
/**
|
||||
* ตัวอย่าง RFA Workflow DSL
|
||||
*/
|
||||
export const RFA_WORKFLOW_EXAMPLE: WorkflowDsl = {
|
||||
name: 'RFA_APPROVAL',
|
||||
version: '1.0.0',
|
||||
description: 'Request for Approval workflow for construction projects',
|
||||
states: [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
'REVISED',
|
||||
],
|
||||
initialState: 'DRAFT',
|
||||
finalStates: ['APPROVED', 'REJECTED'],
|
||||
transitions: [
|
||||
{
|
||||
from: 'DRAFT',
|
||||
to: 'SUBMITTED',
|
||||
trigger: 'SUBMIT',
|
||||
label: 'Submit for approval',
|
||||
guards: [
|
||||
{
|
||||
type: 'permission',
|
||||
config: { permission: 'rfa.submit' },
|
||||
},
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
type: 'update_status',
|
||||
config: { status: 'SUBMITTED' },
|
||||
},
|
||||
{
|
||||
type: 'send_email',
|
||||
config: {
|
||||
to: 'reviewer@example.com',
|
||||
template: 'rfa_submitted',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: 'SUBMITTED',
|
||||
to: 'UNDER_REVIEW',
|
||||
trigger: 'START_REVIEW',
|
||||
label: 'Start review',
|
||||
guards: [
|
||||
{
|
||||
type: 'permission',
|
||||
config: { permission: 'rfa.review' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: 'UNDER_REVIEW',
|
||||
to: 'APPROVED',
|
||||
trigger: 'APPROVE',
|
||||
label: 'Approve',
|
||||
guards: [
|
||||
{
|
||||
type: 'permission',
|
||||
config: { permission: 'rfa.approve' },
|
||||
},
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
type: 'update_status',
|
||||
config: { status: 'APPROVED' },
|
||||
},
|
||||
{
|
||||
type: 'send_notification',
|
||||
config: {
|
||||
message: 'RFA has been approved',
|
||||
type: 'success',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: 'UNDER_REVIEW',
|
||||
to: 'REJECTED',
|
||||
trigger: 'REJECT',
|
||||
label: 'Reject',
|
||||
guards: [
|
||||
{
|
||||
type: 'permission',
|
||||
config: { permission: 'rfa.approve' },
|
||||
},
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
type: 'update_status',
|
||||
config: { status: 'REJECTED' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: 'UNDER_REVIEW',
|
||||
to: 'REVISED',
|
||||
trigger: 'REQUEST_REVISION',
|
||||
label: 'Request revision',
|
||||
effects: [
|
||||
{
|
||||
type: 'create_notification',
|
||||
config: {
|
||||
message: 'Please revise and resubmit',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: 'REVISED',
|
||||
to: 'SUBMITTED',
|
||||
trigger: 'RESUBMIT',
|
||||
label: 'Resubmit after revision',
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
documentType: 'RFA',
|
||||
estimatedDuration: '5 days',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user