690409:0953 Done Task-BE-AI-02
CI / CD Pipeline / build (push) Successful in 4m30s
CI / CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
2026-04-09 09:53:57 +07:00
parent 4f34aeae6b
commit 99c8d61856
18 changed files with 1791 additions and 60 deletions
+317
View File
@@ -0,0 +1,317 @@
// File: src/modules/ai/ai.service.spec.ts
// Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { AiService } from './ai.service';
import { AiValidationService } from './ai-validation.service';
import {
MigrationLog,
MigrationLogStatus,
} from './entities/migration-log.entity';
import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity';
import { AiCallbackDto } from './dto/ai-callback.dto';
import { MigrationUpdateDto } from './dto/migration-update.dto';
import { NotFoundException, BusinessException } from '../../common/exceptions';
describe('AiService', () => {
let service: AiService;
// Mock Repositories
const mockMigrationLogRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
}),
};
const mockAuditLogRepo = {
create: jest.fn(),
save: jest.fn(),
};
// Mock ConfigService — คืนค่า Config ตาม Key
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string | number> = {
AI_N8N_WEBHOOK_URL: 'http://localhost:5678/webhook/test',
AI_N8N_AUTH_TOKEN: 'test-token',
AI_TIMEOUT_MS: 30000,
APP_BASE_URL: 'http://localhost:3001',
};
return config[key];
}),
};
// Mock HttpService (ไม่ต้องการ HTTP call จริงใน Unit Test)
const mockHttpService = {
post: jest.fn(),
};
// Mock AiValidationService
const mockValidationService = {
validateAiOutput: jest.fn(),
buildAuditSummary: jest
.fn()
.mockReturnValue('model=gemma4, confidence=0.90, valid=true'),
getConfidenceAction: jest.fn().mockReturnValue('low_priority_review'),
};
beforeEach(async () => {
jest.clearAllMocks();
// ตั้งค่า default return values
mockMigrationLogRepo.create.mockReturnValue({
publicId: '019505a1-7c3e-7000-8000-abc123def456',
sourceFile: 'test-file-uuid',
status: MigrationLogStatus.PENDING_REVIEW,
});
mockMigrationLogRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...entity, id: 1 })
);
mockAuditLogRepo.create.mockReturnValue({});
mockAuditLogRepo.save.mockResolvedValue({});
const module: TestingModule = await Test.createTestingModule({
providers: [
AiService,
{
provide: getRepositoryToken(MigrationLog),
useValue: mockMigrationLogRepo,
},
{ provide: getRepositoryToken(AiAuditLog), useValue: mockAuditLogRepo },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: AiValidationService, useValue: mockValidationService },
],
}).compile();
service = module.get<AiService>(AiService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
// --- handleWebhookCallback ---
describe('handleWebhookCallback', () => {
const validPayload: AiCallbackDto = {
migrationLogPublicId: '019505a1-7c3e-7000-8000-abc123def456',
aiModel: 'gemma4',
status: AiAuditStatus.SUCCESS,
confidenceScore: 0.92,
extractedMetadata: { subject: 'Test', discipline: 'Civil' },
processingTimeMs: 5000,
};
const validAuthHeader = 'Bearer test-token';
it('ควรปฏิเสธ request เมื่อไม่มี Authorization header', async () => {
await expect(
service.handleWebhookCallback(validPayload, 'n8n', '')
).rejects.toThrow();
});
it('ควรปฏิเสธ request เมื่อ Token ไม่ถูกต้อง', async () => {
await expect(
service.handleWebhookCallback(validPayload, 'n8n', 'Bearer wrong-token')
).rejects.toThrow();
});
it('ควร throw NotFoundException เมื่อ MigrationLog ไม่พบ', async () => {
mockMigrationLogRepo.findOne.mockResolvedValue(null);
mockValidationService.validateAiOutput.mockReturnValue({
isValid: true,
action: 'low_priority_review',
confidence: 0.92,
reasons: [],
});
await expect(
service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader)
).rejects.toBeInstanceOf(NotFoundException);
});
it('ควรอัปเดต MigrationLog เมื่อ Callback ถูกต้อง', async () => {
const existingLog = {
id: 1,
publicId: '019505a1-7c3e-7000-8000-abc123def456',
status: MigrationLogStatus.PENDING_REVIEW,
sourceFile: 'test.pdf',
save: jest.fn(),
};
mockMigrationLogRepo.findOne.mockResolvedValue(existingLog);
mockValidationService.validateAiOutput.mockReturnValue({
isValid: true,
action: 'low_priority_review',
confidence: 0.92,
reasons: [],
});
await service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader);
expect(mockMigrationLogRepo.save).toHaveBeenCalled();
expect(mockAuditLogRepo.create).toHaveBeenCalled();
});
it('ควร Auto-approve เมื่อ confidence >= 0.95', async () => {
const highConfidencePayload = { ...validPayload, confidenceScore: 0.97 };
const existingLog = {
id: 1,
publicId: '019505a1-7c3e-7000-8000-abc123def456',
status: MigrationLogStatus.PENDING_REVIEW,
sourceFile: 'test.pdf',
};
mockMigrationLogRepo.findOne.mockResolvedValue(existingLog);
mockValidationService.validateAiOutput.mockReturnValue({
isValid: true,
action: 'auto_approve',
confidence: 0.97,
reasons: [],
});
await service.handleWebhookCallback(
highConfidencePayload,
'n8n',
validAuthHeader
);
const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][];
const savedLog = calls[0][0];
expect(savedLog.status).toBe(MigrationLogStatus.VERIFIED);
});
it('ควรตั้งสถานะ FAILED เมื่อ AI ล้มเหลว', async () => {
const failedPayload = {
...validPayload,
status: AiAuditStatus.FAILED,
errorMessage: 'OCR timeout',
};
const existingLog = {
id: 1,
publicId: '019505a1-7c3e-7000-8000-abc123def456',
status: MigrationLogStatus.PENDING_REVIEW,
sourceFile: 'test.pdf',
};
mockMigrationLogRepo.findOne.mockResolvedValue(existingLog);
mockValidationService.validateAiOutput.mockReturnValue({
isValid: false,
action: 'reject',
confidence: 0,
reasons: ['AI processing failed'],
});
await service.handleWebhookCallback(
failedPayload,
'n8n',
validAuthHeader
);
const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][];
const savedLog = calls[0][0];
expect(savedLog.status).toBe(MigrationLogStatus.FAILED);
});
});
// --- updateMigrationLog ---
describe('updateMigrationLog', () => {
const publicId = '019505a1-7c3e-7000-8000-abc123def456';
it('ควร throw NotFoundException เมื่อ MigrationLog ไม่พบ', async () => {
mockMigrationLogRepo.findOne.mockResolvedValue(null);
await expect(
service.updateMigrationLog(publicId, {}, 1)
).rejects.toBeInstanceOf(NotFoundException);
});
it('ควรอัปเดตสถานะ PENDING_REVIEW → VERIFIED ได้', async () => {
const existingLog = {
id: 1,
publicId,
status: MigrationLogStatus.PENDING_REVIEW,
sourceFile: 'test.pdf',
};
mockMigrationLogRepo.findOne.mockResolvedValue(existingLog);
mockMigrationLogRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...entity })
);
const dto: MigrationUpdateDto = { status: MigrationLogStatus.VERIFIED };
const result = await service.updateMigrationLog(publicId, dto, 5);
expect(result.status).toBe(MigrationLogStatus.VERIFIED);
expect(result.reviewedBy).toBe(5);
});
it('ควร throw BusinessException เมื่อ State Transition ไม่ถูกต้อง (IMPORTED → VERIFIED)', async () => {
const existingLog = {
id: 1,
publicId,
status: MigrationLogStatus.IMPORTED, // Terminal State
sourceFile: 'test.pdf',
};
mockMigrationLogRepo.findOne.mockResolvedValue(existingLog);
const dto: MigrationUpdateDto = { status: MigrationLogStatus.VERIFIED };
await expect(
service.updateMigrationLog(publicId, dto, 1)
).rejects.toBeInstanceOf(BusinessException);
});
it('ควรอัปเดต adminFeedback ได้โดยไม่ต้องเปลี่ยนสถานะ', async () => {
const existingLog = {
id: 1,
publicId,
status: MigrationLogStatus.PENDING_REVIEW,
sourceFile: 'test.pdf',
adminFeedback: undefined,
};
mockMigrationLogRepo.findOne.mockResolvedValue(existingLog);
mockMigrationLogRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...entity })
);
const dto: MigrationUpdateDto = {
adminFeedback: 'ตรวจสอบแล้ว ข้อมูลถูกต้อง',
};
const result = await service.updateMigrationLog(publicId, dto, 3);
expect(result.adminFeedback).toBe('ตรวจสอบแล้ว ข้อมูลถูกต้อง');
expect(result.status).toBe(MigrationLogStatus.PENDING_REVIEW);
});
});
// --- getMigrationList ---
describe('getMigrationList', () => {
it('ควรคืน paginated result', async () => {
const result = await service.getMigrationList({ page: 1, limit: 10 });
expect(result).toHaveProperty('items');
expect(result).toHaveProperty('total');
expect(result).toHaveProperty('page', 1);
expect(result).toHaveProperty('limit', 10);
expect(result).toHaveProperty('totalPages');
});
});
});