251209:0000 Backend Test stagenot finish & Frontend add Task 013-015
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
2025-12-09 00:00:28 +07:00
parent 863a727756
commit 8aceced902
23 changed files with 3571 additions and 118 deletions

View File

@@ -0,0 +1,30 @@
services:
mariadb_test:
image: mariadb:11.8
container_name: mariadb-test
restart: always
environment:
MYSQL_ROOT_PASSWORD: Center#2025
MYSQL_DATABASE: lcbp3_test
MYSQL_USER: admin
MYSQL_PASSWORD: Center2025
ports:
- '3307:3306'
tmpfs:
- /var/lib/mysql
networks:
- lcbp3-test-net
redis_test:
image: redis:7-alpine
container_name: redis-test
restart: always
command: redis-server --requirepass "Center2025"
ports:
- '6380:6379'
networks:
- lcbp3-test-net
networks:
lcbp3-test-net:
driver: bridge

View File

@@ -7,13 +7,15 @@
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"doc": "npx @compodoc/compodoc -p tsconfig.doc.json -s",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test": "jest --forceExit",
"test:debug-handles": "jest --detectOpenHandles",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
@@ -72,11 +74,12 @@
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27",
"uuid": "^13.0.0",
"uuid": "^9.0.1",
"winston": "^3.18.3",
"zod": "^4.1.13"
},
"devDependencies": {
"@compodoc/compodoc": "^1.1.32",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
@@ -94,7 +97,7 @@
"@types/opossum": "^8.1.9",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"@types/uuid": "^9.0.8",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",

View File

@@ -102,7 +102,16 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout (Revoke Tokens)' })
@ApiResponse({ status: 200, description: 'Logged out successfully' })
@ApiResponse({
status: 200,
description: 'Logged out successfully',
schema: {
type: 'object',
properties: {
message: { type: 'string', example: 'Logged out successfully' },
},
},
})
async logout(@Req() req: RequestWithUser) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {

View File

@@ -1,18 +1,202 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UserService } from '../../modules/user/user.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from '../../modules/user/entities/user.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { UnauthorizedException } from '@nestjs/common';
describe('AuthService', () => {
let service: AuthService;
let userService: UserService;
let jwtService: JwtService;
let tokenRepo: Repository<RefreshToken>;
const mockUser = {
user_id: 1,
username: 'testuser',
password: 'hashedpassword',
primaryOrganizationId: 1,
};
const mockQueryBuilder = {
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(mockUser),
};
const mockUserRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};
const mockTokenRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
providers: [
AuthService,
{
provide: UserService,
useValue: {
findOneByUsername: jest.fn(),
create: jest.fn(),
findOne: jest.fn(),
},
},
{
provide: JwtService,
useValue: {
signAsync: jest.fn().mockResolvedValue('jwt_token'),
decode: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key) => {
if (key.includes('EXPIRATION')) return '1h';
return 'secret';
}),
},
},
{
provide: CACHE_MANAGER,
useValue: {
set: jest.fn(),
},
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepo,
},
{
provide: getRepositoryToken(RefreshToken),
useValue: mockTokenRepo,
},
],
}).compile();
service = module.get<AuthService>(AuthService);
userService = module.get<UserService>(UserService);
jwtService = module.get<JwtService>(JwtService);
tokenRepo = module.get(getRepositoryToken(RefreshToken));
// Mock bcrypt
jest
.spyOn(bcrypt, 'compare')
.mockImplementation(() => Promise.resolve(true));
jest
.spyOn(bcrypt, 'hash')
.mockImplementation(() => Promise.resolve('hashedpassword'));
jest
.spyOn(bcrypt, 'genSalt')
.mockImplementation(() => Promise.resolve('salt'));
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('validateUser', () => {
it('should return user without password if validation succeeds', async () => {
const result = await service.validateUser('testuser', 'password');
expect(result).toBeDefined();
expect(result).not.toHaveProperty('password');
expect(result.username).toBe('testuser');
});
it('should return null if user not found', async () => {
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
const result = await service.validateUser('unknown', 'password');
expect(result).toBeNull();
});
it('should return null if password mismatch', async () => {
jest
.spyOn(bcrypt, 'compare')
.mockImplementation(() => Promise.resolve(false));
const result = await service.validateUser('testuser', 'wrongpassword');
expect(result).toBeNull();
});
});
describe('login', () => {
it('should return access and refresh tokens', async () => {
mockTokenRepo.create.mockReturnValue({ id: 1 });
mockTokenRepo.save.mockResolvedValue({ id: 1 });
const result = await service.login(mockUser);
expect(result).toHaveProperty('access_token');
expect(result).toHaveProperty('refresh_token');
expect(mockTokenRepo.save).toHaveBeenCalled();
});
});
describe('register', () => {
it('should register a new user', async () => {
(userService.findOneByUsername as jest.Mock).mockResolvedValue(null);
(userService.create as jest.Mock).mockResolvedValue(mockUser);
const dto = {
username: 'newuser',
password: 'password',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
};
const result = await service.register(dto);
expect(result).toBeDefined();
expect(userService.create).toHaveBeenCalled();
});
});
describe('refreshToken', () => {
it('should return new tokens if valid', async () => {
const mockStoredToken = {
tokenHash: 'somehash',
isRevoked: false,
expiresAt: new Date(Date.now() + 10000),
};
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
(userService.findOne as jest.Mock).mockResolvedValue(mockUser);
const result = await service.refreshToken(1, 'valid_refresh_token');
expect(result.access_token).toBeDefined();
expect(result.refresh_token).toBeDefined();
// Should mark old token as revoked
expect(mockTokenRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ isRevoked: true })
);
});
it('should throw UnauthorizedException if token revoked', async () => {
const mockStoredToken = {
tokenHash: 'somehash',
isRevoked: true,
expiresAt: new Date(Date.now() + 10000),
};
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
await expect(service.refreshToken(1, 'revoked_token')).rejects.toThrow(
UnauthorizedException
);
});
});
});

View File

@@ -1,18 +1,142 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageService } from './file-storage.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Attachment } from './entities/attachment.entity';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs-extra';
import {
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { Repository } from 'typeorm';
// Mock fs-extra
jest.mock('fs-extra');
describe('FileStorageService', () => {
let service: FileStorageService;
let attachmentRepo: Repository<Attachment>;
const mockAttachment = {
id: 1,
originalFilename: 'test.pdf',
storedFilename: 'uuid.pdf',
filePath: '/permanent/2024/12/uuid.pdf',
fileSize: 1024,
uploadedByUserId: 1,
} as Attachment;
const mockFile = {
originalname: 'test.pdf',
mimetype: 'application/pdf',
size: 1024,
buffer: Buffer.from('test-content'),
} as Express.Multer.File;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FileStorageService],
providers: [
FileStorageService,
{
provide: getRepositoryToken(Attachment),
useValue: {
create: jest.fn().mockReturnValue(mockAttachment),
save: jest.fn().mockResolvedValue(mockAttachment),
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn((key) => {
if (key === 'NODE_ENV') return 'test';
return null;
}),
},
},
],
}).compile();
service = module.get<FileStorageService>(FileStorageService);
attachmentRepo = module.get(getRepositoryToken(Attachment));
jest.clearAllMocks();
(fs.ensureDirSync as jest.Mock).mockReturnValue(true);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.pathExists as jest.Mock).mockResolvedValue(true);
(fs.move as jest.Mock).mockResolvedValue(undefined);
(fs.remove as jest.Mock).mockResolvedValue(undefined);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('upload', () => {
it('should save file to temp and create DB record', async () => {
const result = await service.upload(mockFile, 1);
expect(fs.writeFile).toHaveBeenCalled();
expect(attachmentRepo.create).toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should throw BadRequestException if write fails', async () => {
(fs.writeFile as jest.Mock).mockRejectedValueOnce(
new Error('Write error')
);
await expect(service.upload(mockFile, 1)).rejects.toThrow(
BadRequestException
);
});
});
describe('commit', () => {
it('should move files to permanent storage', async () => {
const tempIds = ['uuid-1'];
const mockAttachments = [
{
...mockAttachment,
isTemporary: true,
tempId: 'uuid-1',
filePath: '/temp/uuid.pdf',
},
];
(attachmentRepo.find as jest.Mock).mockResolvedValue(mockAttachments);
await service.commit(tempIds);
expect(fs.ensureDir).toHaveBeenCalled();
expect(fs.move).toHaveBeenCalled();
expect(attachmentRepo.save).toHaveBeenCalled();
});
it('should show warning if file counts mismatch', async () => {
(attachmentRepo.find as jest.Mock).mockResolvedValue([]);
await expect(service.commit(['uuid-1'])).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete file if user owns it', async () => {
(attachmentRepo.findOne as jest.Mock).mockResolvedValue(mockAttachment);
await service.delete(1, 1);
expect(fs.remove).toHaveBeenCalled();
expect(attachmentRepo.remove).toHaveBeenCalled();
});
it('should throw ForbiddenException if user does not own file', async () => {
(attachmentRepo.findOne as jest.Mock).mockResolvedValue(mockAttachment);
await expect(service.delete(1, 999)).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CorrespondenceController } from './correspondence.controller';
import { CorrespondenceService } from './correspondence.service';
describe('CorrespondenceController', () => {
let controller: CorrespondenceController;
@@ -7,6 +8,20 @@ describe('CorrespondenceController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CorrespondenceController],
providers: [
{
provide: CorrespondenceService,
useValue: {
create: jest.fn(),
findAll: jest.fn(),
submit: jest.fn(),
processAction: jest.fn(),
getReferences: jest.fn(),
addReference: jest.fn(),
removeReference: jest.fn(),
},
},
],
}).compile();
controller = module.get<CorrespondenceController>(CorrespondenceController);

View File

@@ -5,91 +5,118 @@ import {
Body,
UseGuards,
Request,
Param, // <--- ✅ 1. เพิ่ม Param
ParseIntPipe, // <--- ✅ 2. เพิ่ม ParseIntPipe
Param,
ParseIntPipe,
Query,
Delete,
} from '@nestjs/common';
import { CorrespondenceService } from './correspondence.service.js';
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js';
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto.js'; // <--- ✅ 3. เพิ่ม Import DTO นี้
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { CorrespondenceService } from './correspondence.service';
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto';
import { WorkflowActionDto } from './dto/workflow-action.dto';
import { AddReferenceDto } from './dto/add-reference.dto';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { WorkflowActionDto } from './dto/workflow-action.dto.js';
// ... imports ...
import { AddReferenceDto } from './dto/add-reference.dto.js';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto.js';
import { Query, Delete } from '@nestjs/common'; // เพิ่ม Query, Delete
import { Audit } from '../../common/decorators/audit.decorator'; // Import
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator';
@ApiTags('Correspondences')
@Controller('correspondences')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
export class CorrespondenceController {
constructor(private readonly correspondenceService: CorrespondenceService) {}
@Post(':id/workflow/action')
@RequirePermission('workflow.action_review') // สิทธิ์ในการกดอนุมัติ/ตรวจสอบ
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
@RequirePermission('workflow.action_review')
processAction(
@Param('id', ParseIntPipe) id: number,
@Body() actionDto: WorkflowActionDto,
@Request() req: any,
@Request() req: any
) {
return this.correspondenceService.processAction(id, actionDto, req.user);
}
@Post()
@RequirePermission('correspondence.create') // 🔒 ต้องมีสิทธิ์สร้าง
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้
@ApiOperation({ summary: 'Create new correspondence' })
@ApiResponse({
status: 201,
description: 'Correspondence created successfully.',
type: CreateCorrespondenceDto,
})
@RequirePermission('correspondence.create')
@Audit('correspondence.create', 'correspondence')
create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) {
return this.correspondenceService.create(createDto, req.user);
}
// ✅ ปรับปรุง findAll ให้รับ Query Params
@Get()
@ApiOperation({ summary: 'Search correspondences' })
@ApiResponse({ status: 200, description: 'Return list of correspondences.' })
@RequirePermission('document.view')
findAll(@Query() searchDto: SearchCorrespondenceDto) {
return this.correspondenceService.findAll(searchDto);
}
// ✅ เพิ่ม Endpoint นี้ครับ
@Post(':id/submit')
@RequirePermission('correspondence.create') // หรือจะสร้าง Permission ใหม่ 'workflow.submit' ก็ได้
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้
@ApiOperation({ summary: 'Submit correspondence to workflow' })
@ApiResponse({
status: 201,
description: 'Correspondence submitted successfully.',
})
@RequirePermission('correspondence.create')
@Audit('correspondence.create', 'correspondence')
submit(
@Param('id', ParseIntPipe) id: number,
@Body() submitDto: SubmitCorrespondenceDto,
@Request() req: any,
@Request() req: any
) {
return this.correspondenceService.submit(
id,
submitDto.templateId,
req.user,
req.user
);
}
// --- REFERENCES ---
@Get(':id/references')
@ApiOperation({ summary: 'Get referenced documents' })
@ApiResponse({
status: 200,
description: 'Return list of referenced documents.',
})
@RequirePermission('document.view')
getReferences(@Param('id', ParseIntPipe) id: number) {
return this.correspondenceService.getReferences(id);
}
@Post(':id/references')
@RequirePermission('document.edit') // ต้องมีสิทธิ์แก้ไขถึงจะเพิ่ม Ref ได้
@ApiOperation({ summary: 'Add reference to another document' })
@ApiResponse({ status: 201, description: 'Reference added successfully.' })
@RequirePermission('document.edit')
addReference(
@Param('id', ParseIntPipe) id: number,
@Body() dto: AddReferenceDto,
@Body() dto: AddReferenceDto
) {
return this.correspondenceService.addReference(id, dto);
}
@Delete(':id/references/:targetId')
@ApiOperation({ summary: 'Remove reference' })
@ApiResponse({ status: 200, description: 'Reference removed successfully.' })
@RequirePermission('document.edit')
removeReference(
@Param('id', ParseIntPipe) id: number,
@Param('targetId', ParseIntPipe) targetId: number,
@Param('targetId', ParseIntPipe) targetId: number
) {
return this.correspondenceService.removeReference(id, targetId);
}

View File

@@ -1,6 +1,11 @@
import { IsInt, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AddReferenceDto {
@ApiProperty({
description: 'Target Correspondence ID to reference',
example: 20,
})
@IsInt()
@IsNotEmpty()
targetId!: number;

View File

@@ -1,33 +1,43 @@
import { IsOptional, IsString, IsInt } from 'class-validator';
import { Type } from 'class-transformer'; // <--- ✅ Import จาก class-transformer
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchCorrespondenceDto {
@ApiPropertyOptional({
description: 'Search term (Title or Document Number)',
})
@IsOptional()
@IsString()
search?: string; // ค้นหาจาก Title หรือ Number
search?: string;
@ApiPropertyOptional({ description: 'Filter by Document Type ID' })
@IsOptional()
@Type(() => Number)
@IsInt()
typeId?: number;
@ApiPropertyOptional({ description: 'Filter by Project ID' })
@IsOptional()
@Type(() => Number)
@IsInt()
projectId?: number;
// status อาจจะซับซ้อนหน่อยเพราะอยู่ที่ Revision แต่ใส่ไว้ก่อน
@ApiPropertyOptional({ description: 'Filter by Status ID' })
@IsOptional()
@Type(() => Number)
@IsInt()
statusId?: number;
// Pagination
@ApiPropertyOptional({ description: 'Page number (default 1)', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
page?: number;
@ApiPropertyOptional({
description: 'Items per page (default 10)',
default: 10,
})
@IsOptional()
@Type(() => Number)
@IsInt()

View File

@@ -1,6 +1,11 @@
import { IsInt, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SubmitCorrespondenceDto {
@ApiProperty({
description: 'ID of the Workflow Template to start',
example: 1,
})
@IsInt()
@IsNotEmpty()
templateId!: number;

View File

@@ -1,14 +1,27 @@
import { IsEnum, IsString, IsOptional, IsInt } from 'class-validator';
import { WorkflowAction } from '../../workflow-engine/interfaces/workflow.interface.js';
import { WorkflowAction } from '../../workflow-engine/interfaces/workflow.interface';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class WorkflowActionDto {
@ApiProperty({
description: 'Workflow Action',
enum: ['APPROVE', 'REJECT', 'RETURN', 'CANCEL', 'ACKNOWLEDGE'],
})
@IsEnum(WorkflowAction)
action!: WorkflowAction; // APPROVE, REJECT, RETURN, ACKNOWLEDGE
@ApiPropertyOptional({
description: 'Review comments',
example: 'Approved with note...',
})
@IsString()
@IsOptional()
comments?: string;
@ApiPropertyOptional({
description: 'Sequence to return to (only for RETURN action)',
example: 1,
})
@IsInt()
@IsOptional()
returnToSequence?: number; // ใช้กรณี action = RETURN

View File

@@ -1,18 +1,181 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DocumentNumberingService } from './document-numbering.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Repository, OptimisticLockVersionMismatchError } from 'typeorm';
import { InternalServerErrorException } from '@nestjs/common';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberFormat } from './entities/document-number-format.entity';
import { Project } from '../project/entities/project.entity';
import { Organization } from '../project/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
import { DocumentNumberError } from './entities/document-number-error.entity';
// Mock Redis and Redlock
const mockRedis = {
disconnect: jest.fn(),
};
const mockRedlock = {
acquire: jest.fn(),
};
const mockLock = {
release: jest.fn().mockResolvedValue(undefined),
};
jest.mock('ioredis', () => {
return jest.fn().mockImplementation(() => mockRedis);
});
jest.mock('redlock', () => {
return jest.fn().mockImplementation(() => {
return mockRedlock;
});
});
describe('DocumentNumberingService', () => {
let service: DocumentNumberingService;
let module: TestingModule;
let counterRepo: Repository<DocumentNumberCounter>;
let formatRepo: Repository<DocumentNumberFormat>;
let auditRepo: Repository<DocumentNumberAudit>;
const mockProject = { id: 1, projectCode: 'LCBP3' };
const mockOrg = { id: 1, name: 'Google' };
const mockType = { id: 1, typeCode: 'COR' };
const mockDiscipline = { id: 1, code: 'CIV' };
const mockContext = {
projectId: 1,
originatorId: 1,
typeId: 1,
disciplineId: 1,
year: 2025,
customTokens: { TYPE_CODE: 'COR', ORG_CODE: 'GGL' },
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [DocumentNumberingService],
mockRedlock.acquire.mockResolvedValue(mockLock);
module = await Test.createTestingModule({
providers: [
DocumentNumberingService,
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('localhost') },
},
{
provide: getRepositoryToken(DocumentNumberCounter),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn().mockReturnValue({ lastNumber: 0 }),
},
},
{
provide: getRepositoryToken(DocumentNumberFormat),
useValue: { findOne: jest.fn() },
},
{
provide: getRepositoryToken(DocumentNumberAudit),
useValue: { save: jest.fn() },
},
{
provide: getRepositoryToken(DocumentNumberError),
useValue: { save: jest.fn() },
},
// Mock other dependencies used inside generateNextNumber lookups
{
provide: getRepositoryToken(Project),
useValue: { findOne: jest.fn() },
},
{
provide: getRepositoryToken(Organization),
useValue: { findOne: jest.fn() },
},
{
provide: getRepositoryToken(CorrespondenceType),
useValue: { findOne: jest.fn() },
},
{
provide: getRepositoryToken(Discipline),
useValue: { findOne: jest.fn() },
},
{
provide: getRepositoryToken(CorrespondenceSubType),
useValue: { findOne: jest.fn() },
},
],
}).compile();
service = module.get<DocumentNumberingService>(DocumentNumberingService);
counterRepo = module.get(getRepositoryToken(DocumentNumberCounter));
formatRepo = module.get(getRepositoryToken(DocumentNumberFormat));
auditRepo = module.get(getRepositoryToken(DocumentNumberAudit));
});
afterEach(async () => {
jest.clearAllMocks();
service.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateNextNumber', () => {
it('should generate a new number successfully', async () => {
const projectRepo = module.get(getRepositoryToken(Project));
const orgRepo = module.get(getRepositoryToken(Organization));
const typeRepo = module.get(getRepositoryToken(CorrespondenceType));
const disciplineRepo = module.get(getRepositoryToken(Discipline));
(projectRepo.findOne as jest.Mock).mockResolvedValue(mockProject);
(orgRepo.findOne as jest.Mock).mockResolvedValue(mockOrg);
(typeRepo.findOne as jest.Mock).mockResolvedValue(mockType);
(disciplineRepo.findOne as jest.Mock).mockResolvedValue(mockDiscipline);
(formatRepo.findOne as jest.Mock).mockResolvedValue({
formatTemplate: '{SEQ}',
});
(counterRepo.findOne as jest.Mock).mockResolvedValue(null); // First time
(counterRepo.save as jest.Mock).mockResolvedValue({ lastNumber: 1 });
service.onModuleInit();
const result = await service.generateNextNumber(mockContext);
expect(result).toBe('000001'); // Default padding 6
expect(counterRepo.save).toHaveBeenCalled();
expect(auditRepo.save).toHaveBeenCalled();
});
it('should throw InternalServerErrorException if max retries exceeded', async () => {
const projectRepo = module.get(getRepositoryToken(Project));
const orgRepo = module.get(getRepositoryToken(Organization));
const typeRepo = module.get(getRepositoryToken(CorrespondenceType));
const disciplineRepo = module.get(getRepositoryToken(Discipline));
(projectRepo.findOne as jest.Mock).mockResolvedValue(mockProject);
(orgRepo.findOne as jest.Mock).mockResolvedValue(mockOrg);
(typeRepo.findOne as jest.Mock).mockResolvedValue(mockType);
(disciplineRepo.findOne as jest.Mock).mockResolvedValue(mockDiscipline);
(formatRepo.findOne as jest.Mock).mockResolvedValue({
formatTemplate: '{SEQ}',
});
(counterRepo.findOne as jest.Mock).mockResolvedValue({ lastNumber: 1 });
// Always fail
(counterRepo.save as jest.Mock).mockRejectedValue(
new OptimisticLockVersionMismatchError('Counter', 1, 2)
);
service.onModuleInit();
await expect(service.generateNextNumber(mockContext)).rejects.toThrow(
InternalServerErrorException
);
expect(counterRepo.save).toHaveBeenCalledTimes(3); // Max retries
});
});
});

View File

@@ -1,66 +1,204 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WorkflowEngineService } from './workflow-engine.service';
import { WorkflowAction } from './interfaces/workflow.interface';
import { BadRequestException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource, 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 { WorkflowDslService } from './workflow-dsl.service';
import { WorkflowEventService } from './workflow-event.service';
import { NotFoundException } from '@nestjs/common';
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
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(),
},
};
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 () => {
const module: TestingModule = await Test.createTestingModule({
providers: [WorkflowEngineService],
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(),
},
},
{ provide: WorkflowDslService, useValue: mockDslService },
{ provide: WorkflowEventService, useValue: mockEventService },
{ provide: DataSource, useValue: mockDataSource },
],
}).compile();
service = module.get<WorkflowEngineService>(WorkflowEngineService);
defRepo = module.get(getRepositoryToken(WorkflowDefinition));
instanceRepo = module.get(getRepositoryToken(WorkflowInstance));
dslService = module.get(WorkflowDslService);
eventService = module.get(WorkflowEventService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('processAction', () => {
// 🟢 กรณี: อนุมัติทั่วไป (ไปขั้นต่อไป)
it('should move to next step on APPROVE', () => {
const result = service.processAction(1, 3, WorkflowAction.APPROVE);
expect(result.nextStepSequence).toBe(2);
expect(result.shouldUpdateStatus).toBe(false);
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 complete workflow on APPROVE at last step', () => {
const result = service.processAction(3, 3, WorkflowAction.APPROVE);
expect(result.nextStepSequence).toBeNull(); // ไม่มีขั้นต่อไป
expect(result.shouldUpdateStatus).toBe(true);
expect(result.documentStatus).toBe('COMPLETED');
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 stop workflow on REJECT', () => {
const result = service.processAction(1, 3, WorkflowAction.REJECT);
expect(result.nextStepSequence).toBeNull();
expect(result.shouldUpdateStatus).toBe(true);
expect(result.documentStatus).toBe('REJECTED');
});
it('should rollback transaction on error', async () => {
mockQueryRunner.manager.findOne.mockRejectedValue(new Error('DB Error'));
// 🟠 กรณี: ส่งกลับ (ย้อนกลับ 1 ขั้น)
it('should return to previous step on RETURN', () => {
const result = service.processAction(2, 3, WorkflowAction.RETURN);
expect(result.nextStepSequence).toBe(1);
expect(result.shouldUpdateStatus).toBe(true);
expect(result.documentStatus).toBe('REVISE_REQUIRED');
});
await expect(
service.processTransition('inst-1', 'APPROVE', 1)
).rejects.toThrow('DB Error');
// 🟠 กรณี: ส่งกลับ (ระบุขั้น)
it('should return to specific step on RETURN', () => {
const result = service.processAction(3, 5, WorkflowAction.RETURN, 1);
expect(result.nextStepSequence).toBe(1);
});
// ❌ กรณี: Error (ส่งกลับต่ำกว่า 1)
it('should throw error if return step is invalid', () => {
expect(() => {
service.processAction(1, 3, WorkflowAction.RETURN);
}).toThrow(BadRequestException);
expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled();
expect(mockQueryRunner.release).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "**/*spec.ts"]
}