a2973be208
- เพิ่ม POST /api/ai/jobs + GET /api/ai/jobs/:jobId endpoints (FR-001, FR-002) - เพิ่ม BullMQ Worker MigrateDocumentWorker + OCR auto-detect (FR-003, FR-004) - เพิ่ม cleanup-temp-files + expire-pending-reviews workers (FR-005, FR-005a/b) - สร้าง SQL deltas: tags, correspondence_tags, alter migration_review_queue (FR-006, ADR-009) - เพิ่ม MigrationReviewService.commitRecord() + SELECT FOR UPDATE (FR-007, FR-007a) - เพิ่ม CASL permission migration.commit + MigrationReviewController (FR-007) - สร้าง TagsModule + TagsService + TagsController (US3) - สร้าง Migration Review Queue frontend page + ReviewQueueTable (US2) - อัปเดต n8n guide: deterministic Idempotency-Key + token pre-flight (FR-001a, FR-010a/b) - สร้าง spec.md, plan.md, tasks.md, data-model.md, contracts/, quickstart.md - สร้าง ADR-028 document + validation-report.md (PASS 32/32 tasks, 173/173 tests)
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
// File: src/modules/ai/ai.service.spec.ts
|
|
// Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions
|
|
// Change Log
|
|
// - 2026-05-21: เพิ่ม unit tests สำหรับ getSystemHealth (T026) ทั้งกรณี cache hit/miss และ queue metrics.
|
|
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { getQueueToken } from '@nestjs/bullmq';
|
|
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';
|
|
import { AuditLog } from '../../common/entities/audit-log.entity';
|
|
import {
|
|
QUEUE_AI_BATCH,
|
|
QUEUE_AI_REALTIME,
|
|
} from '../common/constants/queue.constants';
|
|
import { OllamaService } from './services/ollama.service';
|
|
import { AiQdrantService } from './qdrant.service';
|
|
import { ImportTransaction } from '../migration/entities/import-transaction.entity';
|
|
|
|
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
|
|
|
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(),
|
|
};
|
|
|
|
const mockMainAuditLogRepo = {
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
};
|
|
|
|
const mockQueue = {
|
|
add: jest.fn(),
|
|
isPaused: jest.fn().mockResolvedValue(false),
|
|
getActiveCount: jest.fn().mockResolvedValue(1),
|
|
getWaitingCount: jest.fn().mockResolvedValue(2),
|
|
getFailedCount: jest.fn().mockResolvedValue(3),
|
|
getCompletedCount: jest.fn().mockResolvedValue(4),
|
|
resume: jest.fn(),
|
|
getState: jest.fn().mockResolvedValue('completed'),
|
|
};
|
|
|
|
const mockOllamaService = {
|
|
checkHealth: jest.fn().mockResolvedValue({
|
|
status: 'HEALTHY',
|
|
latencyMs: 120,
|
|
models: ['gemma4:e4b', 'nomic-embed-text'],
|
|
}),
|
|
};
|
|
|
|
const mockQdrantService = {
|
|
checkHealth: jest.fn().mockResolvedValue({
|
|
status: 'HEALTHY',
|
|
latencyMs: 45,
|
|
collections: ['lcbp3_vectors'],
|
|
}),
|
|
};
|
|
|
|
const mockRedis = {
|
|
get: jest.fn(),
|
|
set: 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();
|
|
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({});
|
|
mockMainAuditLogRepo.create.mockReturnValue({});
|
|
mockMainAuditLogRepo.save.mockResolvedValue({});
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
AiService,
|
|
{
|
|
provide: getRepositoryToken(MigrationLog),
|
|
useValue: mockMigrationLogRepo,
|
|
},
|
|
{ provide: getRepositoryToken(AiAuditLog), useValue: mockAuditLogRepo },
|
|
{
|
|
provide: getRepositoryToken(AuditLog),
|
|
useValue: mockMainAuditLogRepo,
|
|
},
|
|
{
|
|
provide: getRepositoryToken(ImportTransaction),
|
|
useValue: { findOne: jest.fn(), create: jest.fn(), save: jest.fn() },
|
|
},
|
|
{ provide: getQueueToken(QUEUE_AI_REALTIME), useValue: mockQueue },
|
|
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockQueue },
|
|
{ provide: ConfigService, useValue: mockConfigService },
|
|
{ provide: HttpService, useValue: mockHttpService },
|
|
{ provide: AiValidationService, useValue: mockValidationService },
|
|
{ provide: OllamaService, useValue: mockOllamaService },
|
|
{ provide: AiQdrantService, useValue: mockQdrantService },
|
|
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
|
],
|
|
}).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,
|
|
};
|
|
|
|
// หมายเหตุ: token validation ย้ายไป ServiceAccountGuard ที่ controller layer แล้ว (🟢 LOW-1)
|
|
|
|
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')
|
|
).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');
|
|
|
|
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');
|
|
|
|
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');
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
// --- getSystemHealth ---
|
|
|
|
describe('getSystemHealth', () => {
|
|
it('ควรอ่านข้อมูลสุขภาพจาก Redis cache หากมีข้อมูลอยู่แล้ว (Cache Hit)', async () => {
|
|
const mockCachedData = {
|
|
ollama: { status: 'HEALTHY', latencyMs: 50, models: ['model1'] },
|
|
qdrant: { status: 'HEALTHY', latencyMs: 20, collections: ['col1'] },
|
|
queues: {
|
|
realtime: {
|
|
active: 1,
|
|
waiting: 2,
|
|
failed: 3,
|
|
completed: 4,
|
|
isPaused: false,
|
|
},
|
|
batch: {
|
|
active: 1,
|
|
waiting: 2,
|
|
failed: 3,
|
|
completed: 4,
|
|
isPaused: false,
|
|
},
|
|
},
|
|
timestamp: '2026-05-21T12:00:00.000Z',
|
|
};
|
|
mockRedis.get.mockResolvedValue(JSON.stringify(mockCachedData));
|
|
const result = await service.getSystemHealth();
|
|
expect(result).toEqual(mockCachedData);
|
|
expect(mockRedis.get).toHaveBeenCalledWith('system_health:cache');
|
|
expect(mockOllamaService.checkHealth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ควรดึงข้อมูลจาก Service และบันทึกลง Redis cache เมื่อไม่มีข้อมูลใน cache (Cache Miss)', async () => {
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockOllamaService.checkHealth.mockResolvedValue({
|
|
status: 'HEALTHY',
|
|
latencyMs: 120,
|
|
models: ['gemma4:e4b', 'nomic-embed-text'],
|
|
});
|
|
mockQdrantService.checkHealth.mockResolvedValue({
|
|
status: 'HEALTHY',
|
|
latencyMs: 45,
|
|
collections: ['lcbp3_vectors'],
|
|
});
|
|
const result = await service.getSystemHealth();
|
|
expect(result.ollama.status).toBe('HEALTHY');
|
|
expect(result.qdrant.status).toBe('HEALTHY');
|
|
expect(result.queues.realtime).toEqual({
|
|
active: 1,
|
|
waiting: 2,
|
|
failed: 3,
|
|
completed: 4,
|
|
isPaused: false,
|
|
});
|
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
|
'system_health:cache',
|
|
expect.any(String),
|
|
'EX',
|
|
30
|
|
);
|
|
});
|
|
});
|
|
});
|