feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
- Add ADR-036 unified OCR architecture (typhoon-ocr via Ollama) - Extend AI execution profiles for OCR sandbox configuration - Add comprehensive frontend test coverage (components, hooks, services) - Add backend test coverage for document-numbering services - Update OCR sidecar with typhoon-ocr integration - Add AI policy service and execution profile management - Update AGENTS.md and architecture documentation
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
// File: lib/api/client.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Export getAuthToken for unit testing
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@@ -10,7 +12,7 @@ let cachedToken: string | null = null;
|
||||
let tokenPromise: Promise<string | null> | null = null;
|
||||
|
||||
// Async function to get token
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
export async function getAuthToken(): Promise<string | null> {
|
||||
if (cachedToken) return cachedToken;
|
||||
|
||||
if (tokenPromise) return tokenPromise;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// File: frontend/lib/i18n/__tests__/index.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Add coverage for Thai/English translators and template replacement
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createT, t } from '../index';
|
||||
|
||||
describe('i18n utility', () => {
|
||||
it('default translator ควรใช้ภาษาไทย', () => {
|
||||
expect(t('workflow.action.APPROVE')).toBe('อนุมัติ');
|
||||
});
|
||||
|
||||
it('createT ควรสร้าง translator ภาษาอังกฤษได้', () => {
|
||||
const translate = createT('en');
|
||||
expect(translate('workflow.action.APPROVE')).toBe('Approve');
|
||||
});
|
||||
|
||||
it('ควรคืน key เดิมเมื่อไม่พบข้อความ', () => {
|
||||
const translate = createT('th');
|
||||
expect(translate('missing.translation.key')).toBe('missing.translation.key');
|
||||
});
|
||||
|
||||
it('ควรแทนค่า template params ด้วย string หรือ number', () => {
|
||||
const translate = createT('en');
|
||||
expect(translate('ai.staging.thresholdWarningDesc', { rate: 42 })).toBe(
|
||||
'Override rate reached 42% in recent records.'
|
||||
);
|
||||
expect(translate('ai.prompt.resultVersionBadge', { version: '3' })).toBe('Extracted with v3');
|
||||
});
|
||||
|
||||
it('ควรแทนค่า missing template param เป็นค่าว่าง', () => {
|
||||
const translate = createT('en');
|
||||
expect(translate('ai.prompt.resultVersionBadge')).toBe('Extracted with v{{version}}');
|
||||
expect(translate('ai.prompt.resultVersionBadge', {})).toBe('Extracted with v');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// File: frontend/lib/services/__tests__/ai.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for aiService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import api from '@/lib/api/client';
|
||||
import { aiService } from '../ai.service';
|
||||
|
||||
describe('aiService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('extract', () => {
|
||||
it('ควรส่งคำขอ POST /ai/extract เพื่อสกัดข้อมูลและส่งกลับผลลัพธ์สำเร็จ', async () => {
|
||||
const mockResult = {
|
||||
documentNumber: 'DOC-001',
|
||||
title: 'Document Title',
|
||||
confidenceScore: 0.95,
|
||||
};
|
||||
vi.mocked(api.post).mockResolvedValue({ data: mockResult });
|
||||
const dto = { filePublicId: 'file-123' };
|
||||
const result = await aiService.extract(dto);
|
||||
expect(api.post).toHaveBeenCalledWith('/ai/extract', dto);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('ควรจัดการการห่อหุ้มข้อมูล (nested data wrapper) ได้อย่างถูกต้อง', async () => {
|
||||
const mockResult = {
|
||||
documentNumber: 'DOC-001',
|
||||
title: 'Document Title',
|
||||
confidenceScore: 0.95,
|
||||
};
|
||||
vi.mocked(api.post).mockResolvedValue({ data: { data: mockResult } });
|
||||
const dto = { filePublicId: 'file-123' };
|
||||
const result = await aiService.extract(dto);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMigrationList', () => {
|
||||
it('ควรดึงประวัติการอพยพข้อมูลพร้อมแบ่งหน้าได้ถูกต้อง', async () => {
|
||||
const mockResponse = {
|
||||
items: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-log111111111',
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
vi.mocked(api.get).mockResolvedValue({ data: mockResponse });
|
||||
const result = await aiService.getMigrationList({ page: 1, limit: 10 });
|
||||
expect(api.get).toHaveBeenCalledWith('/ai/migration', { params: { page: 1, limit: 10 } });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('ควรคืนค่ารูปแบบแบ่งหน้าเริ่มต้นหากข้อมูลที่ได้รับไม่ถูกต้อง', async () => {
|
||||
vi.mocked(api.get).mockResolvedValue({ data: null });
|
||||
const result = await aiService.getMigrationList({});
|
||||
expect(result).toEqual({ items: [], total: 0, page: 1, limit: 10, totalPages: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMigration', () => {
|
||||
it('ควรส่งคำขอ PATCH พร้อมแนบ Idempotency-Key สำเร็จ', async () => {
|
||||
const mockLog = {
|
||||
publicId: '019505a1-7c3e-7000-8000-log111111111',
|
||||
status: 'VERIFIED',
|
||||
};
|
||||
vi.mocked(api.patch).mockResolvedValue({ data: mockLog });
|
||||
const dto = { status: 'VERIFIED' as const };
|
||||
const result = await aiService.updateMigration(
|
||||
'019505a1-7c3e-7000-8000-log111111111',
|
||||
dto,
|
||||
'idempotency-123'
|
||||
);
|
||||
expect(api.patch).toHaveBeenCalledWith(
|
||||
'/ai/migration/019505a1-7c3e-7000-8000-log111111111',
|
||||
dto,
|
||||
{ headers: { 'Idempotency-Key': 'idempotency-123' } }
|
||||
);
|
||||
expect(result).toEqual(mockLog);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitFeedback', () => {
|
||||
it('ควรส่งคำขอ POST /ai/feedback พร้อมข้อมูลฟีดแบ็คสำเร็จ', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({ data: {} });
|
||||
const dto = { logPublicId: 'log-1', rating: 5, comments: 'Good extraction' };
|
||||
await aiService.submitFeedback(dto);
|
||||
expect(api.post).toHaveBeenCalledWith('/ai/feedback', dto);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// File: frontend/lib/services/__tests__/audit-log.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for auditLogService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { auditLogService } from '../audit-log.service';
|
||||
|
||||
describe('auditLogService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getLogs', () => {
|
||||
it('ควรดึงข้อมูล audit logs รูปแบบอาร์เรย์ได้อย่างถูกต้อง', async () => {
|
||||
const mockLogs = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-audit1111111',
|
||||
auditId: 'AUD-001',
|
||||
action: 'LOGIN',
|
||||
severity: 'INFO',
|
||||
createdAt: '2026-06-13T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLogs });
|
||||
const result = await auditLogService.getLogs();
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/audit-logs', { params: undefined });
|
||||
expect(result).toEqual(mockLogs);
|
||||
});
|
||||
|
||||
it('ควรดึงข้อมูล audit logs รูปแบบ data wrapper ได้อย่างถูกต้อง', async () => {
|
||||
const mockLogs = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-audit1111111',
|
||||
auditId: 'AUD-001',
|
||||
action: 'LOGIN',
|
||||
severity: 'INFO',
|
||||
createdAt: '2026-06-13T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockLogs } });
|
||||
const result = await auditLogService.getLogs({ search: 'login' });
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/audit-logs', { params: { search: 'login' } });
|
||||
expect(result).toEqual(mockLogs);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// File: frontend/lib/services/__tests__/contract.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for contractService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { contractService } from '../contract.service';
|
||||
|
||||
describe('contractService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('ควรดึงข้อมูลสัญญาและประมวลผลรูปแบบอาร์เรย์ได้อย่างถูกต้อง', async () => {
|
||||
const mockContracts = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-contract111',
|
||||
contractName: 'Contract Alpha',
|
||||
project: {
|
||||
publicId: '019505a1-7c3e-7000-8000-project111',
|
||||
projectName: 'Project Alpha',
|
||||
},
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContracts });
|
||||
const result = await contractService.getAll();
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/contracts', { params: undefined });
|
||||
expect(result).toEqual(mockContracts);
|
||||
});
|
||||
|
||||
it('ควรดึงข้อมูลและประมวลผลรูปแบบ nested data ได้อย่างถูกต้อง', async () => {
|
||||
const mockContracts = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-contract111',
|
||||
contractName: 'Contract Alpha',
|
||||
project: {
|
||||
publicId: '019505a1-7c3e-7000-8000-project111',
|
||||
projectName: 'Project Alpha',
|
||||
},
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockContracts } });
|
||||
const result = await contractService.getAll({ projectId: 1 });
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/contracts', { params: { projectId: 1 } });
|
||||
expect(result).toEqual(mockContracts);
|
||||
});
|
||||
|
||||
it('ควรส่งกลับอาร์เรย์ว่างหากข้อมูลที่ได้รับไม่ใช่รูปแบบอาร์เรย์', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: null });
|
||||
const result = await contractService.getAll();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByUuid', () => {
|
||||
it('ควรดึงรายละเอียดสัญญาตาม UUID สำเร็จ', async () => {
|
||||
const mockContract = {
|
||||
publicId: '019505a1-7c3e-7000-8000-contract111',
|
||||
contractName: 'Contract Alpha',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContract });
|
||||
const result = await contractService.getByUuid('019505a1-7c3e-7000-8000-contract111');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/contracts/019505a1-7c3e-7000-8000-contract111');
|
||||
expect(result).toEqual(mockContract);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อสร้างสัญญาใหม่สำเร็จ', async () => {
|
||||
const createDto = { contractName: 'New Contract', contractCode: 'C-001', projectId: 1 };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { publicId: 'new-uuid', ...createDto } });
|
||||
const result = await contractService.create(createDto);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/contracts', createDto);
|
||||
expect(result.contractName).toBe('New Contract');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('ควรส่งคำขอ PATCH เพื่ออัปเดตสัญญาสำเร็จ', async () => {
|
||||
const updateDto = { contractName: 'Updated Contract' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { publicId: 'uuid', ...updateDto } });
|
||||
const result = await contractService.update('uuid', updateDto);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/contracts/uuid', updateDto);
|
||||
expect(result.contractName).toBe('Updated Contract');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('ควรส่งคำขอ DELETE เพื่อลบสัญญาสำเร็จ', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||
const result = await contractService.delete('uuid');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/contracts/uuid');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// File: frontend/lib/services/__tests__/review-team.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for reviewTeamService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { reviewTeamService } from '../review-team.service';
|
||||
|
||||
describe('reviewTeamService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('ควรดึงข้อมูลทีมทบทวนทั้งหมดสำเร็จ', async () => {
|
||||
const mockTeams = [{ publicId: '019505a1-7c3e-7000-8000-team11111111', name: 'Review Team Alpha' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTeams });
|
||||
const result = await reviewTeamService.getAll({ projectPublicId: 'proj-1' });
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/review-teams', { params: { projectPublicId: 'proj-1' } });
|
||||
expect(result).toEqual(mockTeams);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByPublicId', () => {
|
||||
it('ควรดึงข้อมูลทีมตาม PublicId สำเร็จ', async () => {
|
||||
const mockTeam = { publicId: '019505a1-7c3e-7000-8000-team11111111', name: 'Review Team Alpha' };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTeam });
|
||||
const result = await reviewTeamService.getByPublicId('019505a1-7c3e-7000-8000-team11111111');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/review-teams/019505a1-7c3e-7000-8000-team11111111');
|
||||
expect(result).toEqual(mockTeam);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อสร้างทีมทบทวนใหม่สำเร็จ', async () => {
|
||||
const createDto = { name: 'New Team', projectPublicId: 'proj-1', defaultForRfaTypes: ['RFA'] };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { publicId: 'new-team-uuid', ...createDto } });
|
||||
const result = await reviewTeamService.create(createDto);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/review-teams', createDto);
|
||||
expect(result.name).toBe('New Team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('ควรส่งคำขอ PATCH เพื่ออัปเดตทีมทบทวนสำเร็จ', async () => {
|
||||
const updateDto = { name: 'Updated Team' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { publicId: 'team-uuid', ...updateDto } });
|
||||
const result = await reviewTeamService.update('team-uuid', updateDto);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/review-teams/team-uuid', updateDto);
|
||||
expect(result.name).toBe('Updated Team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMember', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อเพิ่มสมาชิกเข้าทีมทบทวนสำเร็จ', async () => {
|
||||
const memberDto = { userPublicId: 'user-1', disciplineId: 1, role: 'REVIEWER' as const };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
|
||||
const result = await reviewTeamService.addMember('team-uuid', memberDto);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/review-teams/team-uuid/members', memberDto);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('ควรส่งคำขอ DELETE เพื่อลบสมาชิกออกจากทีมทบทวนสำเร็จ', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||
const result = await reviewTeamService.removeMember('team-uuid', 'member-uuid');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/review-teams/team-uuid/members/member-uuid');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('ควรส่งคำขอ DELETE เพื่อหยุดการทำงานของทีมทบทวนสำเร็จ', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||
const result = await reviewTeamService.deactivate('team-uuid');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/review-teams/team-uuid');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// File: frontend/lib/services/__tests__/search.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for searchService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { searchService } from '../search.service';
|
||||
|
||||
describe('searchService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('ควรส่งคำขอ GET /search พร้อมข้อมูลการค้นหาสำเร็จ', async () => {
|
||||
const mockResult = { items: [{ publicId: '019505a1-7c3e-7000-8000-doc111111111', title: 'Test doc' }] };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
|
||||
const query = { q: 'test', limit: 10, offset: 0 };
|
||||
const result = await searchService.search(query);
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/search', { params: query });
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggest', () => {
|
||||
it('ควรดึงข้อมูล suggest และแกะค่า items ออกมาสำเร็จ', async () => {
|
||||
const mockResult = { items: ['test1', 'test2'] };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
|
||||
const result = await searchService.suggest('test');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/search', { params: { q: 'test', limit: 5 } });
|
||||
expect(result).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('ควรคืนค่า raw response ใน suggest หากไม่มีฟิลด์ items', async () => {
|
||||
const mockResult = ['test1', 'test2'];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
|
||||
const result = await searchService.suggest('test');
|
||||
expect(result).toEqual(['test1', 'test2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reindex', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อสั่ง reindex สำเร็จ', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
|
||||
const result = await searchService.reindex('correspondence');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search/reindex', { type: 'correspondence' });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,10 @@
|
||||
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (หลัก+OCR) ใน AiSystemHealth interface
|
||||
// - 2026-06-02: แก้ endpoint getAvailableModels ให้ตรงกับ backend admin route (/ai/admin/models)
|
||||
// - 2026-06-02: normalize VRAM response ให้รองรับ field names จาก backend ปัจจุบันและรูปแบบ loadedModels แบบเดิม
|
||||
// - 2026-06-13: T027-T029 — เพิ่ม getSandboxProfile, saveSandboxProfile, resetSandboxProfile สำหรับ sandbox parameter management
|
||||
// - 2026-06-13: T042-T043 — เพิ่ม applyProfile และ getProductionDefaults สำหรับปรับใช้และดึงค่า production parameters
|
||||
// - 2026-06-13: US4 — อัปเดต submitSandboxExtract และ submitSandboxAiExtract ให้รองรับ project/contract publicId
|
||||
|
||||
|
||||
import api from '../api/client';
|
||||
import { AiJobResponse } from '../../types/ai';
|
||||
@@ -138,6 +142,17 @@ export interface AiActiveModelResponse {
|
||||
activeModel: string;
|
||||
}
|
||||
|
||||
/** พารามิเตอร์ sandbox draft สำหรับ profile (ADR-036) */
|
||||
export interface SandboxProfileParams {
|
||||
canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
|
||||
temperature: number;
|
||||
topP: number;
|
||||
maxTokens: number | null;
|
||||
numCtx: number | null;
|
||||
repeatPenalty: number;
|
||||
keepAliveSeconds: number;
|
||||
}
|
||||
|
||||
const extractData = <T>(value: unknown): T => {
|
||||
if (value && typeof value === 'object' && 'data' in value) {
|
||||
return (value as { data: T }).data;
|
||||
@@ -215,10 +230,16 @@ export const adminAiService = {
|
||||
return extractData<AiSandboxJobResult>(data);
|
||||
},
|
||||
submitSandboxExtract: async (
|
||||
file: File
|
||||
file: File,
|
||||
projectPublicId: string,
|
||||
contractPublicId?: string
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('projectPublicId', projectPublicId);
|
||||
if (contractPublicId) {
|
||||
formData.append('contractPublicId', contractPublicId);
|
||||
}
|
||||
const { data } = await api.post('/ai/admin/sandbox/extract', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
@@ -258,11 +279,15 @@ export const adminAiService = {
|
||||
|
||||
submitSandboxAiExtract: async (
|
||||
requestPublicId: string,
|
||||
promptVersion?: number
|
||||
promptVersion: number | undefined,
|
||||
projectPublicId: string,
|
||||
contractPublicId?: string
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const { data } = await api.post('/ai/admin/sandbox/ai-extract', {
|
||||
requestPublicId,
|
||||
promptVersion,
|
||||
projectPublicId,
|
||||
contractPublicId,
|
||||
});
|
||||
return extractData<{ requestPublicId: string; jobId: string; status: string }>(data);
|
||||
},
|
||||
@@ -317,6 +342,51 @@ export const adminAiService = {
|
||||
return extractData<{ activeEngineName: string }>(data);
|
||||
},
|
||||
|
||||
// --- Sandbox Parameter Management (ADR-036, T027-T029) ---
|
||||
|
||||
getSandboxProfile: async (profileName: string): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.get(`/ai/sandbox-profiles/${encodeURIComponent(profileName)}`);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
saveSandboxProfile: async (
|
||||
profileName: string,
|
||||
updates: Partial<SandboxProfileParams>,
|
||||
idempotencyKey: string
|
||||
): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.put(
|
||||
`/ai/sandbox-profiles/${encodeURIComponent(profileName)}`,
|
||||
updates,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
resetSandboxProfile: async (profileName: string): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.post(
|
||||
`/ai/sandbox-profiles/${encodeURIComponent(profileName)}/reset`,
|
||||
{}
|
||||
);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
applyProfile: async (
|
||||
profileName: string,
|
||||
idempotencyKey: string
|
||||
): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.post(
|
||||
`/ai/profiles/${encodeURIComponent(profileName)}/apply`,
|
||||
{},
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
getProductionDefaults: async (profileName: string): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.get(`/ai/profiles/${encodeURIComponent(profileName)}`);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
submitAiJob: async (
|
||||
type: string,
|
||||
documentPublicId?: string,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// File: frontend/lib/stores/__tests__/auth-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Add coverage for auth state transitions and permission helpers
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore, User } from '../auth-store';
|
||||
|
||||
const user: User = {
|
||||
id: '019505a1-7c3e-7000-8000-abc123def100',
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def100',
|
||||
username: 'frontend.tester',
|
||||
email: 'tester@example.local',
|
||||
firstName: 'Frontend',
|
||||
lastName: 'Tester',
|
||||
role: 'User',
|
||||
permissions: ['documents.read', 'workflow.execute'],
|
||||
primaryOrganizationName: 'NP DMS',
|
||||
};
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => {
|
||||
useAuthStore.setState({ user: null, token: null, isAuthenticated: false });
|
||||
});
|
||||
});
|
||||
|
||||
it('ควรมีค่า default เป็นสถานะยังไม่ authenticated', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('setAuth ควรบันทึก user, token และสถานะ authenticated', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
});
|
||||
expect(result.current.user?.publicId).toBe(user.publicId);
|
||||
expect(result.current.token).toBe('access-token');
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('logout ควรล้างข้อมูล session ออกจาก store', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
result.current.logout();
|
||||
});
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('hasPermission ควรตรวจ permission ของ user ปัจจุบัน', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
expect(result.current.hasPermission('documents.read')).toBe(false);
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
});
|
||||
expect(result.current.hasPermission('documents.read')).toBe(true);
|
||||
expect(result.current.hasPermission('admin.manage')).toBe(false);
|
||||
});
|
||||
|
||||
it('hasPermission ควรให้ Admin ผ่านทุก permission', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
act(() => {
|
||||
result.current.setAuth({ ...user, role: 'admin', permissions: [] }, 'access-token');
|
||||
});
|
||||
expect(result.current.hasPermission('admin.manage')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasRole ควรเทียบ role แบบตรงตัวกับ user ปัจจุบัน', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
expect(result.current.hasRole('User')).toBe(false);
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
});
|
||||
expect(result.current.hasRole('User')).toBe(true);
|
||||
expect(result.current.hasRole('Admin')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// File: frontend/lib/stores/__tests__/draft-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for useDraftStore (Zustand)
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useDraftStore } from '../draft-store';
|
||||
|
||||
describe('useDraftStore', () => {
|
||||
beforeEach(() => {
|
||||
// รีเซ็ต store ก่อนแต่ละ test
|
||||
act(() => {
|
||||
useDraftStore.setState({ drafts: {} });
|
||||
});
|
||||
});
|
||||
|
||||
it('ค่า default ควรเป็น drafts: {}', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
expect(result.current.drafts).toEqual({});
|
||||
});
|
||||
|
||||
it('saveDraft ควรบันทึก draft data ด้วย key ที่กำหนด', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
const draftData = { title: 'Test Document', projectId: '019505a1-7c3e-7000-8000-abc123def456' };
|
||||
act(() => {
|
||||
result.current.saveDraft('rfa-new', draftData);
|
||||
});
|
||||
expect(result.current.drafts['rfa-new']).toEqual(draftData);
|
||||
});
|
||||
|
||||
it('getDraft ควรดึงข้อมูล draft ตาม key', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
const draftData = { subject: 'Correspondence Test', content: 'Body text' };
|
||||
act(() => {
|
||||
result.current.saveDraft('corr-edit', draftData);
|
||||
});
|
||||
const retrieved = result.current.getDraft('corr-edit');
|
||||
expect(retrieved).toEqual(draftData);
|
||||
});
|
||||
|
||||
it('getDraft ควร return undefined หาก key ไม่มีใน store', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
const retrieved = result.current.getDraft('non-existent-key');
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clearDraft ควรลบ draft ออกตาม key', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
act(() => {
|
||||
result.current.saveDraft('rfa-draft', { title: 'To Delete' });
|
||||
});
|
||||
expect(result.current.drafts['rfa-draft']).toBeDefined();
|
||||
act(() => {
|
||||
result.current.clearDraft('rfa-draft');
|
||||
});
|
||||
expect(result.current.drafts['rfa-draft']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('saveDraft ไม่ควรลบ draft อื่นที่ไม่ใช่ key เดียวกัน', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
act(() => {
|
||||
result.current.saveDraft('key-a', { data: 'A' });
|
||||
result.current.saveDraft('key-b', { data: 'B' });
|
||||
});
|
||||
act(() => {
|
||||
result.current.clearDraft('key-a');
|
||||
});
|
||||
expect(result.current.drafts['key-a']).toBeUndefined();
|
||||
expect(result.current.drafts['key-b']).toEqual({ data: 'B' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// File: frontend/lib/stores/__tests__/project-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for useProjectStore (Zustand)
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useProjectStore } from '../project-store';
|
||||
|
||||
describe('useProjectStore', () => {
|
||||
beforeEach(() => {
|
||||
// รีเซ็ต store ก่อนแต่ละ test
|
||||
act(() => {
|
||||
useProjectStore.setState({ selectedProjectId: null });
|
||||
});
|
||||
});
|
||||
|
||||
it('ค่า default ควรเป็น selectedProjectId: null', () => {
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
expect(result.current.selectedProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it('setSelectedProjectId ควรตั้งค่า selectedProjectId ด้วย UUIDv7 ที่กำหนด', () => {
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
const projectId = '019505a1-7c3e-7000-8000-abc123def456';
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(projectId);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBe(projectId);
|
||||
});
|
||||
|
||||
it('setSelectedProjectId ควรเปลี่ยน selectedProjectId จาก UUID เป็น null ได้', () => {
|
||||
const projectId = '019505a1-7c3e-7000-8000-abc123def456';
|
||||
act(() => {
|
||||
useProjectStore.setState({ selectedProjectId: projectId });
|
||||
});
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(null);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it('setSelectedProjectId ควรเปลี่ยน project ได้หลายครั้ง', () => {
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
const projectId1 = '019505a1-7c3e-7000-8000-abc123def001';
|
||||
const projectId2 = '019505a1-7c3e-7000-8000-abc123def002';
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(projectId1);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBe(projectId1);
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(projectId2);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBe(projectId2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// File: frontend/lib/stores/__tests__/ui-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for useUIStore (Zustand)
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useUIStore } from '../ui-store';
|
||||
|
||||
describe('useUIStore', () => {
|
||||
beforeEach(() => {
|
||||
// รีเซ็ต store ก่อนแต่ละ test
|
||||
act(() => {
|
||||
useUIStore.setState({ isSidebarOpen: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('ค่า default ควรเป็น isSidebarOpen: true', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
expect(result.current.isSidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleSidebar ควรสลับค่า isSidebarOpen จาก true เป็น false', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.toggleSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleSidebar ควรสลับค่า isSidebarOpen จาก false เป็น true', () => {
|
||||
act(() => {
|
||||
useUIStore.setState({ isSidebarOpen: false });
|
||||
});
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.toggleSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('closeSidebar ควรตั้งค่า isSidebarOpen เป็น false', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.closeSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('openSidebar ควรตั้งค่า isSidebarOpen เป็น true', () => {
|
||||
act(() => {
|
||||
useUIStore.setState({ isSidebarOpen: false });
|
||||
});
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.openSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// File: frontend/lib/utils/__tests__/uuid-guard.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for assertUuid utility (pure function 100%)
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assertUuid } from '../uuid-guard';
|
||||
|
||||
describe('assertUuid', () => {
|
||||
it('ควร return UUID ที่ถูกต้องกลับมา', () => {
|
||||
const validUuid = '019505a1-7c3e-7000-8000-abc123def456';
|
||||
expect(assertUuid(validUuid)).toBe(validUuid);
|
||||
});
|
||||
|
||||
it('ควร return UUIDv4 ที่ถูกต้องกลับมา', () => {
|
||||
const uuidV4 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||
expect(assertUuid(uuidV4)).toBe(uuidV4);
|
||||
});
|
||||
|
||||
it('ควร return UUID lowercase ที่ถูกต้องกลับมา', () => {
|
||||
const lowercase = '00000000-0000-0000-0000-000000000001';
|
||||
expect(assertUuid(lowercase)).toBe(lowercase);
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ value ไม่ใช่ UUID format', () => {
|
||||
expect(() => assertUuid('not-a-uuid')).toThrow('Invalid UUID format: not-a-uuid');
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ value เป็น integer string', () => {
|
||||
expect(() => assertUuid('12345')).toThrow('Invalid UUID format: 12345');
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ value เป็น string ว่าง', () => {
|
||||
expect(() => assertUuid('')).toThrow('Invalid UUID format: ');
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ UUID มี segment ไม่ครบ', () => {
|
||||
expect(() => assertUuid('019505a1-7c3e-7000-8000')).toThrow();
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ UUID มีตัวอักษรที่ไม่ใช่ hex', () => {
|
||||
expect(() => assertUuid('gggggggg-gggg-gggg-gggg-gggggggggggg')).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user