feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
CI / CD Pipeline / build (push) Failing after 6m24s
CI / CD Pipeline / deploy (push) Has been skipped

- 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:
2026-06-14 06:34:07 +07:00
parent e3503b6a77
commit 7e8f4859cd
108 changed files with 33914 additions and 339 deletions
@@ -0,0 +1,122 @@
// File: frontend/components/admin/__tests__/organization-dialog.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for OrganizationDialog component
// - 2026-06-13: Fix createTestQueryClient — ใช้ wrapper pattern ถูกต้อง
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { OrganizationDialog } from '../organization-dialog';
import { createTestQueryClient } from '@/lib/test-utils';
// Mock hooks
vi.mock('@/hooks/use-master-data', () => ({
useCreateOrganization: () => ({
mutate: vi.fn(),
isPending: false,
}),
useUpdateOrganization: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
// Mock Dialog component เพื่อให้ทดสอบง่ายขึ้น (Radix UI ใน jsdom)
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
function renderWithProvider(ui: React.ReactElement) {
const { wrapper: Wrapper } = createTestQueryClient();
return render(<Wrapper>{ui}</Wrapper>);
}
const mockOnOpenChange = vi.fn();
describe('OrganizationDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ควรไม่เรนเดอร์ Dialog เมื่อ open เป็น false', () => {
renderWithProvider(
<OrganizationDialog open={false} onOpenChange={mockOnOpenChange} />,
);
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
});
it('ควรเรนเดอร์ Dialog เมื่อ open เป็น true', () => {
renderWithProvider(
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
);
expect(screen.getByTestId('dialog')).toBeInTheDocument();
});
it('ควรแสดง title "New Organization" เมื่อไม่มี organization prop', () => {
renderWithProvider(
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
);
expect(screen.getByText('New Organization')).toBeInTheDocument();
});
it('ควรแสดง title "Edit Organization" เมื่อมี organization prop', () => {
const mockOrg = {
publicId: '019505a1-7c3e-7000-8000-abc123def001',
organizationCode: 'OWNER',
organizationName: 'Test Owner Co., Ltd.',
isActive: true,
} as any;
renderWithProvider(
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} organization={mockOrg} />,
);
expect(screen.getByText('Edit Organization')).toBeInTheDocument();
});
it('ควรแสดงปุ่ม Cancel และ Create Organization สำหรับ New', () => {
renderWithProvider(
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
);
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create Organization' })).toBeInTheDocument();
});
it('ควรแสดงปุ่ม Save Changes สำหรับ Edit', () => {
const mockOrg = {
publicId: '019505a1-7c3e-7000-8000-abc123def001',
organizationCode: 'OWNER',
organizationName: 'Test Owner Co., Ltd.',
isActive: true,
} as any;
renderWithProvider(
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} organization={mockOrg} />,
);
expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument();
});
it('ควรเรียก onOpenChange(false) เมื่อคลิก Cancel', async () => {
renderWithProvider(
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
});
it('ควรแสดง validation error เมื่อ submit form ว่างเปล่า', async () => {
renderWithProvider(
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
);
const form = screen.getByRole('button', { name: 'Create Organization' }).closest('form');
if (form) fireEvent.submit(form);
await waitFor(() => {
expect(screen.getByText('Organization Code is required')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,58 @@
// File: frontend/components/admin/__tests__/sidebar.test.tsx
// Change Log
// - 2026-06-13: Add coverage for admin sidebar navigation and expansion behavior.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminMobileSidebar, AdminSidebar } from '../sidebar';
const pathnameMock = vi.fn();
vi.mock('next/navigation', () => ({
usePathname: () => pathnameMock(),
}));
vi.mock('next/link', () => ({
default: ({ href, children, onClick, className }: { href: string; children: React.ReactNode; onClick?: () => void; className?: string }) => (
<a href={href} onClick={onClick} className={className}>
{children}
</a>
),
}));
describe('AdminSidebar', () => {
beforeEach(() => {
pathnameMock.mockReturnValue('/admin/access-control/users');
});
it('auto-expands the active menu and renders child links', () => {
render(<AdminSidebar />);
expect(screen.getByText('Admin Console')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'ผู้ใช้งาน' })).toHaveAttribute('href', '/admin/access-control/users');
expect(screen.getByRole('link', { name: /back to dashboard/i })).toHaveAttribute('href', '/dashboard');
});
it('toggles a collapsed menu on click', async () => {
const user = userEvent.setup();
pathnameMock.mockReturnValue('/admin/settings');
render(<AdminSidebar />);
expect(screen.queryByRole('link', { name: 'โครงการ' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /ตั้งค่าโครงการ/i }));
expect(screen.getByRole('link', { name: 'โครงการ' })).toBeInTheDocument();
});
});
describe('AdminMobileSidebar', () => {
beforeEach(() => {
pathnameMock.mockReturnValue('/admin/settings');
});
it('opens mobile navigation from trigger button', async () => {
const user = userEvent.setup();
render(<AdminMobileSidebar />);
await user.click(screen.getByRole('button', { name: 'Toggle admin menu' }));
expect(screen.getByText('Admin Navigation')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /AI Console/i })).toHaveAttribute('href', '/admin/ai');
});
});
@@ -0,0 +1,144 @@
// File: frontend/components/admin/__tests__/user-dialog.test.tsx
// Change Log
// - 2026-06-13: Add coverage for admin user dialog create and edit flows.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { UserDialog } from '../user-dialog';
import { useCreateUser, useRoles, useUpdateUser } from '@/hooks/use-users';
import { useOrganizations } from '@/hooks/use-master-data';
import type { User } from '@/types/user';
const createMutate = vi.fn();
const updateMutate = vi.fn();
vi.mock('@/hooks/use-users', () => ({
useCreateUser: vi.fn(),
useUpdateUser: vi.fn(),
useRoles: vi.fn(),
}));
vi.mock('@/hooks/use-master-data', () => ({
useOrganizations: vi.fn(),
}));
const existingUser: User = {
publicId: '019505a1-7c3e-7000-8000-abc123defb01',
username: 'existing',
email: 'existing@example.com',
firstName: 'Existing',
lastName: 'User',
isActive: true,
lineId: 'line-existing',
primaryOrganizationId: '019505a1-7c3e-7000-8000-abc123defb02',
roles: [
{
publicId: '019505a1-7c3e-7000-8000-abc123defb03',
roleId: 2,
roleName: 'Reviewer',
description: 'Reviews documents',
},
],
failedAttempts: 0,
};
function input(name: string): HTMLInputElement {
const found = document.body.querySelector(`input[name="${name}"]`);
if (!(found instanceof HTMLInputElement)) {
throw new Error(`Input not found: ${name}`);
}
return found;
}
describe('UserDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useCreateUser).mockReturnValue({
mutate: createMutate,
isPending: false,
} as unknown as ReturnType<typeof useCreateUser>);
vi.mocked(useUpdateUser).mockReturnValue({
mutate: updateMutate,
isPending: false,
} as unknown as ReturnType<typeof useUpdateUser>);
vi.mocked(useRoles).mockReturnValue({
data: [
{
publicId: '019505a1-7c3e-7000-8000-abc123defb03',
roleId: 2,
roleName: 'Reviewer',
description: 'Reviews documents',
},
],
} as unknown as ReturnType<typeof useRoles>);
vi.mocked(useOrganizations).mockReturnValue({
data: [
{
publicId: '019505a1-7c3e-7000-8000-abc123defb02',
organizationCode: 'TEAM',
organizationName: 'TEAM Consulting',
},
],
} as unknown as ReturnType<typeof useOrganizations>);
});
it('creates a user with required fields and selected role', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(<UserDialog open onOpenChange={onOpenChange} />);
await user.type(input('username'), 'newuser');
await user.type(input('email'), 'new@example.com');
await user.type(input('firstName'), 'New');
await user.type(input('lastName'), 'User');
await user.type(input('password'), 'secret1');
await user.type(input('confirmPassword'), 'secret1');
await user.click(screen.getByRole('checkbox', { name: /Reviewer/i }));
await user.click(screen.getByRole('button', { name: 'Create User' }));
await waitFor(() => {
expect(createMutate).toHaveBeenCalledWith(
expect.objectContaining({
username: 'newuser',
email: 'new@example.com',
firstName: 'New',
lastName: 'User',
password: 'secret1',
roleIds: [2],
}),
expect.objectContaining({ onSuccess: expect.any(Function) })
);
});
});
it('pre-fills existing user and submits update without empty password', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(<UserDialog open onOpenChange={onOpenChange} user={existingUser} />);
expect(input('username')).toHaveValue('existing');
await user.clear(input('firstName'));
await user.type(input('firstName'), 'Edited');
await user.click(screen.getByRole('checkbox', { name: 'Active User' }));
await user.click(screen.getByRole('button', { name: 'Update User' }));
await waitFor(() => {
expect(updateMutate).toHaveBeenCalledWith(
{
uuid: existingUser.publicId,
data: expect.objectContaining({
firstName: 'Edited',
isActive: false,
}),
},
expect.objectContaining({ onSuccess: expect.any(Function) })
);
});
expect(updateMutate.mock.calls[0][0].data).not.toHaveProperty('password');
});
it('closes when cancel is clicked', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(<UserDialog open onOpenChange={onOpenChange} />);
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
@@ -3,16 +3,23 @@
// - 2026-05-25: Created OcrSandboxPromptManager component for dynamic prompt editing, version control, and sandbox testing (ADR-029)
// - 2026-05-25: Extracted inline strings to i18n keys via useTranslations() (Obs #1 fix)
// - 2026-05-25: Refactored sandbox polling to useSandboxRun hook (Obs #2 fix)
// - 2026-05-26: เพิ่มการตรวจสอบ versionsQuery.data แบบทนทานเพื่อป้องกัน Error N.find is not a function ในกรณีที่ API ส่งข้อมูลแบบ wrapped object มา
// - 2026-05-26: เพิ่มการตรวจสอบ versionsQuery.data แบบทนทานเพื่อป้องกัน Error N.find is not a function
// - 2026-05-29: เพิ่ม OCR Raw Text section ในผล sandbox
// - 2026-05-29: ปรับปรุงการโหลด Active Prompt ให้ทนทานต่อ race conditions และรูปแบบประเภทข้อมูลที่ส่งมาจาก API (boolean, number, string)
// - 2026-05-29: ปรับปรุงการโหลด Active Prompt ให้ทนทานต่อ race conditions และรูปแบบประเภทข้อมูล
// - 2026-05-30: Refactor เป็น 2-step flow (Step 1: OCR-only → Step 2: AI Extraction) ตาม spec 231
// - 2026-06-02: ปรับปรุงลำดับปุ่มแท็บเริ่มต้นให้เริ่มที่ OCR Sandbox และเปลี่ยน dropdown labels ของตัวเลือกโมเดล Typhoon OCR ให้แสดงหน่วยความจำ VRAM แม่นยำ (T012, T013, ADR-033)
// - 2026-06-04: เปลี่ยน OCR Engine dropdown จาก hardcoded เป็น dynamic โดยดึงจาก getOcrEngines() API และ map engineType → SandboxOcrEngineType
// - 2026-06-04: เพิ่ม UI sliders (temperature/topP/repeatPenalty) สำหรับ typhoon-np-dms-ocr engine; ส่งเป็น optional override ไปยัง sidecar
// - 2026-06-02: ปรับปรุงลำดับปุ่มแท็บเริ่มต้นให้เริ่มที่ OCR Sandbox และเปลี่ยน dropdown labels ของ Typhoon OCR
// - 2026-06-04: เปลี่ยน OCR Engine dropdown จาก hardcoded เป็น dynamic โดยดึงจาก getOcrEngines() API
// - 2026-06-04: เพิ่ม UI sliders (temperature/topP/repeatPenalty) สำหรับ OCR engine
// - 2026-06-13: ADR-036 — เปลี่ยน sandbox OCR engine key เป็น np-dms-ocr
// - 2026-06-13: T030 — เพิ่ม Sandbox Parameter Panel สำหรับ tuning production profile draft
// - 2026-06-13: T044-T045 — เพิ่มปุ่ม Apply to Production และแสดงผลแผงพารามิเตอร์ของระบบ Production แบบอ่านอย่างเดียว
// - 2026-06-13: US4 — เพิ่ม project/contract selectors สำหรับ sandbox context parity
// - 2026-06-13: US5 — เพิ่มลิงก์สลับไปยังหน้าจัดการ Prompt Version (Editor tab) จากส่วนเลือกเวอร์ชันใน Sandbox
// - 2026-06-13: US9 — แก้ไข ESLint errors: ลบ parseInt และแก้ไข unsafe any type casting ของ projects/contracts
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -34,10 +41,23 @@ import {
} from 'lucide-react';
import { useAiPrompts, useSandboxRun } from '@/hooks/use-ai-prompts';
import { useTranslations } from '@/hooks/use-translations';
import { useProjects, useContracts } from '@/hooks/use-master-data';
import PromptVersionHistory from './PromptVersionHistory';
import { cn } from '@/lib/utils';
import { AiPrompt } from '@/types/ai-prompts';
import { adminAiService, OcrEngineResponse } from '@/lib/services/admin-ai.service';
import { adminAiService, OcrEngineResponse, SandboxProfileParams } from '@/lib/services/admin-ai.service';
interface SandboxProjectOption {
publicId: string;
projectCode: string;
projectName: string;
}
interface SandboxContractOption {
publicId: string;
contractCode: string;
contractName: string;
}
const DEFAULT_OCR_TEMPLATE = `คุณคือเอนจิ้นสกัดข้อมูลอัจฉริยะ (Document Intelligence Engine)
วิเคราะห์ข้อความ OCR ที่ได้รับจากเอกสารของโครงการ Laem Chabang Port Phase 3 และสกัดข้อมูลเมตาดาต้าให้ออกมาเป็น JSON object ที่ถูกต้องตามโครงสร้างที่กำหนด
@@ -120,6 +140,110 @@ export default function OcrSandboxPromptManager() {
queryFn: () => adminAiService.getOcrEngines(),
staleTime: 60_000,
});
// --- Sandbox Parameter Panel state (T030, ADR-036) ---
const [selectedModel, setSelectedModel] = useState<'np-dms-ai' | 'np-dms-ocr'>('np-dms-ai');
const profileName = selectedModel === 'np-dms-ai' ? 'standard' : 'ocr-extract';
const [sandboxParams, setSandboxParams] = useState<SandboxProfileParams | null>(null);
const [sandboxParamsDraft, setSandboxParamsDraft] = useState<Partial<SandboxProfileParams>>({});
const [isSavingParams, setIsSavingParams] = useState(false);
const [isResettingParams, setIsResettingParams] = useState(false);
const [showParamPanel, setShowParamPanel] = useState(false);
// --- US4 states ---
const [selectedProjectPublicId, setSelectedProjectPublicId] = useState<string>('');
const [selectedContractPublicId, setSelectedContractPublicId] = useState<string>('');
const { data: projectsData } = useProjects();
const projects = Array.isArray(projectsData) ? (projectsData as SandboxProjectOption[]) : [];
const { data: contractsData } = useContracts(selectedProjectPublicId);
const contracts = Array.isArray(contractsData) ? (contractsData as SandboxContractOption[]) : [];
const handleProjectChange = (projectId: string) => {
setSelectedProjectPublicId(projectId);
setSelectedContractPublicId('');
};
// --- Phase 4 apply and production defaults states (T044, T045) ---
const [prodParams, setProdParams] = useState<SandboxProfileParams | null>(null);
const [isApplyingParams, setIsApplyingParams] = useState(false);
const fetchProdParams = useCallback(async () => {
try {
const params = await adminAiService.getProductionDefaults(profileName);
setProdParams(params);
} catch {
// Ignored
}
}, [profileName]);
useEffect(() => {
adminAiService.getSandboxProfile(profileName)
.then((params) => {
setSandboxParams(params);
setSandboxParamsDraft({
temperature: params.temperature,
topP: params.topP,
repeatPenalty: params.repeatPenalty,
maxTokens: params.maxTokens,
numCtx: params.numCtx,
keepAliveSeconds: params.keepAliveSeconds,
});
})
.catch(() => { /* ไม่ต้องแสดง error — อาจเป็น 403 หาก feature ยังไม่เปิด */ });
fetchProdParams();
}, [profileName, fetchProdParams]);
const handleSaveParams = useCallback(async () => {
setIsSavingParams(true);
try {
const key = `sandbox-params-${profileName}-${Date.now()}`;
const updated = await adminAiService.saveSandboxProfile(profileName, sandboxParamsDraft, key);
setSandboxParams(updated);
toast.success('Sandbox parameters saved');
} catch {
toast.error('Failed to save sandbox parameters');
} finally {
setIsSavingParams(false);
}
}, [profileName, sandboxParamsDraft]);
const handleApplyParams = useCallback(async () => {
if (!confirm(`Are you sure you want to apply sandbox draft parameters for ${profileName} to production? This will immediately affect live production jobs.`)) {
return;
}
setIsApplyingParams(true);
try {
const idempotencyKey = `apply-params-${profileName}-${Date.now()}`;
await adminAiService.applyProfile(profileName, idempotencyKey);
toast.success('Parameters successfully applied to production!');
await fetchProdParams();
} catch {
toast.error('Failed to apply parameters to production');
} finally {
setIsApplyingParams(false);
}
}, [profileName, fetchProdParams]);
const handleResetParams = useCallback(async () => {
setIsResettingParams(true);
try {
const reset = await adminAiService.resetSandboxProfile(profileName);
setSandboxParams(reset);
setSandboxParamsDraft({
temperature: reset.temperature,
topP: reset.topP,
repeatPenalty: reset.repeatPenalty,
maxTokens: reset.maxTokens,
numCtx: reset.numCtx,
keepAliveSeconds: reset.keepAliveSeconds,
});
toast.success('Sandbox parameters reset to production values');
} catch {
toast.error('Failed to reset sandbox parameters');
} finally {
setIsResettingParams(false);
}
}, [profileName]);
const ocrEngineOptions = useMemo(() => {
const base = [{ value: 'auto', label: 'Auto (Current Baseline)' }];
if (!ocrEnginesData) return base;
@@ -128,7 +252,7 @@ export default function OcrSandboxPromptManager() {
e.engineType === 'tesseract'
? 'tesseract'
: e.engineType === 'typhoon_ocr'
? 'typhoon-np-dms-ocr'
? 'np-dms-ocr'
: e.engineType;
const vramLabel =
e.vramRequirementMB > 0
@@ -222,6 +346,10 @@ export default function OcrSandboxPromptManager() {
// Step 1: OCR-only handler
const handleStep1Ocr = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProjectPublicId) {
toast.error('Please select a project first');
return;
}
if (!ocrFile) {
toast.error(t('ai.prompt.noFile'));
return;
@@ -229,7 +357,7 @@ export default function OcrSandboxPromptManager() {
try {
resetSandbox();
setSandboxStep('ocr');
const typhoonOptions = selectedOcrEngine === 'typhoon-np-dms-ocr'
const typhoonOptions = selectedOcrEngine === 'np-dms-ocr'
? { temperature: typhoonTemperature, topP: typhoonTopP, repeatPenalty: typhoonRepeatPenalty }
: undefined;
const { requestPublicId } = await adminAiService.submitSandboxOcr(
@@ -270,6 +398,10 @@ export default function OcrSandboxPromptManager() {
// Step 2: AI Extraction handler
const handleStep2AiExtract = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProjectPublicId) {
toast.error('Please select a project first');
return;
}
if (!ocrResult) {
toast.error('Please run Step 1 (OCR) first');
return;
@@ -282,7 +414,9 @@ export default function OcrSandboxPromptManager() {
resetSandbox();
const { requestPublicId } = await adminAiService.submitSandboxAiExtract(
ocrResult.requestPublicId,
selectedPromptVersion
selectedPromptVersion,
selectedProjectPublicId,
selectedContractPublicId || undefined
);
toast.success('AI Extraction started');
// เริ่ม polling ผ่าน useSandboxRun hook
@@ -302,6 +436,8 @@ export default function OcrSandboxPromptManager() {
setTyphoonTopP(0.1);
setTyphoonRepeatPenalty(1.1);
setOcrFile(null);
setSelectedProjectPublicId('');
setSelectedContractPublicId('');
resetSandbox();
};
// แปล status key เป็นข้อความตาม locale ปัจจุบัน
@@ -396,10 +532,140 @@ export default function OcrSandboxPromptManager() {
: 'Step 2: Test AI prompt with OCR text'}
</p>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{/* Project and Contract Selectors (US4) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-semibold text-foreground flex items-center gap-1">
Project <span className="text-destructive">*</span>
</label>
<select
value={selectedProjectPublicId}
onChange={(e) => handleProjectChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
disabled={sandboxState.isRunning}
>
<option value="">-- Select Project --</option>
{projects.map((proj) => (
<option key={proj.publicId} value={proj.publicId}>
{proj.projectCode} - {proj.projectName}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-foreground">
Contract
</label>
<select
value={selectedContractPublicId}
onChange={(e) => setSelectedContractPublicId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
disabled={sandboxState.isRunning || !selectedProjectPublicId}
>
<option value="">-- Select Contract (Optional) --</option>
{contracts.map((ctr) => (
<option key={ctr.publicId} value={ctr.publicId}>
{ctr.contractCode} - {ctr.contractName}
</option>
))}
</select>
</div>
</div>
<div className="border-t border-border/10 my-4" />
{sandboxStep === 'ocr' ? (
<form onSubmit={handleStep1Ocr} className="space-y-4">
<div className="space-y-2">
<div className="space-y-4">
{/* --- Sandbox Parameter Panel (T030) --- */}
{sandboxParams && (
<div className="rounded-md border border-border/30 bg-muted/10">
<button
type="button"
onClick={() => setShowParamPanel((v) => !v)}
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<span>LLM Sandbox Parameters (production profile draft)</span>
<span className="text-[10px]">{showParamPanel ? '\u25b2' : '\u25bc'}</span>
</button>
{showParamPanel && (
<div className="px-3 pb-3 space-y-3 border-t border-border/20 pt-3">
<div className="space-y-1">
<label className="text-[10px] font-medium text-muted-foreground">Model Profile (T050)</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value as 'np-dms-ai' | 'np-dms-ocr')}
className="w-full rounded border border-input bg-background px-2.5 py-1 text-xs"
>
<option value="np-dms-ai">LLM Engine (np-dms-ai / standard)</option>
<option value="np-dms-ocr">OCR Engine (np-dms-ocr / ocr-extract)</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="space-y-1">
<div className="flex justify-between"><label>Temperature</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.temperature ?? sandboxParams?.temperature) ?? 0).toFixed(2)}</span></div>
<input type="range" min={0} max={1} step={0.01} value={(sandboxParamsDraft.temperature ?? sandboxParams?.temperature) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, temperature: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Top-P</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.topP ?? sandboxParams?.topP) ?? 0).toFixed(2)}</span></div>
<input type="range" min={0} max={1} step={0.01} value={(sandboxParamsDraft.topP ?? sandboxParams?.topP) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, topP: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Repeat Penalty</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.repeatPenalty ?? sandboxParams?.repeatPenalty) ?? 1).toFixed(2)}</span></div>
<input type="range" min={1} max={2} step={0.01} value={(sandboxParamsDraft.repeatPenalty ?? sandboxParams?.repeatPenalty) ?? 1} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, repeatPenalty: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Keep-Alive (s)</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.keepAliveSeconds ?? sandboxParams?.keepAliveSeconds) ?? 0}</span></div>
<input type="range" min={0} max={3600} step={60} value={(sandboxParamsDraft.keepAliveSeconds ?? sandboxParams?.keepAliveSeconds) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, keepAliveSeconds: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
{selectedModel === 'np-dms-ai' && (
<>
<div className="space-y-1">
<div className="flex justify-between"><label>Max Tokens</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.maxTokens ?? sandboxParams?.maxTokens) ?? 4096}</span></div>
<input type="range" min={256} max={16384} step={256} value={(sandboxParamsDraft.maxTokens ?? sandboxParams?.maxTokens) ?? 4096} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, maxTokens: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Ctx Size</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.numCtx ?? sandboxParams?.numCtx) ?? 8192}</span></div>
<input type="range" min={1024} max={32768} step={1024} value={(sandboxParamsDraft.numCtx ?? sandboxParams?.numCtx) ?? 8192} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, numCtx: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
</>
)}
</div>
{/* Production Defaults Read-Only Panel (T045) */}
{prodParams && (
<div className="rounded border border-emerald-500/20 bg-emerald-500/5 p-2.5 text-xs space-y-1">
<p className="font-semibold text-emerald-600 dark:text-emerald-400">Current Production Parameters (Read-only)</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] font-mono text-muted-foreground font-semibold">
<div>Model: {prodParams.canonicalModel}</div>
<div>Temperature: {prodParams.temperature.toFixed(2)}</div>
<div>Top-P: {prodParams.topP.toFixed(2)}</div>
<div>Repeat Penalty: {prodParams.repeatPenalty.toFixed(2)}</div>
<div>Keep-Alive: {prodParams.keepAliveSeconds}s</div>
{prodParams.maxTokens !== null && <div>Max Tokens: {prodParams.maxTokens}</div>}
{prodParams.numCtx !== null && <div>Ctx Size: {prodParams.numCtx}</div>}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-1 flex-wrap">
<Button type="button" variant="outline" size="sm" disabled={isResettingParams} onClick={handleResetParams} className="text-xs h-7 px-3">
{isResettingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reset to Production'}
</Button>
<Button type="button" variant="secondary" size="sm" disabled={isSavingParams} onClick={handleSaveParams} className="text-xs h-7 px-3">
{isSavingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save Draft'}
</Button>
<Button type="button" variant="destructive" size="sm" disabled={isApplyingParams} onClick={handleApplyParams} className="text-xs h-7 px-3 flex items-center gap-1">
{isApplyingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
Apply to Production
</Button>
</div>
</div>
)}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-medium">OCR Engine</label>
<select
@@ -407,14 +673,12 @@ export default function OcrSandboxPromptManager() {
onChange={(e) => setSelectedOcrEngine(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
>
{ocrEngineOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{ocrEngineOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{selectedOcrEngine === 'typhoon-np-dms-ocr' && (
{selectedOcrEngine === 'np-dms-ocr' && (
<div className="space-y-3 rounded-md border border-dashed border-amber-500/30 bg-amber-500/5 p-3">
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">Typhoon OCR Options <span className="font-normal text-muted-foreground">(override Modelfile defaults)</span></p>
<div className="space-y-1">
@@ -516,7 +780,7 @@ export default function OcrSandboxPromptManager() {
<div className="flex justify-end gap-3 pt-2">
<Button
type="submit"
disabled={sandboxState.isRunning || !ocrFile}
disabled={sandboxState.isRunning || !ocrFile || !selectedProjectPublicId}
className="flex items-center gap-2"
>
{sandboxState.isRunning ? (
@@ -537,7 +801,16 @@ export default function OcrSandboxPromptManager() {
<form onSubmit={handleStep2AiExtract} className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Prompt Version:</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium">Prompt Version:</span>
<button
type="button"
onClick={() => setActiveTab('editor')}
className="text-[10px] text-primary hover:underline font-semibold"
>
(Manage/Edit Prompts)
</button>
</div>
<select
value={selectedPromptVersion ?? (activePrompt?.versionNumber ?? '')}
onChange={(e) => setSelectedPromptVersion(e.target.value ? Number(e.target.value) : undefined)}
@@ -562,7 +835,7 @@ export default function OcrSandboxPromptManager() {
</Button>
<Button
type="submit"
disabled={sandboxState.isRunning || !activePrompt}
disabled={sandboxState.isRunning || !activePrompt || !selectedProjectPublicId}
className="flex items-center gap-2"
>
{sandboxState.isRunning ? (
@@ -591,7 +864,7 @@ export default function OcrSandboxPromptManager() {
OCR Raw Text (Step 1 Result)
</CardTitle>
<Badge variant="outline" className="text-xs">
{ocrResult.engineUsed === 'typhoon-np-dms-ocr'
{ocrResult.engineUsed === 'np-dms-ocr'
? 'np-dms-ocr'
: ocrResult.ocrUsed
? 'Tesseract'
@@ -0,0 +1,71 @@
// File: frontend/components/admin/ai/__tests__/ocr-engine-selector.test.tsx
// Change Log
// - 2026-06-13: Add coverage for OCR engine loading, display, and selection flows.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toast } from 'sonner';
import OcrEngineSelector from '../OcrEngineSelector';
import { adminAiService, type OcrEngineResponse } from '@/lib/services/admin-ai.service';
vi.mock('@/lib/services/admin-ai.service', () => ({
adminAiService: {
getOcrEngines: vi.fn(),
selectOcrEngine: vi.fn(),
},
}));
const engines: OcrEngineResponse[] = [
{
engineId: 'tesseract',
engineName: 'Tesseract OCR',
engineType: 'tesseract',
isCurrentActive: true,
concurrentLimit: 4,
vramRequirementMB: 0,
},
{
engineId: 'typhoon',
engineName: 'Typhoon OCR',
engineType: 'typhoon_ocr',
isCurrentActive: false,
concurrentLimit: 1,
vramRequirementMB: 6144,
},
];
describe('OcrEngineSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(adminAiService.getOcrEngines).mockResolvedValue(engines);
vi.mocked(adminAiService.selectOcrEngine).mockResolvedValue({ success: true });
});
it('renders OCR engine data from admin service', async () => {
render(<OcrEngineSelector />);
expect(await screen.findByText('Tesseract OCR')).toBeInTheDocument();
expect(screen.getByText('Typhoon OCR')).toBeInTheDocument();
expect(screen.getByText('AI Powered')).toBeInTheDocument();
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(1);
});
it('selects a non-active OCR engine and refreshes list', async () => {
const user = userEvent.setup();
render(<OcrEngineSelector />);
await user.click(await screen.findByRole('button', { name: 'สลับใช้งาน' }));
await waitFor(() => {
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('typhoon');
});
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ');
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2);
});
it('shows an error toast when loading engines fails', async () => {
vi.mocked(adminAiService.getOcrEngines).mockRejectedValue(new Error('API error'));
render(<OcrEngineSelector />);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('ไม่สามารถดึงข้อมูล OCR Engines ได้');
});
});
});
@@ -0,0 +1,206 @@
// File: frontend/components/admin/ai/__tests__/ocr-sandbox-prompt-manager.test.tsx
// Change Log:
// - 2026-06-14: Add smoke coverage for OcrSandboxPromptManager sandbox/editor paths
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import OcrSandboxPromptManager from '../OcrSandboxPromptManager';
import { AiPrompt } from '@/types/ai-prompts';
const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastError: vi.fn(),
refetchVersions: vi.fn(),
createVersion: vi.fn(),
activateVersion: vi.fn(),
deleteVersion: vi.fn(),
updateNote: vi.fn(),
resetSandbox: vi.fn(),
startPolling: vi.fn(),
}));
const prompts: AiPrompt[] = [
{
promptType: 'ocr_extraction',
versionNumber: 2,
template: 'Extract {{ocr_text}} with {{master_data_context}}',
isActive: true,
testResultJson: null,
manualNote: null,
lastTestedAt: null,
activatedAt: '2026-06-01T00:00:00Z',
createdAt: '2026-06-01T00:00:00Z',
},
];
vi.mock('sonner', () => ({
toast: {
success: mocks.toastSuccess,
error: mocks.toastError,
},
}));
vi.mock('@/hooks/use-translations', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
if (key === 'ai.prompt.activeLabel') return `Active: v${params?.version}`;
if (key === 'ai.prompt.charCount') return `${params?.count} / 4000 ตัวอักษร`;
return key;
},
}));
vi.mock('@/hooks/use-ai-prompts', () => ({
useAiPrompts: () => ({
versionsQuery: {
data: prompts,
isSuccess: true,
isLoading: false,
refetch: mocks.refetchVersions,
},
createMutation: { mutateAsync: mocks.createVersion, isPending: false },
activateMutation: { mutateAsync: mocks.activateVersion, isPending: false },
deleteMutation: { mutateAsync: mocks.deleteVersion, isPending: false },
updateNoteMutation: { mutateAsync: mocks.updateNote, isPending: false },
}),
useSandboxRun: () => ({
state: {
isRunning: false,
progress: 0,
statusText: '',
result: null,
},
jobId: null,
reset: mocks.resetSandbox,
startPolling: mocks.startPolling,
}),
}));
vi.mock('@/hooks/use-master-data', () => ({
useProjects: () => ({
data: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def201',
projectCode: 'LCB3',
projectName: 'Laem Chabang Phase 3',
},
],
}),
useContracts: () => ({
data: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def301',
contractCode: 'C01',
contractName: 'Marine Works',
},
],
}),
}));
vi.mock('@/lib/services/admin-ai.service', () => ({
adminAiService: {
getOcrEngines: vi.fn().mockResolvedValue([
{
engineType: 'typhoon_ocr',
engineName: 'np-dms-ocr',
vramRequirementMB: 4096,
isCurrentActive: true,
},
]),
getSandboxProfile: vi.fn().mockResolvedValue({
temperature: 0.1,
topP: 0.2,
repeatPenalty: 1.1,
maxTokens: 1024,
numCtx: 4096,
keepAliveSeconds: 0,
}),
getProductionDefaults: vi.fn().mockResolvedValue({
temperature: 0.2,
topP: 0.3,
repeatPenalty: 1.2,
maxTokens: 2048,
numCtx: 8192,
keepAliveSeconds: 60,
}),
saveSandboxProfile: vi.fn().mockResolvedValue({
temperature: 0.2,
topP: 0.3,
repeatPenalty: 1.2,
maxTokens: 2048,
numCtx: 8192,
keepAliveSeconds: 60,
}),
resetSandboxProfile: vi.fn().mockResolvedValue({
temperature: 0.1,
topP: 0.2,
repeatPenalty: 1.1,
maxTokens: 1024,
numCtx: 4096,
keepAliveSeconds: 0,
}),
applyProfile: vi.fn().mockResolvedValue({ ok: true }),
submitSandboxOcr: vi.fn().mockResolvedValue({
requestPublicId: '019505a1-7c3e-7000-8000-abc123def401',
}),
getSandboxJobStatus: vi.fn().mockResolvedValue({
status: 'pending',
}),
},
}));
vi.mock('../PromptVersionHistory', () => ({
default: ({ versions, onLoadTemplate }: { versions: AiPrompt[]; onLoadTemplate: (version: AiPrompt) => void }) => (
<div data-testid="prompt-version-history">
<span>{versions.length} versions</span>
<button type="button" onClick={() => onLoadTemplate(versions[0])}>
Load version
</button>
</div>
),
}));
const renderManager = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<OcrSandboxPromptManager />
</QueryClientProvider>
);
};
describe('OcrSandboxPromptManager', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
});
it('ควร render sandbox tab พร้อม project, contract, engine และ history', async () => {
renderManager();
expect(screen.getByText('ai.prompt.tabSandbox')).toBeInTheDocument();
expect(screen.getByText('Step 1: Run OCR Only')).toBeInTheDocument();
expect(screen.getByTestId('prompt-version-history')).toHaveTextContent('1 versions');
await waitFor(() => expect(screen.getByText(/np-dms-ocr/)).toBeInTheDocument());
});
it('ควรสลับไป editor และบันทึก prompt version ได้', async () => {
mocks.createVersion.mockResolvedValueOnce(prompts[0]);
renderManager();
fireEvent.click(screen.getByText('ai.prompt.tabEditor'));
expect(await screen.findByDisplayValue('Extract {{ocr_text}} with {{master_data_context}}')).toBeInTheDocument();
fireEvent.click(screen.getByText('ai.prompt.saveVersion'));
await waitFor(() => expect(mocks.createVersion).toHaveBeenCalledWith('Extract {{ocr_text}} with {{master_data_context}}'));
expect(mocks.toastSuccess).toHaveBeenCalledWith('ai.prompt.saveVersionSuccess');
});
it('ควร load template จาก history เข้า editor', async () => {
renderManager();
fireEvent.click(screen.getByText('Load version'));
expect(await screen.findByDisplayValue('Extract {{ocr_text}} with {{master_data_context}}')).toBeInTheDocument();
expect(mocks.toastSuccess).toHaveBeenCalledWith('ai.prompt.loadSuccess');
});
});
@@ -0,0 +1,73 @@
// File: frontend/components/admin/ai/__tests__/prompt-version-history.test.tsx
// Change Log
// - 2026-06-13: Add coverage for prompt version history rendering and actions.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import PromptVersionHistory from '../PromptVersionHistory';
import type { AiPrompt } from '@/types/ai-prompts';
const versions: AiPrompt[] = [
{
publicId: '019505a1-7c3e-7000-8000-abc123defa01',
promptType: 'metadata_extraction',
versionNumber: 3,
template: 'active prompt',
isActive: true,
createdAt: '2026-06-13T08:00:00.000Z',
lastTestedAt: '2026-06-13T09:00:00.000Z',
manualNote: 'ผ่านการทดสอบกับ RFA แล้ว',
},
{
publicId: '019505a1-7c3e-7000-8000-abc123defa02',
promptType: 'metadata_extraction',
versionNumber: 2,
template: 'draft prompt',
isActive: false,
createdAt: '2026-06-12T08:00:00.000Z',
},
];
describe('PromptVersionHistory', () => {
it('renders loading and empty states', () => {
const callbacks = {
onLoadTemplate: vi.fn(),
onActivateVersion: vi.fn(),
onDeleteVersion: vi.fn(),
};
const { rerender } = render(
<PromptVersionHistory versions={[]} isLoading {...callbacks} isActivating={false} isDeleting={false} />
);
expect(screen.getByText('กำลังโหลดประวัติเวอร์ชัน...')).toBeInTheDocument();
rerender(<PromptVersionHistory versions={[]} isLoading={false} {...callbacks} isActivating={false} isDeleting={false} />);
expect(screen.getByText('ไม่พบเวอร์ชันอื่นในระบบ')).toBeInTheDocument();
});
it('renders versions and triggers version actions', async () => {
const user = userEvent.setup();
const onLoadTemplate = vi.fn();
const onActivateVersion = vi.fn();
const onDeleteVersion = vi.fn();
render(
<PromptVersionHistory
versions={versions}
isLoading={false}
onLoadTemplate={onLoadTemplate}
onActivateVersion={onActivateVersion}
onDeleteVersion={onDeleteVersion}
isActivating={false}
isDeleting={false}
/>
);
expect(screen.getByText('v3')).toBeInTheDocument();
expect(screen.getByText('ใช้งานจริง (Active)')).toBeInTheDocument();
expect(screen.getByText('ผ่านการทดสอบกับ RFA แล้ว')).toBeInTheDocument();
await user.click(screen.getAllByRole('button', { name: 'โหลด (Load)' })[1]);
await user.click(screen.getByRole('button', { name: 'ใช้งาน (Activate)' }));
await user.click(screen.getByRole('button', { name: '' }));
expect(onLoadTemplate).toHaveBeenCalledWith(versions[1]);
expect(onActivateVersion).toHaveBeenCalledWith(2);
expect(onDeleteVersion).toHaveBeenCalledWith(2);
});
});
@@ -0,0 +1,94 @@
// File: frontend/components/admin/reference/__tests__/generic-crud-table.test.tsx
// Change Log
// - 2026-06-13: Add coverage for generic reference CRUD table states and create mutation.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type ColumnDef } from '@tanstack/react-table';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toast } from 'sonner';
import { createTestQueryClient } from '@/lib/test-utils';
import { GenericCrudTable } from '../generic-crud-table';
type ReferenceRow = {
id: number;
publicId: string;
code: string;
name: string;
active: boolean;
};
const rows: ReferenceRow[] = [
{
id: 1,
publicId: '019505a1-7c3e-7000-8000-abc123def701',
code: 'DISC',
name: 'Discipline',
active: true,
},
];
const columns: ColumnDef<ReferenceRow>[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'name', header: 'Name' },
];
function renderTable(overrides?: Partial<React.ComponentProps<typeof GenericCrudTable<ReferenceRow>>>) {
const { wrapper } = createTestQueryClient();
const props: React.ComponentProps<typeof GenericCrudTable<ReferenceRow>> = {
title: 'Reference Data',
description: 'Manage reference data',
entityName: 'Reference',
queryKey: ['reference-test'],
fetchFn: vi.fn().mockResolvedValue(rows),
createFn: vi.fn().mockResolvedValue({ success: true }),
updateFn: vi.fn().mockResolvedValue({ success: true }),
deleteFn: vi.fn().mockResolvedValue({ success: true }),
columns,
fields: [
{ name: 'code', label: 'Code', type: 'text', required: true },
{ name: 'name', label: 'Name', type: 'textarea' },
{ name: 'active', label: 'Active', type: 'checkbox' },
],
...overrides,
};
return {
...render(<GenericCrudTable<ReferenceRow> {...props} />, { wrapper }),
props,
};
}
describe('GenericCrudTable', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders data rows returned by fetchFn', async () => {
renderTable();
expect(await screen.findByText('DISC')).toBeInTheDocument();
expect(screen.getByText('Reference Data')).toBeInTheDocument();
expect(screen.getByText('Discipline')).toBeInTheDocument();
});
it('renders empty state for wrapped empty data', async () => {
renderTable({ fetchFn: vi.fn().mockResolvedValue({ data: [] }) });
expect(await screen.findByText('No data found.')).toBeInTheDocument();
});
it('creates a new item from dialog form', async () => {
const user = userEvent.setup();
const createFn = vi.fn().mockResolvedValue({ success: true });
renderTable({ createFn });
await user.click(await screen.findByRole('button', { name: /add reference/i }));
await user.type(screen.getByLabelText(/code/i), 'AREA');
await user.type(screen.getByLabelText(/name/i), 'Area');
await user.click(screen.getByRole('button', { name: /^add reference$/i }));
await waitFor(() => {
expect(createFn).toHaveBeenCalledWith(
expect.objectContaining({ code: 'AREA', name: 'Area', active: true }),
expect.any(Object)
);
});
expect(toast.success).toHaveBeenCalledWith('Reference created successfully');
});
});
@@ -0,0 +1,76 @@
// File: frontend/components/admin/security/__tests__/rbac-matrix.test.tsx
// Change Log
// - 2026-06-13: Add coverage for RBAC matrix load, toggle, and save behavior.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toast } from 'sonner';
import apiClient from '@/lib/api/client';
import { createTestQueryClient } from '@/lib/test-utils';
import { RbacMatrix } from '../rbac-matrix';
const roles = [
{
publicId: '019505a1-7c3e-7000-8000-abc123def601',
roleId: 10,
roleName: 'Admin',
permissions: [{ permissionId: 1, permissionName: 'system.view', description: 'View system' }],
},
{
publicId: '019505a1-7c3e-7000-8000-abc123def602',
roleId: 20,
roleName: 'Viewer',
permissions: [],
},
];
const permissions = [
{ permissionId: 1, permissionName: 'system.view', description: 'View system' },
{ permissionId: 2, permissionName: 'system.manage', description: 'Manage system' },
];
function renderWithQueryClient() {
const { wrapper } = createTestQueryClient();
return render(<RbacMatrix />, { wrapper });
}
describe('RbacMatrix', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(apiClient.get).mockImplementation((url: string) => {
if (url === '/users/roles') return Promise.resolve({ data: { data: roles } });
if (url === '/users/permissions') return Promise.resolve({ data: { data: permissions } });
return Promise.resolve({ data: [] });
});
vi.mocked(apiClient.patch).mockResolvedValue({ data: { success: true } });
});
it('renders roles and permissions from API data', async () => {
renderWithQueryClient();
expect(await screen.findByText('Admin')).toBeInTheDocument();
expect(screen.getByText('Viewer')).toBeInTheDocument();
expect(screen.getByText('system.manage')).toBeInTheDocument();
});
it('saves pending permission changes', async () => {
const user = userEvent.setup();
renderWithQueryClient();
await screen.findByText('system.manage');
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[3]);
await user.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => {
expect(apiClient.patch).toHaveBeenCalledWith('/users/roles/20/permissions', { permissionIds: [2] });
});
expect(toast.success).toHaveBeenCalledWith('Permissions updated successfully');
});
it('renders empty matrix safely when API response is malformed', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: { data: null } } });
renderWithQueryClient();
await waitFor(() => {
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
});
});
});