Files
lcbp3/backend/src/modules/ai/ai.service.spec.ts
T
admin a2973be208 feat(migration): ADR-028 migration architecture refactor
- เพิ่ม 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)
2026-05-22 17:10:07 +07:00

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
);
});
});
});