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();
});
});
});
@@ -0,0 +1,197 @@
// File: frontend/components/circulation/__tests__/circulation-list.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for CirculationList component
// - 2026-06-14: Render column cells in DataTable mock to cover list formatting logic
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { CirculationList } from '../circulation-list';
import { Circulation, CirculationListResponse } from '@/types/circulation';
import { ColumnDef } from '@tanstack/react-table';
import React from 'react';
vi.mock('@/components/common/data-table', () => ({
DataTable: ({ data, columns }: { data: Circulation[]; columns: ColumnDef<Circulation>[] }) => {
type MockCellContext = {
row: {
original: Circulation;
getValue: (key: string) => unknown;
};
};
const renderCell = (column: ColumnDef<Circulation>, row: Circulation): React.ReactNode => {
if (!column.cell || typeof column.cell !== 'function') return null;
const key = 'accessorKey' in column ? String(column.accessorKey) : '';
const context: MockCellContext = {
row: {
original: row,
getValue: (valueKey: string) => row[valueKey as keyof Circulation],
},
};
return (
<div data-testid={`cell-${key || column.id || 'custom'}-${row.publicId}`}>
{column.cell(context as never)}
</div>
);
};
return (
<div data-testid="data-table">
<span data-testid="row-count">{data.length} rows</span>
<span data-testid="col-count">{columns.length} columns</span>
{data.map((row) => (
<div key={row.publicId} data-testid={`row-${row.publicId}`}>
{columns.map((column) => (
<React.Fragment key={String(('accessorKey' in column && column.accessorKey) || column.id)}>
{renderCell(column, row)}
</React.Fragment>
))}
</div>
))}
</div>
);
},
}));
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
const createRouting = (status: 'PENDING' | 'COMPLETED'): Circulation['routings'][number] => ({
id: status === 'COMPLETED' ? 1 : 2,
circulationId: 1,
stepNumber: status === 'COMPLETED' ? 1 : 2,
organizationId: 1,
status,
createdAt: '2026-06-01T00:00:00Z',
updatedAt: '2026-06-01T00:00:00Z',
});
// Mock CirculationListResponse data ตาม ADR-019 (UUIDv7)
const mockResponse: CirculationListResponse = {
data: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def001',
organizationId: 1,
circulationNo: 'CIR-2026-001',
subject: 'Test Circulation',
statusCode: 'ACTIVE',
createdByUserId: 1,
organization: {
publicId: '019505a1-7c3e-7000-8000-abc123def010',
organizationName: 'Test Org',
organizationCode: 'ORG',
},
routings: [createRouting('COMPLETED'), createRouting('PENDING')],
createdAt: '2026-06-01T00:00:00Z',
updatedAt: '2026-06-01T00:00:00Z',
},
],
meta: { total: 1, page: 1, limit: 20, totalPages: 1 },
};
describe('CirculationList', () => {
it('ควรเรนเดอร์ DataTable ได้ถูกต้อง', () => {
render(<CirculationList data={mockResponse} />);
expect(screen.getByTestId('data-table')).toBeInTheDocument();
});
it('ควรแสดงจำนวน rows ถูกต้อง', () => {
render(<CirculationList data={mockResponse} />);
expect(screen.getByTestId('row-count')).toHaveTextContent('1 rows');
});
it('ควรแสดงข้อมูล column cells หลักได้ถูกต้อง', () => {
render(<CirculationList data={mockResponse} />);
expect(screen.getByText('CIR-2026-001')).toBeInTheDocument();
expect(screen.getByText('Test Circulation')).toBeInTheDocument();
expect(screen.getByText('Test Org')).toBeInTheDocument();
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
expect(screen.getByText('1/2')).toBeInTheDocument();
expect(screen.getByText('01 Jun 2026')).toBeInTheDocument();
expect(screen.getByTitle('View Details').closest('a')).toHaveAttribute(
'href',
'/circulation/019505a1-7c3e-7000-8000-abc123def001'
);
});
it('ควรแสดง fallback เมื่อไม่มี organization และ routings', () => {
const response: CirculationListResponse = {
data: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def002',
organizationId: 1,
circulationNo: 'CIR-2026-002',
subject: 'No Routing',
statusCode: 'DRAFT',
createdByUserId: 1,
createdAt: '2026-06-02T00:00:00Z',
updatedAt: '2026-06-02T00:00:00Z',
},
],
meta: { total: 1, page: 1, limit: 20, totalPages: 1 },
};
render(<CirculationList data={response} />);
expect(screen.getByText('DRAFT')).toBeInTheDocument();
expect(screen.getAllByText('-')).toHaveLength(2);
});
it('ควร map status variant ของสถานะ completed และ unknown โดยไม่ error', () => {
const response: CirculationListResponse = {
data: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def003',
organizationId: 1,
circulationNo: 'CIR-2026-003',
subject: 'Completed Routing',
statusCode: 'COMPLETED',
createdByUserId: 1,
routings: [createRouting('COMPLETED')],
createdAt: '2026-06-03T00:00:00Z',
updatedAt: '2026-06-03T00:00:00Z',
},
{
publicId: '019505a1-7c3e-7000-8000-abc123def004',
organizationId: 1,
circulationNo: 'CIR-2026-004',
subject: 'Unknown Status',
statusCode: 'ARCHIVED',
createdByUserId: 1,
createdAt: '2026-06-04T00:00:00Z',
updatedAt: '2026-06-04T00:00:00Z',
},
],
meta: { total: 2, page: 1, limit: 20, totalPages: 1 },
};
render(<CirculationList data={response} />);
expect(screen.getByText('COMPLETED')).toBeInTheDocument();
expect(screen.getByText('ARCHIVED')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
});
it('ควรแสดง meta total ที่ด้านล่างเมื่อมี meta', () => {
render(<CirculationList data={mockResponse} />);
expect(screen.getByText(/Showing 1 of 1 circulations/)).toBeInTheDocument();
});
it('ควรไม่แสดง meta เมื่อไม่มี meta', () => {
const noMeta = { data: [] } as CirculationListResponse;
render(<CirculationList data={noMeta} />);
expect(screen.queryByText(/Showing/)).not.toBeInTheDocument();
});
it('ควร return null เมื่อ data เป็น null/undefined', () => {
const { container } = render(<CirculationList data={null as unknown as CirculationListResponse} />);
expect(container).toBeEmptyDOMElement();
});
it('ควรเรนเดอร์ empty state เมื่อ data.data เป็น array ว่าง', () => {
const emptyResponse: CirculationListResponse = {
data: [],
meta: { total: 0, page: 1, limit: 20, totalPages: 0 },
};
render(<CirculationList data={emptyResponse} />);
expect(screen.getByTestId('row-count')).toHaveTextContent('0 rows');
expect(screen.getByText(/Showing 0 of 0 circulations/)).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
// File: frontend/components/common/__tests__/can.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for Can component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Can } from '../can';
import { useAuthStore } from '@/lib/stores/auth-store';
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: vi.fn(),
}));
describe('Can Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ควรเรนเดอร์ children เมื่อผู้ใช้มีสิทธิ์ตามที่ระบุ', () => {
vi.mocked(useAuthStore).mockReturnValue({
hasPermission: () => true,
hasRole: () => true,
} as any);
render(
<Can permission="test.permission">
<div>Allowed Content</div>
</Can>
);
expect(screen.getByText('Allowed Content')).toBeInTheDocument();
});
it('ควรเรนเดอร์ fallback เมื่อผู้ใช้ไม่มีสิทธิ์ตามที่ระบุ', () => {
vi.mocked(useAuthStore).mockReturnValue({
hasPermission: () => false,
hasRole: () => true,
} as any);
render(
<Can permission="test.permission" fallback={<div>Access Denied</div>}>
<div>Allowed Content</div>
</Can>
);
expect(screen.queryByText('Allowed Content')).not.toBeInTheDocument();
expect(screen.getByText('Access Denied')).toBeInTheDocument();
});
it('ควรเรนเดอร์ children เมื่อผู้ใช้มีบทบาทตามที่ระบุ', () => {
vi.mocked(useAuthStore).mockReturnValue({
hasPermission: () => true,
hasRole: () => true,
} as any);
render(
<Can role="ADMIN">
<div>Allowed Content</div>
</Can>
);
expect(screen.getByText('Allowed Content')).toBeInTheDocument();
});
it('ควรเรนเดอร์ fallback เมื่อผู้ใช้ไม่มีบทบาทตามที่ระบุ', () => {
vi.mocked(useAuthStore).mockReturnValue({
hasPermission: () => true,
hasRole: () => false,
} as any);
render(
<Can role="ADMIN" fallback={<div>Access Denied</div>}>
<div>Allowed Content</div>
</Can>
);
expect(screen.queryByText('Allowed Content')).not.toBeInTheDocument();
expect(screen.getByText('Access Denied')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
// File: frontend/components/common/__tests__/confirm-dialog.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for ConfirmDialog component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmDialog } from '../confirm-dialog';
describe('ConfirmDialog Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ควรเรนเดอร์เนื้อหาและปุ่มต่างๆ ได้อย่างถูกต้องเมื่อเปิดใช้งาน', () => {
const mockOnOpenChange = vi.fn();
const mockOnConfirm = vi.fn();
render(
<ConfirmDialog
open={true}
onOpenChange={mockOnOpenChange}
title="Confirm Delete"
description="Are you sure you want to delete?"
onConfirm={mockOnConfirm}
confirmText="Yes, Delete"
cancelText="Cancel Action"
/>
);
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to delete?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Yes, Delete' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel Action' })).toBeInTheDocument();
});
it('ควรเรียก onConfirm เมื่อกดปุ่มยืนยันสำเร็จ', () => {
const mockOnOpenChange = vi.fn();
const mockOnConfirm = vi.fn();
render(
<ConfirmDialog
open={true}
onOpenChange={mockOnOpenChange}
title="Confirm Action"
description="Proceed?"
onConfirm={mockOnConfirm}
/>
);
const confirmBtn = screen.getByRole('button', { name: 'Confirm' });
fireEvent.click(confirmBtn);
expect(mockOnConfirm).toHaveBeenCalled();
});
});
@@ -0,0 +1,139 @@
// File: frontend/components/common/__tests__/error-display.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for ErrorDisplay component and parseApiError helper
// - 2026-06-13: Refactor to remove blank lines inside functions to satisfy project guidelines
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorDisplay, parseApiError } from '../error-display';
describe('ErrorDisplay Component', () => {
beforeEach(() => {
vi.stubGlobal('window', {
open: vi.fn(),
});
});
it('ควรส่งกลับ null เมื่อไม่มี error หรือ payload', () => {
const { container } = render(<ErrorDisplay error={null} />);
expect(container.firstChild).toBeNull();
});
it('ควรเรนเดอร์ในโหมด compact สำเร็จ', () => {
const errorPayload = {
type: 'VALIDATION_ERROR',
code: 'ERR_VAL',
message: 'Validation failed',
severity: 'LOW' as const,
timestamp: new Date().toISOString(),
};
render(<ErrorDisplay error={errorPayload} compact={true} />);
expect(screen.getByText('Validation failed')).toBeInTheDocument();
});
it('ควรเรนเดอร์ในโหมดปกติพร้อม Recovery Actions สำเร็จ', () => {
const errorResponse = {
error: {
type: 'SYSTEM_ERROR',
code: 'ERR_SYS',
message: 'System crashed',
severity: 'CRITICAL' as const,
timestamp: new Date().toISOString(),
recoveryActions: ['Restart app', 'Clear cache'],
},
};
render(<ErrorDisplay error={errorResponse} compact={false} />);
expect(screen.getByText('System crashed')).toBeInTheDocument();
expect(screen.getByText('วิธีแก้ไข:')).toBeInTheDocument();
expect(screen.getByText('Restart app')).toBeInTheDocument();
expect(screen.getByText('Clear cache')).toBeInTheDocument();
});
it('ควรเรนเดอร์รายละเอียดทางเทคนิคเมื่อรันในสภาพแวดล้อม development', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const errorPayload = {
type: 'DATABASE_ERROR',
code: 'ERR_DB',
message: 'DB connection lost',
severity: 'HIGH' as const,
timestamp: new Date().toISOString(),
technicalMessage: 'Failed to connect to host postgres://localhost:5432',
};
render(<ErrorDisplay error={errorPayload} />);
expect(screen.getByText('รายละเอียดทางเทคนิค (Development)')).toBeInTheDocument();
expect(screen.getByText('Failed to connect to host postgres://localhost:5432')).toBeInTheDocument();
process.env.NODE_ENV = originalEnv;
});
it('ควรเรียก onRetry เมื่อคลิกปุ่มลองใหม่', () => {
const mockOnRetry = vi.fn();
const errorPayload = {
type: 'API_ERROR',
code: 'ERR_API',
message: 'API failed',
severity: 'MEDIUM' as const,
timestamp: new Date().toISOString(),
};
render(<ErrorDisplay error={errorPayload} onRetry={mockOnRetry} />);
const retryBtn = screen.getByText('ลองใหม่');
fireEvent.click(retryBtn);
expect(mockOnRetry).toHaveBeenCalled();
});
it('ควรเปิดเมลเมื่อคลิกปุ่มติดต่อผู้ดูแลระบบ', () => {
const errorPayload = {
type: 'API_ERROR',
code: 'ERR_API',
message: 'API failed',
severity: 'MEDIUM' as const,
timestamp: new Date().toISOString(),
};
render(<ErrorDisplay error={errorPayload} />);
const supportBtn = screen.getByText('ติดต่อผู้ดูแลระบบ');
fireEvent.click(supportBtn);
expect(window.open).toHaveBeenCalledWith('mailto:support@np-dms.work', '_blank');
});
});
describe('parseApiError helper', () => {
it('ควรจัดการข้อผิดพลาดจากโครงสร้าง Axios Error ได้อย่างถูกต้อง', () => {
const mockAxiosError = {
response: {
data: {
error: {
type: 'API_ERROR',
code: 'ERR_CODE',
message: 'Mock Axios Error',
severity: 'MEDIUM' as const,
timestamp: '2026-06-13T00:00:00.000Z',
},
},
},
};
const parsed = parseApiError(mockAxiosError);
expect(parsed.error.message).toBe('Mock Axios Error');
expect(parsed.error.code).toBe('ERR_CODE');
});
it('ควรคืนค่าเดิมถ้าเป็นโครงสร้าง ApiErrorResponse อยู่แล้ว', () => {
const mockResponse = {
error: {
type: 'CUSTOM_ERROR',
code: 'ERR_CUSTOM',
message: 'Mock Custom Error',
severity: 'LOW' as const,
timestamp: '2026-06-13T00:00:00.000Z',
},
};
const parsed = parseApiError(mockResponse);
expect(parsed).toEqual(mockResponse);
});
it('ควรส่งกลับ Internal/Network error เมื่อมีข้อผิดพลาดที่ไม่รู้จัก', () => {
const parsed = parseApiError('Random Error String');
expect(parsed.error.type).toBe('INTERNAL_ERROR');
expect(parsed.error.code).toBe('NETWORK_ERROR');
expect(parsed.error.message).toBe('ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้');
});
});
@@ -0,0 +1,72 @@
// File: frontend/components/common/__tests__/pagination.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for Pagination component
// - 2026-06-13: Refactor to remove blank lines inside functions to satisfy project guidelines
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination } from '../pagination';
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}));
describe('Pagination Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ควรเรนเดอร์ข้อมูลหน้าปัจจุบัน หน้าทั้งหมด และรายการทั้งหมดสำเร็จ', () => {
render(<Pagination currentPage={2} totalPages={5} total={50} />);
expect(screen.getByText('Showing page 2 of 5 (50 total items)')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Previous' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument();
});
it('ควร disable ปุ่ม Previous เมื่ออยู่หน้าแรก', () => {
render(<Pagination currentPage={1} totalPages={5} total={50} />);
const prevBtn = screen.getByRole('button', { name: 'Previous' });
const nextBtn = screen.getByRole('button', { name: 'Next' });
expect(prevBtn).toBeDisabled();
expect(nextBtn).not.toBeDisabled();
});
it('ควร disable ปุ่ม Next เมื่ออยู่หน้าสุดท้าย', () => {
render(<Pagination currentPage={5} totalPages={5} total={50} />);
const prevBtn = screen.getByRole('button', { name: 'Previous' });
const nextBtn = screen.getByRole('button', { name: 'Next' });
expect(prevBtn).not.toBeDisabled();
expect(nextBtn).toBeDisabled();
});
it('ควรเปลี่ยนหน้าเมื่อคลิกปุ่ม Next', () => {
render(<Pagination currentPage={2} totalPages={5} total={50} />);
const nextBtn = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextBtn);
expect(mockPush).toHaveBeenCalledWith('/?page=3');
});
it('ควรเปลี่ยนหน้าเมื่อคลิกปุ่ม Previous', () => {
render(<Pagination currentPage={2} totalPages={5} total={50} />);
const prevBtn = screen.getByRole('button', { name: 'Previous' });
fireEvent.click(prevBtn);
expect(mockPush).toHaveBeenCalledWith('/?page=1');
});
it('ควรเปลี่ยนหน้าเมื่อคลิกหมายเลขหน้าโดยตรง', () => {
render(<Pagination currentPage={2} totalPages={5} total={50} />);
const pageBtn = screen.getByRole('button', { name: '4' });
fireEvent.click(pageBtn);
expect(mockPush).toHaveBeenCalledWith('/?page=4');
});
});
@@ -0,0 +1,47 @@
// File: frontend/components/common/__tests__/status-badge.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for StatusBadge component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StatusBadge } from '../status-badge';
describe('StatusBadge Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ควรเรนเดอร์ Draft สำหรับสถานะ DRAFT ได้อย่างถูกต้อง', () => {
render(<StatusBadge status="DRAFT" />);
const badge = screen.getByText('Draft');
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass('bg-secondary');
});
it('ควรเรนเดอร์ Pending สำหรับสถานะ PENDING ได้อย่างถูกต้อง', () => {
render(<StatusBadge status="PENDING" />);
const badge = screen.getByText('Pending');
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass('bg-yellow-500');
});
it('ควรเรนเดอร์ Approved สำหรับสถานะ APPROVED ได้อย่างถูกต้อง', () => {
render(<StatusBadge status="APPROVED" />);
const badge = screen.getByText('Approved');
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass('bg-green-500');
});
it('ควรเรนเดอร์ Rejected สำหรับสถานะ REJECTED ได้อย่างถูกต้อง', () => {
render(<StatusBadge status="REJECTED" />);
const badge = screen.getByText('Rejected');
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass('bg-destructive');
});
it('ควรเรนเดอร์ข้อความตามสถานะเดิมและใช้ default styling เมื่อไม่พบรูปแบบสถานะที่ระบุ', () => {
render(<StatusBadge status="UNKNOWN_STATUS" />);
const badge = screen.getByText('UNKNOWN_STATUS');
expect(badge).toBeInTheDocument();
});
});
@@ -0,0 +1,45 @@
// File: frontend/components/common/__tests__/workflow-error-boundary.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for WorkflowErrorBoundary component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { WorkflowErrorBoundary } from '../workflow-error-boundary';
const ProblematicComponent = () => {
throw new Error('Test crash error');
};
describe('WorkflowErrorBoundary Component', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('ควรเรนเดอร์ children ตามปกติเมื่อไม่มีข้อผิดพลาด', () => {
render(
<WorkflowErrorBoundary>
<div>Safe Content</div>
</WorkflowErrorBoundary>
);
expect(screen.getByText('Safe Content')).toBeInTheDocument();
});
it('ควรเรนเดอร์ default message เมื่อตรวจพบข้อผิดพลาดใน Component ย่อย', () => {
render(
<WorkflowErrorBoundary>
<ProblematicComponent />
</WorkflowErrorBoundary>
);
expect(screen.getByText('เกิดข้อผิดพลาด ไม่สามารถแสดง Workflow ได้ กรุณารีเฟรชหน้า')).toBeInTheDocument();
});
it('ควรเรนเดอร์ custom fallback เมื่อตรวจพบข้อผิดพลาดและส่ง fallback มาให้', () => {
render(
<WorkflowErrorBoundary fallback={<div>Custom Error Alert</div>}>
<ProblematicComponent />
</WorkflowErrorBoundary>
);
expect(screen.getByText('Custom Error Alert')).toBeInTheDocument();
});
});
@@ -0,0 +1,278 @@
// File: frontend/components/correspondences/detail.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for CorrespondenceDetail component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { CorrespondenceDetail } from './detail';
import { useSubmitCorrespondence, useProcessWorkflow, useCancelCorrespondence } from '@/hooks/use-correspondence';
import { useAuthStore } from '@/lib/stores/auth-store';
import { Correspondence } from '@/types/correspondence';
vi.mock('@/hooks/use-correspondence', () => ({
useSubmitCorrespondence: vi.fn(),
useProcessWorkflow: vi.fn(),
useCancelCorrespondence: vi.fn(),
}));
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: vi.fn(),
}));
vi.mock('@/components/correspondences/tag-manager', () => ({
TagManager: () => <div data-testid="tag-manager" />,
}));
vi.mock('@/components/correspondences/reference-selector', () => ({
ReferenceSelector: () => <div data-testid="reference-selector" />,
}));
vi.mock('@/components/correspondences/circulation-status-card', () => ({
CirculationStatusCard: () => <div data-testid="circulation-status-card" />,
}));
vi.mock('@/components/correspondences/revision-history', () => ({
RevisionHistory: () => <div data-testid="revision-history" />,
}));
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
describe('CorrespondenceDetail Component', () => {
const mockSubmitMutate = vi.fn();
const mockProcessMutate = vi.fn();
const mockCancelMutate = vi.fn();
const mockCorrespondence: Correspondence = {
publicId: '019505a1-7c3e-7000-8000-abc123def456',
correspondenceNumber: 'CORR-2026-0001',
projectId: 1,
correspondenceTypeId: 1,
isInternal: false,
createdAt: '2026-06-13T00:00:00.000Z',
originator: {
publicId: '019505a1-7c3e-7000-8000-org111111111',
organizationName: 'Originator Org',
organizationCode: 'ORG-ORIG',
},
recipients: [
{
correspondenceId: 1,
recipientOrganizationId: 1,
recipientType: 'TO',
recipientOrganization: {
publicId: '019505a1-7c3e-7000-8000-org222222222',
organizationName: 'Recipient Org',
organizationCode: 'ORG-REC',
},
},
{
correspondenceId: 1,
recipientOrganizationId: 2,
recipientType: 'CC',
recipientOrganization: {
publicId: '019505a1-7c3e-7000-8000-org333333333',
organizationName: 'CC Org',
organizationCode: 'ORG-CC',
},
},
],
project: {
publicId: '019505a1-7c3e-7000-8000-proj11111111',
projectName: 'Test Project',
projectCode: 'PROJ-TEST',
},
type: {
id: 1,
typeName: 'Letter',
typeCode: 'LTR',
},
revisions: [
{
publicId: '019505a1-7c3e-7000-8000-rev111111111',
revisionNumber: 1,
revisionLabel: 'A',
subject: 'Test Subject',
description: 'Test Description',
body: 'Test Body Content',
remarks: 'Test Remarks',
isCurrent: true,
status: {
id: 1,
statusCode: 'DRAFT',
statusName: 'Draft',
},
details: {
importance: 'NORMAL',
},
documentDate: '2026-06-13T00:00:00.000Z',
dueDate: '2026-06-20T00:00:00.000Z',
issuedDate: '2026-06-13T00:00:00.000Z',
receivedDate: '2026-06-13T00:00:00.000Z',
createdAt: '2026-06-13T00:00:00.000Z',
correspondence: {} as any,
attachmentLinks: [
{
isMainDocument: true,
attachment: {
publicId: '019505a1-7c3e-7000-8000-file1111111',
originalFilename: 'test-file.pdf',
filePath: '/uploads/test-file.pdf',
},
},
],
},
],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSubmitCorrespondence).mockReturnValue({
mutate: mockSubmitMutate,
isPending: false,
} as any);
vi.mocked(useProcessWorkflow).mockReturnValue({
mutate: mockProcessMutate,
isPending: false,
} as any);
vi.mocked(useCancelCorrespondence).mockReturnValue({
mutate: mockCancelMutate,
isPending: false,
} as any);
vi.mocked(useAuthStore).mockReturnValue({
user: {
publicId: '019505a1-7c3e-7000-8000-user11111111',
username: 'testuser',
email: 'test@np-dms.work',
firstName: 'Test',
lastName: 'User',
role: 'ADMIN',
},
hasPermission: () => true,
} as any);
});
it('ควรเรนเดอร์รายละเอียดเอกสารและข้อมูลพื้นฐานได้ถูกต้อง', () => {
render(<CorrespondenceDetail data={mockCorrespondence} />);
expect(screen.getByText('CORR-2026-0001')).toBeInTheDocument();
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText('Test Description')).toBeInTheDocument();
expect(screen.getByText('Test Body Content')).toBeInTheDocument();
expect(screen.getByText('Test Remarks')).toBeInTheDocument();
expect(screen.getByText('Originator Org')).toBeInTheDocument();
expect(screen.getByText('Recipient Org')).toBeInTheDocument();
expect(screen.getByText('ORG-CC')).toBeInTheDocument();
expect(screen.getByText('test-file.pdf')).toBeInTheDocument();
});
it('ควรแสดงปุ่มและส่งคำขอเมื่อกด Submit for Review ในกรณีที่เป็น DRAFT', () => {
render(<CorrespondenceDetail data={mockCorrespondence} />);
const submitBtn = screen.getByRole('button', { name: 'Submit for Review' });
fireEvent.click(submitBtn);
expect(mockSubmitMutate).toHaveBeenCalledWith({
uuid: '019505a1-7c3e-7000-8000-abc123def456',
data: {},
});
});
it('ควรแสดงข้อความเตือนภัยและซ่อนปุ่มการกระทำบางอย่างหากเอกสารถูกยกเลิก', () => {
const cancelledCorrespondence = {
...mockCorrespondence,
revisions: [
{
...mockCorrespondence.revisions![0],
status: { id: 2, statusCode: 'CANCELLED', statusName: 'Cancelled' },
},
],
};
render(<CorrespondenceDetail data={cancelledCorrespondence} />);
expect(screen.getByText('This correspondence has been cancelled')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Submit for Review' })).not.toBeInTheDocument();
});
it('ควรแสดงปุ่ม Approve และ Reject ในกรณีที่เอกสารเป็น IN_REVIEW', () => {
const inReviewCorrespondence = {
...mockCorrespondence,
revisions: [
{
...mockCorrespondence.revisions![0],
status: { id: 3, statusCode: 'IN_REVIEW', statusName: 'In Review' },
},
],
};
render(<CorrespondenceDetail data={inReviewCorrespondence} />);
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument();
});
it('ควรเปิดการกดยืนยันการอนุมัติและส่งความคิดเห็นได้ถูกต้อง', async () => {
const inReviewCorrespondence = {
...mockCorrespondence,
revisions: [
{
...mockCorrespondence.revisions![0],
status: { id: 3, statusCode: 'IN_REVIEW', statusName: 'In Review' },
},
],
};
render(<CorrespondenceDetail data={inReviewCorrespondence} />);
const approveBtn = screen.getByRole('button', { name: 'Approve' });
fireEvent.click(approveBtn);
expect(screen.getByText('Confirm Approval')).toBeInTheDocument();
const commentInput = screen.getByPlaceholderText('Enter comments...');
fireEvent.change(commentInput, { target: { value: 'Approved comment' } });
const confirmBtn = screen.getByRole('button', { name: 'Confirm Approve' });
fireEvent.click(confirmBtn);
expect(mockProcessMutate).toHaveBeenCalledWith(
{
uuid: '019505a1-7c3e-7000-8000-abc123def456',
data: { action: 'APPROVE', comments: 'Approved comment' },
},
expect.any(Object)
);
});
it('ควรเปิดส่วนยกเลิกเอกสารและส่งเหตุผลการยกเลิกได้ถูกต้อง', () => {
render(<CorrespondenceDetail data={mockCorrespondence} />);
const cancelBtn = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelBtn);
expect(screen.getByText('Cancel Correspondence')).toBeInTheDocument();
const reasonInput = screen.getByPlaceholderText('Enter reason for cancellation...');
fireEvent.change(reasonInput, { target: { value: 'Test cancellation reason' } });
const confirmCancelBtn = screen.getByRole('button', { name: 'Confirm Cancellation' });
fireEvent.click(confirmCancelBtn);
expect(mockCancelMutate).toHaveBeenCalledWith(
{
uuid: '019505a1-7c3e-7000-8000-abc123def456',
reason: 'Test cancellation reason',
},
expect.any(Object)
);
});
it('ควรเรนเดอร์เวอร์ชันที่เลือกแบบเฉพาะเจาะจงเมื่อส่ง parameter selectedRevisionId มา', () => {
const multiRevCorrespondence = {
...mockCorrespondence,
revisions: [
{
...mockCorrespondence.revisions![0],
publicId: '019505a1-7c3e-7000-8000-rev111111111',
subject: 'Revision A Subject',
isCurrent: false,
},
{
...mockCorrespondence.revisions![0],
publicId: '019505a1-7c3e-7000-8000-rev222222222',
subject: 'Revision B Subject',
isCurrent: true,
},
],
};
render(
<CorrespondenceDetail
data={multiRevCorrespondence}
selectedRevisionId="019505a1-7c3e-7000-8000-rev111111111"
/>
);
expect(screen.getByText('Revision A Subject')).toBeInTheDocument();
expect(screen.queryByText('Revision B Subject')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,142 @@
// File: frontend/components/correspondences/list.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for CorrespondenceList component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { CorrespondenceList } from './list';
import { useAuthStore } from '@/lib/stores/auth-store';
import { CorrespondenceRevision } from '@/types/correspondence';
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: vi.fn(),
}));
describe('CorrespondenceList Component', () => {
const mockRevisions: CorrespondenceRevision[] = [
{
publicId: '019505a1-7c3e-7000-8000-rev111111111',
revisionNumber: 1,
revisionLabel: 'A',
subject: 'Test Subject Alpha',
isCurrent: true,
dueDate: '2026-06-20T00:00:00.000Z',
createdAt: '2026-06-13T00:00:00.000Z',
status: {
id: 1,
statusCode: 'DRAFT',
statusName: 'Draft',
},
correspondence: {
publicId: '019505a1-7c3e-7000-8000-corr1111111',
correspondenceNumber: 'CORR-2026-0001',
projectId: 1,
isInternal: false,
originator: {
publicId: '019505a1-7c3e-7000-8000-org111111111',
organizationName: 'Originator Org',
organizationCode: 'ORG-ORIG',
},
project: {
publicId: '019505a1-7c3e-7000-8000-proj11111111',
projectName: 'Test Project',
projectCode: 'PROJ-TEST',
},
type: {
id: 1,
typeName: 'Letter',
typeCode: 'LTR',
},
},
},
{
publicId: '019505a1-7c3e-7000-8000-rev222222222',
revisionNumber: 2,
revisionLabel: 'B',
subject: 'Test Subject Beta',
isCurrent: true,
dueDate: '2026-06-01T00:00:00.000Z',
createdAt: '2026-06-02T00:00:00.000Z',
status: {
id: 3,
statusCode: 'IN_REVIEW',
statusName: 'In Review',
},
correspondence: {
publicId: '019505a1-7c3e-7000-8000-corr2222222',
correspondenceNumber: 'CORR-2026-0002',
projectId: 1,
isInternal: false,
originator: {
publicId: '019505a1-7c3e-7000-8000-org111111111',
organizationName: 'Originator Org',
organizationCode: 'ORG-ORIG',
},
project: {
publicId: '019505a1-7c3e-7000-8000-proj11111111',
projectName: 'Test Project',
projectCode: 'PROJ-TEST',
},
type: {
id: 1,
typeName: 'Letter',
typeCode: 'LTR',
},
},
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useAuthStore).mockReturnValue({
user: {
publicId: '019505a1-7c3e-7000-8000-user11111111',
username: 'testuser',
email: 'test@np-dms.work',
firstName: 'Test',
lastName: 'User',
role: 'ADMIN',
},
hasPermission: () => true,
} as any);
});
it('ควรเรนเดอร์รายชื่อเอกสารและหัวตารางได้ถูกต้อง', () => {
render(<CorrespondenceList data={mockRevisions} />);
expect(screen.getByText('CORR-2026-0001')).toBeInTheDocument();
expect(screen.getByText('Test Subject Alpha')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getAllByText('ORG-ORIG').length).toBeGreaterThan(0);
expect(screen.getAllByText('PROJ-TEST').length).toBeGreaterThan(0);
});
it('ควรตรวจสอบและแสดงผล Overdue เมื่อเลยกำหนดดิวเดท', () => {
render(<CorrespondenceList data={mockRevisions} />);
const overdueRow = screen.getByText('Test Subject Beta').closest('tr');
expect(overdueRow).toBeInTheDocument();
const dueDateCell = screen.getByText('01 Jun 2026');
expect(dueDateCell).toHaveClass('text-destructive');
});
it('ควรแสดงปุ่มแก้ไขสำหรับผู้มีสิทธิ์ในสถานะที่แก้ไขได้', () => {
render(<CorrespondenceList data={mockRevisions} />);
const editButtons = screen.getAllByTitle('Edit');
expect(editButtons.length).toBeGreaterThan(0);
});
it('ควรซ่อนปุ่มแก้ไขหากผู้ใช้ไม่มีสิทธิ์แก้ไข', () => {
vi.mocked(useAuthStore).mockReturnValue({
user: {
publicId: '019505a1-7c3e-7000-8000-user11111111',
username: 'testuser',
email: 'test@np-dms.work',
firstName: 'Test',
lastName: 'User',
role: 'VIEWER',
},
hasPermission: () => false,
} as any);
render(<CorrespondenceList data={mockRevisions} />);
expect(screen.queryByTitle('Edit')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,52 @@
// File: frontend/components/layout/__tests__/dashboard-shell.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for DashboardShell component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { act } from '@testing-library/react';
import { DashboardShell } from '../dashboard-shell';
import { useUIStore } from '@/lib/stores/ui-store';
describe('DashboardShell', () => {
beforeEach(() => {
act(() => {
useUIStore.setState({ isSidebarOpen: true });
});
});
it('ควรเรนเดอร์ children ได้ถูกต้อง', () => {
render(
<DashboardShell>
<div>Test Content</div>
</DashboardShell>,
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('ควรมี class md:ml-[240px] เมื่อ isSidebarOpen เป็น true', () => {
act(() => {
useUIStore.setState({ isSidebarOpen: true });
});
const { container } = render(
<DashboardShell>
<div>Content</div>
</DashboardShell>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain('md:ml-[240px]');
});
it('ควรมี class md:ml-[70px] เมื่อ isSidebarOpen เป็น false', () => {
act(() => {
useUIStore.setState({ isSidebarOpen: false });
});
const { container } = render(
<DashboardShell>
<div>Content</div>
</DashboardShell>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain('md:ml-[70px]');
});
});
@@ -0,0 +1,27 @@
// File: frontend/components/layout/__tests__/header.test.tsx
// Change Log
// - 2026-06-13: Add coverage for Header composition.
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Header } from '../header';
vi.mock('../user-menu', () => ({ UserMenu: () => <div>User menu</div> }));
vi.mock('../global-search', () => ({ GlobalSearch: () => <label>Search<input aria-label="Search" /></label> }));
vi.mock('../notifications-dropdown', () => ({ NotificationsDropdown: () => <button>Notifications</button> }));
vi.mock('../sidebar', () => ({ MobileSidebar: () => <button>Mobile sidebar</button> }));
vi.mock('../theme-toggle', () => ({ ThemeToggle: () => <button>Theme</button> }));
vi.mock('../project-switcher', () => ({ ProjectSwitcher: () => <button>Project</button> }));
describe('Header', () => {
it('renders application title and composed controls', () => {
render(<Header />);
expect(screen.getByText('LCBP3-DMS')).toBeInTheDocument();
expect(screen.getByLabelText('Search')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mobile sidebar' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Project' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Theme' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
expect(screen.getByText('User menu')).toBeInTheDocument();
});
});
@@ -0,0 +1,313 @@
// File: frontend/components/layout/__tests__/layout-widgets.test.tsx
// Change Log:
// - 2026-06-14: Add coverage for uncovered layout widgets and navigation interactions
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { GlobalSearch } from '../global-search';
import { MobileSidebar, Sidebar } from '../sidebar';
import { ProjectSwitcher } from '../project-switcher';
import { NotificationsDropdown } from '../notifications-dropdown';
import { UserMenu } from '../user-menu';
import { useProjectStore } from '@/lib/stores/project-store';
import { useAuthStore } from '@/lib/stores/auth-store';
const mocks = vi.hoisted(() => ({
routerPush: vi.fn(),
markAsRead: vi.fn(),
signOut: vi.fn(),
pathname: '/correspondences',
searchType: '',
suggestions: [
{
uuid: '019505a1-7c3e-7000-8000-abc123def501',
type: 'correspondence',
title: 'Incoming Correspondence',
documentNumber: 'COR-001',
},
],
searchLoading: false,
projects: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def601',
projectName: 'Project One',
},
{
publicId: '019505a1-7c3e-7000-8000-abc123def602',
projectName: 'Project Two',
},
],
projectsLoading: false,
notifications: {
items: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def701',
notificationId: 1,
title: 'Workflow task',
message: 'Please review the RFA',
type: 'INFO',
isRead: false,
createdAt: '2026-06-14T00:00:00Z',
link: '/review-tasks',
},
],
unreadCount: 1,
},
notificationsLoading: false,
session: {
user: {
name: 'DMS Admin',
email: 'admin@example.local',
role: 'ADMIN',
},
},
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mocks.routerPush }),
usePathname: () => mocks.pathname,
useSearchParams: () => ({
get: (key: string) => (key === 'type' ? mocks.searchType : null),
}),
}));
vi.mock('next/link', () => ({
default: ({ children, href, onClick, className, title }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a href={String(href)} onClick={onClick} className={className} title={title}>
{children}
</a>
),
}));
vi.mock('next-auth/react', () => ({
useSession: () => ({ data: mocks.session }),
signOut: mocks.signOut,
}));
vi.mock('@/hooks/use-search', () => ({
useSearchSuggestions: () => ({
data: mocks.suggestions,
isLoading: mocks.searchLoading,
}),
}));
vi.mock('@/hooks/use-projects', () => ({
useProjects: () => ({
data: mocks.projects,
isLoading: mocks.projectsLoading,
}),
}));
vi.mock('@/hooks/use-notification', () => ({
useNotifications: () => ({
data: mocks.notifications,
isLoading: mocks.notificationsLoading,
}),
useMarkNotificationRead: () => ({
mutate: mocks.markAsRead,
}),
}));
vi.mock('@/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value?: string;
onValueChange?: (value: string) => void;
}) => (
<select data-testid="project-select" value={value} onChange={(event) => onValueChange?.(event.target.value)}>
{children}
</select>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectValue: ({ placeholder }: { placeholder?: string }) => <option value="">{placeholder}</option>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => <option value={value}>{children}</option>,
}));
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({
children,
onClick,
disabled,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
}) => (
<button type="button" onClick={onClick} disabled={disabled} className={className}>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
}));
vi.mock('@/components/ui/command', () => ({
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandGroup: ({ children, heading }: { children: React.ReactNode; heading?: string }) => (
<div>
{heading && <div>{heading}</div>}
{children}
</div>
),
CommandItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: () => void }) => (
<button type="button" onClick={onSelect}>
{children}
</button>
),
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/components/ui/sheet', () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}));
describe('layout widgets', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.pathname = '/correspondences';
mocks.searchType = '';
mocks.projects = [
{ publicId: '019505a1-7c3e-7000-8000-abc123def601', projectName: 'Project One' },
{ publicId: '019505a1-7c3e-7000-8000-abc123def602', projectName: 'Project Two' },
];
mocks.projectsLoading = false;
mocks.notificationsLoading = false;
mocks.notifications = {
items: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def701',
notificationId: 1,
title: 'Workflow task',
message: 'Please review the RFA',
type: 'INFO',
isRead: false,
createdAt: '2026-06-14T00:00:00Z',
link: '/review-tasks',
},
],
unreadCount: 1,
};
useProjectStore.setState({ selectedProjectId: null });
useAuthStore.setState({
user: {
id: '019505a1-7c3e-7000-8000-abc123def801',
publicId: '019505a1-7c3e-7000-8000-abc123def801',
username: 'admin',
email: 'admin@example.local',
firstName: 'DMS',
lastName: 'Admin',
role: 'ADMIN',
},
token: 'token',
isAuthenticated: true,
});
});
it('Sidebar ควรแสดงเมนู admin และ collapse label ได้', () => {
render(<Sidebar />);
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button')[0]);
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
expect(screen.getByTitle('Admin Panel')).toBeInTheDocument();
});
it('MobileSidebar ควร render navigation และซ่อน admin เมื่อ role ไม่ใช่ admin', () => {
useAuthStore.setState({
user: {
id: '019505a1-7c3e-7000-8000-abc123def802',
publicId: '019505a1-7c3e-7000-8000-abc123def802',
username: 'viewer',
email: 'viewer@example.local',
firstName: 'DMS',
lastName: 'Viewer',
role: 'User',
},
token: 'token',
isAuthenticated: true,
});
render(<MobileSidebar />);
expect(screen.getByText('Mobile Navigation')).toBeInTheDocument();
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
});
it('GlobalSearch ควร submit query และเปิด suggestion route ได้', async () => {
render(<GlobalSearch />);
const input = screen.getByPlaceholderText('Search documents...');
fireEvent.change(input, { target: { value: 'rfa search' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mocks.routerPush).toHaveBeenCalledWith('/search?q=rfa%20search');
fireEvent.focus(input);
await waitFor(() => expect(screen.getByText('Incoming Correspondence')).toBeInTheDocument());
fireEvent.click(screen.getByText('Incoming Correspondence'));
expect(mocks.routerPush).toHaveBeenCalledWith('/correspondences/019505a1-7c3e-7000-8000-abc123def501');
});
it('ProjectSwitcher ควรเลือก project และ global ได้', () => {
render(<ProjectSwitcher />);
const select = screen.getByTestId('project-select');
fireEvent.change(select, { target: { value: '019505a1-7c3e-7000-8000-abc123def602' } });
expect(useProjectStore.getState().selectedProjectId).toBe('019505a1-7c3e-7000-8000-abc123def602');
fireEvent.change(select, { target: { value: 'global' } });
expect(useProjectStore.getState().selectedProjectId).toBeNull();
});
it('ProjectSwitcher ควร auto-select เมื่อมี project เดียวและแสดง loading/empty state ได้', async () => {
mocks.projects = [{ publicId: '019505a1-7c3e-7000-8000-abc123def603', projectName: 'Single Project' }];
const { rerender, container } = render(<ProjectSwitcher />);
await waitFor(() => expect(useProjectStore.getState().selectedProjectId).toBe('019505a1-7c3e-7000-8000-abc123def603'));
expect(screen.getByText('Single Project')).toBeInTheDocument();
mocks.projectsLoading = true;
rerender(<ProjectSwitcher />);
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
mocks.projectsLoading = false;
mocks.projects = [];
rerender(<ProjectSwitcher />);
expect(screen.queryByText('Single Project')).not.toBeInTheDocument();
});
it('NotificationsDropdown ควร mark read และ navigate เมื่อคลิก notification', () => {
render(<NotificationsDropdown />);
expect(screen.getByText('1')).toBeInTheDocument();
fireEvent.click(screen.getByText('Workflow task'));
expect(mocks.markAsRead).toHaveBeenCalledWith('019505a1-7c3e-7000-8000-abc123def701');
expect(mocks.routerPush).toHaveBeenCalledWith('/review-tasks');
});
it('NotificationsDropdown ควรแสดง loading และ empty state ได้', () => {
mocks.notificationsLoading = true;
const { rerender, container } = render(<NotificationsDropdown />);
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
mocks.notificationsLoading = false;
mocks.notifications = { items: [], unreadCount: 0 };
rerender(<NotificationsDropdown />);
expect(screen.getByText('No new notifications')).toBeInTheDocument();
});
it('UserMenu ควรแสดงข้อมูล session และ logout กลับ login', async () => {
mocks.signOut.mockResolvedValueOnce(undefined);
render(<UserMenu />);
expect(screen.getByText('DMS Admin')).toBeInTheDocument();
fireEvent.click(screen.getByText('Profile'));
expect(mocks.routerPush).toHaveBeenCalledWith('/profile');
fireEvent.click(screen.getByText('Settings'));
expect(mocks.routerPush).toHaveBeenCalledWith('/settings');
fireEvent.click(screen.getByText('Log out'));
await waitFor(() => expect(mocks.signOut).toHaveBeenCalledWith({ redirect: false }));
expect(mocks.routerPush).toHaveBeenCalledWith('/login');
});
});
@@ -0,0 +1,71 @@
// File: frontend/components/layout/__tests__/navbar.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for Navbar component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { act } from '@testing-library/react';
import { Navbar } from '../navbar';
import { useUIStore } from '@/lib/stores/ui-store';
import { useSession } from 'next-auth/react';
// Mock dependencies
vi.mock('next-auth/react', () => ({
useSession: vi.fn(),
signOut: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
describe('Navbar', () => {
beforeEach(() => {
vi.clearAllMocks();
// รีเซ็ต ui store
act(() => {
useUIStore.setState({ isSidebarOpen: true, toggleSidebar: vi.fn() });
});
vi.mocked(useSession).mockReturnValue({
data: { user: { name: 'John Doe', email: 'john@example.com', role: 'Admin' } },
} as any);
});
it('ควรเรนเดอร์ header ได้ถูกต้อง', () => {
render(<Navbar />);
expect(screen.getByRole('banner')).toBeInTheDocument();
});
it('ควรแสดงข้อความ Document Management System', () => {
render(<Navbar />);
expect(screen.getByText('Document Management System')).toBeInTheDocument();
});
it('ควรมีปุ่ม Toggle navigation menu สำหรับ mobile', () => {
render(<Navbar />);
expect(screen.getByText('Toggle navigation menu')).toBeInTheDocument();
});
it('ควรมีปุ่ม Notifications', () => {
render(<Navbar />);
expect(screen.getByText('Notifications')).toBeInTheDocument();
});
it('ควรเรียก toggleSidebar เมื่อคลิกปุ่ม menu', () => {
const mockToggle = vi.fn();
act(() => {
useUIStore.setState({ isSidebarOpen: true, toggleSidebar: mockToggle });
});
render(<Navbar />);
// ปุ่ม menu บน mobile
const menuButton = screen.getByRole('button', { name: /toggle navigation menu/i });
fireEvent.click(menuButton);
expect(mockToggle).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,58 @@
// File: frontend/components/layout/__tests__/theme-toggle.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for ThemeToggle component
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeToggle } from '../theme-toggle';
const mockSetTheme = vi.fn();
let mockResolvedTheme = 'dark';
vi.mock('next-themes', () => ({
useTheme: () => ({
resolvedTheme: mockResolvedTheme,
setTheme: mockSetTheme,
}),
}));
describe('ThemeToggle', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolvedTheme = 'dark';
});
it('ควรแสดงปุ่ม Toggle White/Dark mode', () => {
render(<ThemeToggle />);
const button = screen.getByTitle('Toggle white/dark mode');
expect(button).toBeInTheDocument();
});
it('ควรแสดงข้อความ White เมื่อ theme ปัจจุบันเป็น dark', () => {
mockResolvedTheme = 'dark';
render(<ThemeToggle />);
expect(screen.getByText('White')).toBeInTheDocument();
});
it('ควรแสดงข้อความ Dark เมื่อ theme ปัจจุบันเป็น light', () => {
mockResolvedTheme = 'light';
render(<ThemeToggle />);
expect(screen.getByText('Dark')).toBeInTheDocument();
});
it('ควรเรียก setTheme("light") เมื่อคลิกขณะ theme เป็น dark', () => {
mockResolvedTheme = 'dark';
render(<ThemeToggle />);
const button = screen.getByTitle('Toggle white/dark mode');
fireEvent.click(button);
expect(mockSetTheme).toHaveBeenCalledWith('light');
});
it('ควรเรียก setTheme("dark") เมื่อคลิกขณะ theme เป็น light', () => {
mockResolvedTheme = 'light';
render(<ThemeToggle />);
const button = screen.getByTitle('Toggle white/dark mode');
fireEvent.click(button);
expect(mockSetTheme).toHaveBeenCalledWith('dark');
});
});
@@ -0,0 +1,107 @@
// File: frontend/components/layout/__tests__/user-nav.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for UserNav component
// - 2026-06-13: Fix Radix UI DropdownMenu testing — ใช้ userEvent แทน fireEvent และ waitFor
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserNav } from '../user-nav';
import { useSession, signOut } from 'next-auth/react';
vi.mock('next-auth/react', () => ({
useSession: vi.fn(),
signOut: vi.fn(),
}));
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
describe('UserNav Component', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSession).mockReturnValue({
data: {
user: {
name: 'John Doe',
email: 'john@example.com',
role: 'Admin',
},
},
} as any);
});
it('ควรเรนเดอร์อักษรย่อชื่อผู้ใช้ได้อย่างถูกต้อง', () => {
render(<UserNav />);
expect(screen.getByText('JD')).toBeInTheDocument();
});
it('ควรแสดงรายละเอียดผู้ใช้ใน DropdownMenuContent (forceMount)', async () => {
render(<UserNav />);
// DropdownMenuContent ใช้ forceMount → render อยู่ใน DOM เสมอ
// แต่ Radix ซ่อนด้วย data-state — ต้อง click trigger ก่อน
const user = userEvent.setup();
const trigger = screen.getByRole('button');
await act(async () => {
await user.click(trigger);
});
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('ควรเปลี่ยนเส้นทางไปหน้า Profile เมื่อคลิกเมนู Profile', async () => {
render(<UserNav />);
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByRole('button'));
});
await waitFor(() => {
expect(screen.getByText('Profile')).toBeInTheDocument();
});
await act(async () => {
await user.click(screen.getByText('Profile'));
});
expect(mockPush).toHaveBeenCalledWith('/profile');
});
it('ควรเปลี่ยนเส้นทางไปหน้า Settings เมื่อคลิกเมนู Settings', async () => {
render(<UserNav />);
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByRole('button'));
});
await waitFor(() => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
await act(async () => {
await user.click(screen.getByText('Settings'));
});
expect(mockPush).toHaveBeenCalledWith('/settings');
});
it('ควรออกจากระบบและเปลี่ยนเส้นทางไปหน้า Login เมื่อคลิกเมนู Log out', async () => {
vi.mocked(signOut).mockResolvedValue({} as any);
render(<UserNav />);
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByRole('button'));
});
await waitFor(() => {
expect(screen.getByText('Log out')).toBeInTheDocument();
});
await act(async () => {
await user.click(screen.getByText('Log out'));
});
expect(signOut).toHaveBeenCalledWith({ redirect: false });
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
});
@@ -16,7 +16,7 @@ export function MetricsDashboard() {
const data = await documentNumberingService.getMetrics();
setMetrics(data);
} catch (_error) {
// Failed to fetch metrics - handled by loading state
setMetrics({});
} finally {
setLoading(false);
}
+10 -5
View File
@@ -1,3 +1,7 @@
// File: frontend/components/rfas/form.tsx
// Change Log:
// - 2026-06-13: Export helpers for unit tests
'use client';
import { useForm, type SubmitErrorHandler } from 'react-hook-form';
@@ -100,7 +104,7 @@ type SelectableDrawingOption = {
};
};
const extractArrayData = <T,>(value: unknown): T[] => {
export const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
@@ -118,7 +122,7 @@ const extractArrayData = <T,>(value: unknown): T[] => {
return Array.isArray(current) ? (current as T[]) : [];
};
const dedupeByKey = <T,>(items: T[], getKey: (item: T) => string | number | undefined): T[] => {
export const dedupeByKey = <T,>(items: T[], getKey: (item: T) => string | number | undefined): T[] => {
const seen = new Set<string | number>();
return items.filter((item) => {
@@ -133,7 +137,7 @@ const dedupeByKey = <T,>(items: T[], getKey: (item: T) => string | number | unde
});
};
const getOptionValue = (value?: string | number): string | undefined => {
export const getOptionValue = (value?: string | number): string | undefined => {
if (value === undefined || value === null || value === '') {
return undefined;
}
@@ -141,11 +145,11 @@ const getOptionValue = (value?: string | number): string | undefined => {
return String(value);
};
const getMasterOptionValue = (option: { publicId?: string; id?: number }): string | undefined => {
export const getMasterOptionValue = (option: { publicId?: string; id?: number }): string | undefined => {
return getOptionValue(option.publicId ?? option.id);
};
export function RFAForm() {
export function RFAForm({ defaultValues }: { defaultValues?: Partial<RFAFormData> } = {}) {
const router = useRouter();
const createMutation = useCreateRFA();
const { data: aiStatus, isLoading: isAiStatusLoading } = useAiStatus();
@@ -184,6 +188,7 @@ export function RFAForm() {
dueDate: '',
shopDrawingRevisionIds: [],
asBuiltDrawingRevisionIds: [],
...defaultValues,
},
});
@@ -0,0 +1,125 @@
// File: frontend/components/transmittal/__tests__/transmittal-form.test.tsx
// Change Log
// - 2026-06-13: Add coverage for transmittal form render, cancel, validation, and submit 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 { createTestQueryClient } from '@/lib/test-utils';
import { TransmittalForm } from '../transmittal-form';
import { transmittalService } from '@/lib/services/transmittal.service';
import { correspondenceService } from '@/lib/services/correspondence.service';
import { projectService } from '@/lib/services/project.service';
import { organizationService } from '@/lib/services/organization.service';
const push = vi.fn();
const back = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push, back }),
}));
vi.mock('@/lib/services/transmittal.service', () => ({
transmittalService: {
create: vi.fn(),
},
}));
vi.mock('@/lib/services/correspondence.service', () => ({
correspondenceService: {
getAll: vi.fn(),
},
}));
vi.mock('@/lib/services/project.service', () => ({
projectService: {
getAll: vi.fn(),
},
}));
vi.mock('@/lib/services/organization.service', () => ({
organizationService: {
getAll: vi.fn(),
},
}));
function renderForm() {
const { wrapper } = createTestQueryClient();
return render(<TransmittalForm />, { wrapper });
}
async function chooseCombobox(label: string | RegExp, option: string): Promise<void> {
const user = userEvent.setup();
await user.click(screen.getByRole('combobox', { name: label }));
const matches = await screen.findAllByText(option);
await user.click(matches[matches.length - 1]);
}
describe('TransmittalForm', () => {
beforeEach(() => {
vi.clearAllMocks();
Element.prototype.scrollIntoView = vi.fn();
vi.mocked(projectService.getAll).mockResolvedValue({
data: [{ publicId: '019505a1-7c3e-7000-8000-abc123defc01', projectName: 'LCBP3' }],
});
vi.mocked(organizationService.getAll).mockResolvedValue({
data: [{ uuid: '019505a1-7c3e-7000-8000-abc123defc02', organizationName: 'TEAM Consulting' }],
});
vi.mocked(correspondenceService.getAll).mockResolvedValue({
data: [{ uuid: '019505a1-7c3e-7000-8000-abc123defc03', correspondenceNumber: 'COR-001' }],
});
vi.mocked(transmittalService.create).mockResolvedValue({
uuid: '019505a1-7c3e-7000-8000-abc123defc04',
correspondence: { uuid: '019505a1-7c3e-7000-8000-abc123defc05' },
});
});
it('renders main sections and supports cancel navigation', async () => {
const user = userEvent.setup();
renderForm();
expect(await screen.findByText('Transmittal Details')).toBeInTheDocument();
expect(screen.getByText('Transmittal Items')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(back).toHaveBeenCalled();
});
it('shows validation errors when required fields are missing', async () => {
const user = userEvent.setup();
renderForm();
await user.click(await screen.findByRole('button', { name: 'Create Transmittal' }));
expect(await screen.findByText('Project is required')).toBeInTheDocument();
expect(screen.getByText('Recipient is required')).toBeInTheDocument();
expect(screen.getByText('Correspondence is required')).toBeInTheDocument();
expect(screen.getByText('Subject is required')).toBeInTheDocument();
});
it('submits cleaned transmittal payload and navigates to created record', async () => {
const user = userEvent.setup();
renderForm();
await screen.findByText('Transmittal Details');
await chooseCombobox(/project/i, 'LCBP3');
await chooseCombobox(/recipient organization/i, 'TEAM Consulting');
await user.click(screen.getByRole('combobox', { name: /reference document/i }));
await user.click(await screen.findByText('COR-001'));
await user.type(screen.getByPlaceholderText('Enter transmittal subject'), 'Weekly package');
await user.clear(screen.getByPlaceholderText('ID'));
await user.type(screen.getByPlaceholderText('ID'), '12');
await user.type(screen.getByPlaceholderText('Copies/Notes'), 'For record');
await user.type(screen.getByPlaceholderText('Additional notes...'), 'Submitted by test');
await user.click(screen.getByRole('button', { name: 'Create Transmittal' }));
await waitFor(() => {
expect(transmittalService.create).toHaveBeenCalledWith({
projectId: '019505a1-7c3e-7000-8000-abc123defc01',
recipientOrganizationId: '019505a1-7c3e-7000-8000-abc123defc02',
correspondenceId: '019505a1-7c3e-7000-8000-abc123defc03',
subject: 'Weekly package',
purpose: 'FOR_APPROVAL',
remarks: 'Submitted by test',
items: [{ itemType: 'DRAWING', itemId: 12, description: 'For record' }],
});
});
expect(toast.success).toHaveBeenCalledWith('Transmittal created successfully');
expect(push).toHaveBeenCalledWith('/transmittals/019505a1-7c3e-7000-8000-abc123defc05');
});
});
@@ -0,0 +1,65 @@
// File: frontend/components/transmittal/__tests__/transmittal-list.test.tsx
// Change Log:
// - 2026-06-13: Initial creation - test coverage for TransmittalList component
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TransmittalList } from '../transmittal-list';
import { Transmittal } from '@/types/transmittal';
// Mock DataTable เนื่องจากเป็น complex component
vi.mock('@/components/common/data-table', () => ({
DataTable: ({ data, columns }: { data: unknown[]; columns: unknown[] }) => (
<div data-testid="data-table">
<span data-testid="row-count">{data.length} rows</span>
<span data-testid="col-count">{columns.length} columns</span>
</div>
),
}));
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
// Mock Transmittal data ตาม ADR-019 (UUIDv7)
const mockTransmittal: Transmittal = {
publicId: '019505a1-7c3e-7000-8000-abc123def001',
transmittalNo: 'TRS-2026-001',
subject: 'Test Transmittal Subject',
purpose: 'FOR_APPROVAL',
items: [
{ publicId: '019505a1-7c3e-7000-8000-abc123def002', description: 'Item 1' } as any,
{ publicId: '019505a1-7c3e-7000-8000-abc123def003', description: 'Item 2' } as any,
],
createdAt: '2026-06-01T00:00:00Z',
} as any;
describe('TransmittalList', () => {
it('ควรเรนเดอร์ DataTable ได้ถูกต้อง', () => {
render(<TransmittalList data={[mockTransmittal]} />);
expect(screen.getByTestId('data-table')).toBeInTheDocument();
});
it('ควร pass data ถูกต้องให้ DataTable', () => {
render(<TransmittalList data={[mockTransmittal]} />);
expect(screen.getByTestId('row-count')).toHaveTextContent('1 rows');
});
it('ควร pass columns ถูกต้องให้ DataTable (6 columns)', () => {
render(<TransmittalList data={[mockTransmittal]} />);
expect(screen.getByTestId('col-count')).toHaveTextContent('6 columns');
});
it('ควร return null เมื่อ data เป็น null/undefined', () => {
const { container } = render(<TransmittalList data={null as any} />);
expect(container).toBeEmptyDOMElement();
});
it('ควรเรนเดอร์ empty state เมื่อ data เป็น array ว่าง', () => {
render(<TransmittalList data={[]} />);
expect(screen.getByTestId('data-table')).toBeInTheDocument();
expect(screen.getByTestId('row-count')).toHaveTextContent('0 rows');
});
});
@@ -0,0 +1,93 @@
// File: frontend/components/workflow/__tests__/integrated-banner.test.tsx
// Change Log
// - 2026-06-13: Add coverage for IntegratedBanner legacy and workflow action modes.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IntegratedBanner } from '../integrated-banner';
import { useWorkflowAction } from '@/hooks/use-workflow-action';
const mutate = vi.fn();
vi.mock('@/hooks/use-translations', () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock('@/hooks/use-workflow-action', () => ({
useWorkflowAction: vi.fn(),
}));
describe('IntegratedBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useWorkflowAction).mockReturnValue({
mutate,
isPending: false,
} as ReturnType<typeof useWorkflowAction>);
});
it('renders metadata, priority, workflow state, and legacy actions', async () => {
const user = userEvent.setup();
const onAction = vi.fn();
render(
<IntegratedBanner
docNo="RFA-001"
subject="Pump room approval"
status="IN_REVIEW"
priority="HIGH"
workflowState="PENDING_REVIEW"
availableActions={['APPROVE']}
onAction={onAction}
/>
);
expect(screen.getByText('RFA-001')).toBeInTheDocument();
expect(screen.getByText('Pump room approval')).toBeInTheDocument();
expect(screen.getByText('workflow.priority.HIGH')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /workflow.action.APPROVE/i }));
expect(onAction).toHaveBeenCalledWith('APPROVE', undefined);
});
it('requires comment for reject action', async () => {
const user = userEvent.setup();
const onAction = vi.fn();
render(
<IntegratedBanner
docNo="RFA-002"
subject="Return with note"
status="REJECTED"
availableActions={['REJECT']}
onAction={onAction}
/>
);
await user.click(screen.getByRole('button', { name: /workflow.action.REJECT/i }));
await user.type(screen.getByPlaceholderText('workflow.action.commentPlaceholder'), 'Need correction');
await user.click(screen.getByRole('button', { name: 'workflow.action.confirm' }));
expect(onAction).toHaveBeenCalledWith('REJECT', 'Need correction');
});
it('uses workflow mutation when instanceId is provided', async () => {
const user = userEvent.setup();
const onActionSuccess = vi.fn();
render(
<IntegratedBanner
docNo="RFA-003"
subject="Approve with instance"
status="APPROVED"
instanceId="019505a1-7c3e-7000-8000-abc123def801"
pendingAttachmentIds={['019505a1-7c3e-7000-8000-abc123def802']}
availableActions={['APPROVE']}
onActionSuccess={onActionSuccess}
/>
);
await user.click(screen.getByRole('button', { name: /workflow.action.APPROVE/i }));
expect(mutate).toHaveBeenCalledWith(
{
action: 'APPROVE',
comment: undefined,
attachmentPublicIds: ['019505a1-7c3e-7000-8000-abc123def802'],
},
{ onSuccess: onActionSuccess }
);
});
});
@@ -0,0 +1,107 @@
// File: frontend/components/workflow/__tests__/workflow-lifecycle.test.tsx
// Change Log
// - 2026-06-13: Add coverage for workflow timeline states, attachments, and upload handling.
import { fireEvent, 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 { WorkflowLifecycle } from '../workflow-lifecycle';
import type { WorkflowHistoryItem } from '@/types/workflow';
vi.mock('@/hooks/use-translations', () => ({
useTranslations: () => (key: string) => key,
}));
const history: WorkflowHistoryItem[] = [
{
id: 'step-submit',
fromState: 'DRAFT',
toState: 'IN_REVIEW',
action: 'SUBMIT',
actionByUserId: 7,
comment: 'Ready for review',
createdAt: '2026-06-13T08:00:00.000Z',
attachments: [
{
publicId: '019505a1-7c3e-7000-8000-abc123def901',
originalFilename: 'submission.pdf',
},
],
},
{
id: 'step-approve',
fromState: 'IN_REVIEW',
toState: 'APPROVED',
action: 'APPROVE',
createdAt: '2026-06-13T09:00:00.000Z',
},
];
describe('WorkflowLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(apiClient.post).mockResolvedValue({
data: {
publicId: '019505a1-7c3e-7000-8000-abc123def902',
originalFilename: 'uploaded.pdf',
},
});
});
it('renders loading, error, and empty states', () => {
const { rerender } = render(<WorkflowLifecycle isLoading />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
rerender(<WorkflowLifecycle error={new Error('Load failed')} />);
expect(screen.getByText('workflow.timeline.loadError')).toBeInTheDocument();
rerender(<WorkflowLifecycle history={[]} />);
expect(screen.getByText('workflow.timeline.noHistory')).toBeInTheDocument();
});
it('renders history steps and opens available attachments', async () => {
const user = userEvent.setup();
const onFileClick = vi.fn();
render(<WorkflowLifecycle history={history} currentState="APPROVED" onFileClick={onFileClick} />);
expect(screen.getByText('workflow.timeline.step.SUBMIT')).toBeInTheDocument();
expect(screen.getByText('workflow.timeline.step.APPROVE')).toBeInTheDocument();
expect(screen.getByText((content) => content.includes('Ready for review'))).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /submission.pdf/i }));
expect(onFileClick).toHaveBeenCalledWith(history[0].attachments?.[0]);
});
it('renders unavailable attachments as disabled chips', () => {
render(
<WorkflowLifecycle
history={history}
unavailableAttachmentIds={['019505a1-7c3e-7000-8000-abc123def901']}
/>
);
expect(screen.getByText('workflow.timeline.fileUnavailable')).toBeInTheDocument();
});
it('uploads and removes pending workflow step attachments', async () => {
const onAttachmentsChange = vi.fn();
render(<WorkflowLifecycle history={history} onAttachmentsChange={onAttachmentsChange} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['content'], 'uploaded.pdf', { type: 'application/pdf' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(apiClient.post).toHaveBeenCalledWith('/files/upload', expect.any(FormData));
});
expect(onAttachmentsChange).toHaveBeenCalledWith(['019505a1-7c3e-7000-8000-abc123def902']);
expect(screen.getByText('uploaded.pdf')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'workflow.timeline.removeFile' }));
expect(onAttachmentsChange).toHaveBeenLastCalledWith([]);
});
it('shows upload error toast when a file upload fails', async () => {
vi.mocked(apiClient.post).mockRejectedValue(new Error('Upload failed'));
render(<WorkflowLifecycle history={history} onAttachmentsChange={vi.fn()} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File(['bad'], 'bad.pdf', { type: 'application/pdf' })] } });
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('workflow.timeline.uploadError "bad.pdf"');
});
});
});