feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
- Add ADR-036 unified OCR architecture (typhoon-ocr via Ollama) - Extend AI execution profiles for OCR sandbox configuration - Add comprehensive frontend test coverage (components, hooks, services) - Add backend test coverage for document-numbering services - Update OCR sidecar with typhoon-ocr integration - Add AI policy service and execution profile management - Update AGENTS.md and architecture documentation
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# ยกเว้นไฟล์ทดสอบและ specs
|
||||
*.spec.ts
|
||||
*.test.ts
|
||||
*.spec.tsx
|
||||
*.test.tsx
|
||||
__tests__/
|
||||
tests/
|
||||
test/
|
||||
|
||||
# ยกเว้น Next.js แคชและไฟล์บิลด์
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
coverage/
|
||||
tsconfig.tsbuildinfo
|
||||
eslint-frontend.json
|
||||
npm-audit-frontend.json
|
||||
|
||||
# ยกเว้นโฟลเดอร์มีเดียและโมดูล
|
||||
public/
|
||||
node_modules/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"lastAnalyzedAt": "2026-06-13T13:24:07.512Z",
|
||||
"gitCommitHash": "190b9a3af5f505e9ec59ba8d447c4720b2cb7dae",
|
||||
"version": "1.0.0",
|
||||
"analyzedFiles": 373
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// File: frontend/__tests__/README.md
|
||||
// Change Log
|
||||
// - 2026-06-13: Document frontend unit test naming and header conventions.
|
||||
|
||||
# Frontend Test Conventions
|
||||
|
||||
ใช้ไฟล์ `*.test.ts` หรือ `*.test.tsx` เท่านั้น เพราะ `frontend/vitest.config.ts` include pattern รองรับชื่อนี้
|
||||
|
||||
ทุก test file ต้องขึ้นต้นด้วย:
|
||||
|
||||
```ts
|
||||
// File: frontend/path/to/file.test.ts
|
||||
// Change Log
|
||||
// - YYYY-MM-DD: คำอธิบายการเปลี่ยนแปลง
|
||||
```
|
||||
|
||||
แนวทางหลัก:
|
||||
|
||||
- ใช้ `createTestQueryClient()` จาก `@/lib/test-utils` สำหรับ hook/component ที่ใช้ TanStack Query
|
||||
- Mock HTTP ผ่าน `apiClient` ที่ตั้งค่าไว้ใน `vitest.setup.ts`
|
||||
- Mock data ฝั่ง Public API ต้องใช้ `publicId` เป็น UUIDv7 ตาม ADR-019
|
||||
- ห้ามใช้ `console.log` ใน test
|
||||
- หลีกเลี่ยง `any`; ถ้าจำเป็นต้อง mock shape บางส่วน ให้ใช้ `Partial<T>` หรือ type เฉพาะของ test
|
||||
@@ -0,0 +1,24 @@
|
||||
// File: frontend/__tests__/helpers/api-mock.ts
|
||||
// Change Log
|
||||
// - 2026-06-13: Add shared API client mock shape assertions for frontend tests.
|
||||
|
||||
import { expect, type Mock } from 'vitest';
|
||||
|
||||
type ApiClientMock = {
|
||||
get: Mock;
|
||||
post: Mock;
|
||||
put: Mock;
|
||||
patch: Mock;
|
||||
delete: Mock;
|
||||
};
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า apiClient mock จาก vitest.setup.ts มี method ครบตาม pattern กลาง
|
||||
*/
|
||||
export function expectApiClientMockShape(apiClient: ApiClientMock): void {
|
||||
expect(apiClient.get).toBeTypeOf('function');
|
||||
expect(apiClient.post).toBeTypeOf('function');
|
||||
expect(apiClient.put).toBeTypeOf('function');
|
||||
expect(apiClient.patch).toBeTypeOf('function');
|
||||
expect(apiClient.delete).toBeTypeOf('function');
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
// - 2026-05-30: นำเข้าและแสดงผล OcrEngineSelector component ใน Overview tab (T019, T020)
|
||||
// - 2026-06-02: เพิ่มตัวบ่งชี้โมเดลหลักที่กำลังใช้งาน (Active Global Model badge) บนการ์ด System Toggle (T010, ADR-033)
|
||||
// - 2026-06-13: [235] ลบ AI Model Management (ADR-027) และ OCR Engine Selector ออก; แก้ System Toggle แสดง canonical names (np-dms-ai/np-dms-ocr); แก้ label OCR Sidecar
|
||||
// - 2026-06-13: ADR-036 — ใช้ canonical model constants สำหรับหน้า AI Admin Console
|
||||
|
||||
'use client';
|
||||
|
||||
@@ -45,6 +46,9 @@ interface VramLoadedModelView {
|
||||
vramUsageMB?: number;
|
||||
}
|
||||
|
||||
const MAIN_MODEL_NAME = 'np-dms-ai';
|
||||
const OCR_MODEL_NAME = 'np-dms-ocr';
|
||||
|
||||
function ensureArray<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
@@ -58,9 +62,9 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
const name = item.toLowerCase();
|
||||
let normName = item;
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) {
|
||||
normName = 'np-dms-ocr';
|
||||
} else if (name.includes('typhoon') || name.includes('np-dms-ai')) {
|
||||
normName = 'np-dms-ai';
|
||||
normName = OCR_MODEL_NAME;
|
||||
} else if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) {
|
||||
normName = MAIN_MODEL_NAME;
|
||||
}
|
||||
return {
|
||||
modelId: `${item}-${index}`,
|
||||
@@ -78,9 +82,9 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
const name = rawName.toLowerCase();
|
||||
let normName = rawName;
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) {
|
||||
normName = 'np-dms-ocr';
|
||||
} else if (name.includes('typhoon') || name.includes('np-dms-ai')) {
|
||||
normName = 'np-dms-ai';
|
||||
normName = OCR_MODEL_NAME;
|
||||
} else if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) {
|
||||
normName = MAIN_MODEL_NAME;
|
||||
}
|
||||
return {
|
||||
modelId: model.modelId ?? rawName,
|
||||
@@ -97,8 +101,8 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
|
||||
function toCanonicalModel(rawName: string): string {
|
||||
const name = rawName.toLowerCase();
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return 'np-dms-ocr';
|
||||
if (name.includes('typhoon') || name.includes('np-dms-ai')) return 'np-dms-ai';
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return OCR_MODEL_NAME;
|
||||
if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME;
|
||||
return rawName;
|
||||
}
|
||||
|
||||
@@ -135,8 +139,8 @@ export default function AiAdminConsolePage() {
|
||||
const rawHealthOllamaModels = ensureArray<string>(health?.ollama?.models);
|
||||
const healthOllamaModels = Array.from(new Set(rawHealthOllamaModels.map((m) => {
|
||||
const name = m.toLowerCase();
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return 'np-dms-ocr';
|
||||
if (name.includes('typhoon') || name.includes('np-dms-ai')) return 'np-dms-ai';
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return OCR_MODEL_NAME;
|
||||
if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME;
|
||||
return m;
|
||||
})));
|
||||
const healthQdrantCollections = ensureArray<string>(health?.qdrant?.collections);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
// Change Log
|
||||
// - 2026-05-25: Created useAiPrompts unified hook for React Query prompt operations (ADR-029)
|
||||
// - 2026-05-25: Added useSandboxRun hook to encapsulate submit + polling logic (Obs #2 fix)
|
||||
// - 2026-06-13: US4 — อัปเดต submit ใน useSandboxRun ให้สอดคล้องกับ API signature ใหม่
|
||||
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -144,14 +146,14 @@ export function useSandboxRun(onCompleted?: () => void) {
|
||||
* ส่ง PDF file เข้า sandbox queue และเริ่ม polling อัตโนมัติ
|
||||
* @returns requestPublicId หรือ throw Error เมื่อล้มเหลว
|
||||
*/
|
||||
const submit = useCallback(async (file: File): Promise<string> => {
|
||||
const submit = useCallback(async (file: File, projectPublicId: string, contractPublicId?: string): Promise<string> => {
|
||||
setState({
|
||||
isRunning: true,
|
||||
progress: 10,
|
||||
statusText: 'ai.prompt.uploading',
|
||||
result: null,
|
||||
});
|
||||
const response = await adminAiService.submitSandboxExtract(file);
|
||||
const response = await adminAiService.submitSandboxExtract(file, projectPublicId, contractPublicId);
|
||||
setJobId(response.requestPublicId);
|
||||
return response.requestPublicId;
|
||||
}, []);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// File: lib/api/client.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Export getAuthToken for unit testing
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@@ -10,7 +12,7 @@ let cachedToken: string | null = null;
|
||||
let tokenPromise: Promise<string | null> | null = null;
|
||||
|
||||
// Async function to get token
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
export async function getAuthToken(): Promise<string | null> {
|
||||
if (cachedToken) return cachedToken;
|
||||
|
||||
if (tokenPromise) return tokenPromise;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// File: frontend/lib/i18n/__tests__/index.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Add coverage for Thai/English translators and template replacement
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createT, t } from '../index';
|
||||
|
||||
describe('i18n utility', () => {
|
||||
it('default translator ควรใช้ภาษาไทย', () => {
|
||||
expect(t('workflow.action.APPROVE')).toBe('อนุมัติ');
|
||||
});
|
||||
|
||||
it('createT ควรสร้าง translator ภาษาอังกฤษได้', () => {
|
||||
const translate = createT('en');
|
||||
expect(translate('workflow.action.APPROVE')).toBe('Approve');
|
||||
});
|
||||
|
||||
it('ควรคืน key เดิมเมื่อไม่พบข้อความ', () => {
|
||||
const translate = createT('th');
|
||||
expect(translate('missing.translation.key')).toBe('missing.translation.key');
|
||||
});
|
||||
|
||||
it('ควรแทนค่า template params ด้วย string หรือ number', () => {
|
||||
const translate = createT('en');
|
||||
expect(translate('ai.staging.thresholdWarningDesc', { rate: 42 })).toBe(
|
||||
'Override rate reached 42% in recent records.'
|
||||
);
|
||||
expect(translate('ai.prompt.resultVersionBadge', { version: '3' })).toBe('Extracted with v3');
|
||||
});
|
||||
|
||||
it('ควรแทนค่า missing template param เป็นค่าว่าง', () => {
|
||||
const translate = createT('en');
|
||||
expect(translate('ai.prompt.resultVersionBadge')).toBe('Extracted with v{{version}}');
|
||||
expect(translate('ai.prompt.resultVersionBadge', {})).toBe('Extracted with v');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// File: frontend/lib/services/__tests__/ai.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for aiService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import api from '@/lib/api/client';
|
||||
import { aiService } from '../ai.service';
|
||||
|
||||
describe('aiService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('extract', () => {
|
||||
it('ควรส่งคำขอ POST /ai/extract เพื่อสกัดข้อมูลและส่งกลับผลลัพธ์สำเร็จ', async () => {
|
||||
const mockResult = {
|
||||
documentNumber: 'DOC-001',
|
||||
title: 'Document Title',
|
||||
confidenceScore: 0.95,
|
||||
};
|
||||
vi.mocked(api.post).mockResolvedValue({ data: mockResult });
|
||||
const dto = { filePublicId: 'file-123' };
|
||||
const result = await aiService.extract(dto);
|
||||
expect(api.post).toHaveBeenCalledWith('/ai/extract', dto);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('ควรจัดการการห่อหุ้มข้อมูล (nested data wrapper) ได้อย่างถูกต้อง', async () => {
|
||||
const mockResult = {
|
||||
documentNumber: 'DOC-001',
|
||||
title: 'Document Title',
|
||||
confidenceScore: 0.95,
|
||||
};
|
||||
vi.mocked(api.post).mockResolvedValue({ data: { data: mockResult } });
|
||||
const dto = { filePublicId: 'file-123' };
|
||||
const result = await aiService.extract(dto);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMigrationList', () => {
|
||||
it('ควรดึงประวัติการอพยพข้อมูลพร้อมแบ่งหน้าได้ถูกต้อง', async () => {
|
||||
const mockResponse = {
|
||||
items: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-log111111111',
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
vi.mocked(api.get).mockResolvedValue({ data: mockResponse });
|
||||
const result = await aiService.getMigrationList({ page: 1, limit: 10 });
|
||||
expect(api.get).toHaveBeenCalledWith('/ai/migration', { params: { page: 1, limit: 10 } });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('ควรคืนค่ารูปแบบแบ่งหน้าเริ่มต้นหากข้อมูลที่ได้รับไม่ถูกต้อง', async () => {
|
||||
vi.mocked(api.get).mockResolvedValue({ data: null });
|
||||
const result = await aiService.getMigrationList({});
|
||||
expect(result).toEqual({ items: [], total: 0, page: 1, limit: 10, totalPages: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMigration', () => {
|
||||
it('ควรส่งคำขอ PATCH พร้อมแนบ Idempotency-Key สำเร็จ', async () => {
|
||||
const mockLog = {
|
||||
publicId: '019505a1-7c3e-7000-8000-log111111111',
|
||||
status: 'VERIFIED',
|
||||
};
|
||||
vi.mocked(api.patch).mockResolvedValue({ data: mockLog });
|
||||
const dto = { status: 'VERIFIED' as const };
|
||||
const result = await aiService.updateMigration(
|
||||
'019505a1-7c3e-7000-8000-log111111111',
|
||||
dto,
|
||||
'idempotency-123'
|
||||
);
|
||||
expect(api.patch).toHaveBeenCalledWith(
|
||||
'/ai/migration/019505a1-7c3e-7000-8000-log111111111',
|
||||
dto,
|
||||
{ headers: { 'Idempotency-Key': 'idempotency-123' } }
|
||||
);
|
||||
expect(result).toEqual(mockLog);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitFeedback', () => {
|
||||
it('ควรส่งคำขอ POST /ai/feedback พร้อมข้อมูลฟีดแบ็คสำเร็จ', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({ data: {} });
|
||||
const dto = { logPublicId: 'log-1', rating: 5, comments: 'Good extraction' };
|
||||
await aiService.submitFeedback(dto);
|
||||
expect(api.post).toHaveBeenCalledWith('/ai/feedback', dto);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// File: frontend/lib/services/__tests__/audit-log.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for auditLogService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { auditLogService } from '../audit-log.service';
|
||||
|
||||
describe('auditLogService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getLogs', () => {
|
||||
it('ควรดึงข้อมูล audit logs รูปแบบอาร์เรย์ได้อย่างถูกต้อง', async () => {
|
||||
const mockLogs = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-audit1111111',
|
||||
auditId: 'AUD-001',
|
||||
action: 'LOGIN',
|
||||
severity: 'INFO',
|
||||
createdAt: '2026-06-13T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLogs });
|
||||
const result = await auditLogService.getLogs();
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/audit-logs', { params: undefined });
|
||||
expect(result).toEqual(mockLogs);
|
||||
});
|
||||
|
||||
it('ควรดึงข้อมูล audit logs รูปแบบ data wrapper ได้อย่างถูกต้อง', async () => {
|
||||
const mockLogs = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-audit1111111',
|
||||
auditId: 'AUD-001',
|
||||
action: 'LOGIN',
|
||||
severity: 'INFO',
|
||||
createdAt: '2026-06-13T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockLogs } });
|
||||
const result = await auditLogService.getLogs({ search: 'login' });
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/audit-logs', { params: { search: 'login' } });
|
||||
expect(result).toEqual(mockLogs);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// File: frontend/lib/services/__tests__/contract.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for contractService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { contractService } from '../contract.service';
|
||||
|
||||
describe('contractService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('ควรดึงข้อมูลสัญญาและประมวลผลรูปแบบอาร์เรย์ได้อย่างถูกต้อง', async () => {
|
||||
const mockContracts = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-contract111',
|
||||
contractName: 'Contract Alpha',
|
||||
project: {
|
||||
publicId: '019505a1-7c3e-7000-8000-project111',
|
||||
projectName: 'Project Alpha',
|
||||
},
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContracts });
|
||||
const result = await contractService.getAll();
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/contracts', { params: undefined });
|
||||
expect(result).toEqual(mockContracts);
|
||||
});
|
||||
|
||||
it('ควรดึงข้อมูลและประมวลผลรูปแบบ nested data ได้อย่างถูกต้อง', async () => {
|
||||
const mockContracts = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-contract111',
|
||||
contractName: 'Contract Alpha',
|
||||
project: {
|
||||
publicId: '019505a1-7c3e-7000-8000-project111',
|
||||
projectName: 'Project Alpha',
|
||||
},
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockContracts } });
|
||||
const result = await contractService.getAll({ projectId: 1 });
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/contracts', { params: { projectId: 1 } });
|
||||
expect(result).toEqual(mockContracts);
|
||||
});
|
||||
|
||||
it('ควรส่งกลับอาร์เรย์ว่างหากข้อมูลที่ได้รับไม่ใช่รูปแบบอาร์เรย์', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: null });
|
||||
const result = await contractService.getAll();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByUuid', () => {
|
||||
it('ควรดึงรายละเอียดสัญญาตาม UUID สำเร็จ', async () => {
|
||||
const mockContract = {
|
||||
publicId: '019505a1-7c3e-7000-8000-contract111',
|
||||
contractName: 'Contract Alpha',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContract });
|
||||
const result = await contractService.getByUuid('019505a1-7c3e-7000-8000-contract111');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/contracts/019505a1-7c3e-7000-8000-contract111');
|
||||
expect(result).toEqual(mockContract);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อสร้างสัญญาใหม่สำเร็จ', async () => {
|
||||
const createDto = { contractName: 'New Contract', contractCode: 'C-001', projectId: 1 };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { publicId: 'new-uuid', ...createDto } });
|
||||
const result = await contractService.create(createDto);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/contracts', createDto);
|
||||
expect(result.contractName).toBe('New Contract');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('ควรส่งคำขอ PATCH เพื่ออัปเดตสัญญาสำเร็จ', async () => {
|
||||
const updateDto = { contractName: 'Updated Contract' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { publicId: 'uuid', ...updateDto } });
|
||||
const result = await contractService.update('uuid', updateDto);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/contracts/uuid', updateDto);
|
||||
expect(result.contractName).toBe('Updated Contract');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('ควรส่งคำขอ DELETE เพื่อลบสัญญาสำเร็จ', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||
const result = await contractService.delete('uuid');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/contracts/uuid');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// File: frontend/lib/services/__tests__/review-team.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for reviewTeamService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { reviewTeamService } from '../review-team.service';
|
||||
|
||||
describe('reviewTeamService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('ควรดึงข้อมูลทีมทบทวนทั้งหมดสำเร็จ', async () => {
|
||||
const mockTeams = [{ publicId: '019505a1-7c3e-7000-8000-team11111111', name: 'Review Team Alpha' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTeams });
|
||||
const result = await reviewTeamService.getAll({ projectPublicId: 'proj-1' });
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/review-teams', { params: { projectPublicId: 'proj-1' } });
|
||||
expect(result).toEqual(mockTeams);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByPublicId', () => {
|
||||
it('ควรดึงข้อมูลทีมตาม PublicId สำเร็จ', async () => {
|
||||
const mockTeam = { publicId: '019505a1-7c3e-7000-8000-team11111111', name: 'Review Team Alpha' };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTeam });
|
||||
const result = await reviewTeamService.getByPublicId('019505a1-7c3e-7000-8000-team11111111');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/review-teams/019505a1-7c3e-7000-8000-team11111111');
|
||||
expect(result).toEqual(mockTeam);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อสร้างทีมทบทวนใหม่สำเร็จ', async () => {
|
||||
const createDto = { name: 'New Team', projectPublicId: 'proj-1', defaultForRfaTypes: ['RFA'] };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { publicId: 'new-team-uuid', ...createDto } });
|
||||
const result = await reviewTeamService.create(createDto);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/review-teams', createDto);
|
||||
expect(result.name).toBe('New Team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('ควรส่งคำขอ PATCH เพื่ออัปเดตทีมทบทวนสำเร็จ', async () => {
|
||||
const updateDto = { name: 'Updated Team' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { publicId: 'team-uuid', ...updateDto } });
|
||||
const result = await reviewTeamService.update('team-uuid', updateDto);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/review-teams/team-uuid', updateDto);
|
||||
expect(result.name).toBe('Updated Team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMember', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อเพิ่มสมาชิกเข้าทีมทบทวนสำเร็จ', async () => {
|
||||
const memberDto = { userPublicId: 'user-1', disciplineId: 1, role: 'REVIEWER' as const };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
|
||||
const result = await reviewTeamService.addMember('team-uuid', memberDto);
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/review-teams/team-uuid/members', memberDto);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('ควรส่งคำขอ DELETE เพื่อลบสมาชิกออกจากทีมทบทวนสำเร็จ', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||
const result = await reviewTeamService.removeMember('team-uuid', 'member-uuid');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/review-teams/team-uuid/members/member-uuid');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('ควรส่งคำขอ DELETE เพื่อหยุดการทำงานของทีมทบทวนสำเร็จ', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
||||
const result = await reviewTeamService.deactivate('team-uuid');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/review-teams/team-uuid');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// File: frontend/lib/services/__tests__/search.service.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for searchService
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { searchService } from '../search.service';
|
||||
|
||||
describe('searchService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('ควรส่งคำขอ GET /search พร้อมข้อมูลการค้นหาสำเร็จ', async () => {
|
||||
const mockResult = { items: [{ publicId: '019505a1-7c3e-7000-8000-doc111111111', title: 'Test doc' }] };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
|
||||
const query = { q: 'test', limit: 10, offset: 0 };
|
||||
const result = await searchService.search(query);
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/search', { params: query });
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggest', () => {
|
||||
it('ควรดึงข้อมูล suggest และแกะค่า items ออกมาสำเร็จ', async () => {
|
||||
const mockResult = { items: ['test1', 'test2'] };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
|
||||
const result = await searchService.suggest('test');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/search', { params: { q: 'test', limit: 5 } });
|
||||
expect(result).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('ควรคืนค่า raw response ใน suggest หากไม่มีฟิลด์ items', async () => {
|
||||
const mockResult = ['test1', 'test2'];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
|
||||
const result = await searchService.suggest('test');
|
||||
expect(result).toEqual(['test1', 'test2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reindex', () => {
|
||||
it('ควรส่งคำขอ POST เพื่อสั่ง reindex สำเร็จ', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
|
||||
const result = await searchService.reindex('correspondence');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search/reindex', { type: 'correspondence' });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,10 @@
|
||||
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (หลัก+OCR) ใน AiSystemHealth interface
|
||||
// - 2026-06-02: แก้ endpoint getAvailableModels ให้ตรงกับ backend admin route (/ai/admin/models)
|
||||
// - 2026-06-02: normalize VRAM response ให้รองรับ field names จาก backend ปัจจุบันและรูปแบบ loadedModels แบบเดิม
|
||||
// - 2026-06-13: T027-T029 — เพิ่ม getSandboxProfile, saveSandboxProfile, resetSandboxProfile สำหรับ sandbox parameter management
|
||||
// - 2026-06-13: T042-T043 — เพิ่ม applyProfile และ getProductionDefaults สำหรับปรับใช้และดึงค่า production parameters
|
||||
// - 2026-06-13: US4 — อัปเดต submitSandboxExtract และ submitSandboxAiExtract ให้รองรับ project/contract publicId
|
||||
|
||||
|
||||
import api from '../api/client';
|
||||
import { AiJobResponse } from '../../types/ai';
|
||||
@@ -138,6 +142,17 @@ export interface AiActiveModelResponse {
|
||||
activeModel: string;
|
||||
}
|
||||
|
||||
/** พารามิเตอร์ sandbox draft สำหรับ profile (ADR-036) */
|
||||
export interface SandboxProfileParams {
|
||||
canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
|
||||
temperature: number;
|
||||
topP: number;
|
||||
maxTokens: number | null;
|
||||
numCtx: number | null;
|
||||
repeatPenalty: number;
|
||||
keepAliveSeconds: number;
|
||||
}
|
||||
|
||||
const extractData = <T>(value: unknown): T => {
|
||||
if (value && typeof value === 'object' && 'data' in value) {
|
||||
return (value as { data: T }).data;
|
||||
@@ -215,10 +230,16 @@ export const adminAiService = {
|
||||
return extractData<AiSandboxJobResult>(data);
|
||||
},
|
||||
submitSandboxExtract: async (
|
||||
file: File
|
||||
file: File,
|
||||
projectPublicId: string,
|
||||
contractPublicId?: string
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('projectPublicId', projectPublicId);
|
||||
if (contractPublicId) {
|
||||
formData.append('contractPublicId', contractPublicId);
|
||||
}
|
||||
const { data } = await api.post('/ai/admin/sandbox/extract', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
@@ -258,11 +279,15 @@ export const adminAiService = {
|
||||
|
||||
submitSandboxAiExtract: async (
|
||||
requestPublicId: string,
|
||||
promptVersion?: number
|
||||
promptVersion: number | undefined,
|
||||
projectPublicId: string,
|
||||
contractPublicId?: string
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const { data } = await api.post('/ai/admin/sandbox/ai-extract', {
|
||||
requestPublicId,
|
||||
promptVersion,
|
||||
projectPublicId,
|
||||
contractPublicId,
|
||||
});
|
||||
return extractData<{ requestPublicId: string; jobId: string; status: string }>(data);
|
||||
},
|
||||
@@ -317,6 +342,51 @@ export const adminAiService = {
|
||||
return extractData<{ activeEngineName: string }>(data);
|
||||
},
|
||||
|
||||
// --- Sandbox Parameter Management (ADR-036, T027-T029) ---
|
||||
|
||||
getSandboxProfile: async (profileName: string): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.get(`/ai/sandbox-profiles/${encodeURIComponent(profileName)}`);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
saveSandboxProfile: async (
|
||||
profileName: string,
|
||||
updates: Partial<SandboxProfileParams>,
|
||||
idempotencyKey: string
|
||||
): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.put(
|
||||
`/ai/sandbox-profiles/${encodeURIComponent(profileName)}`,
|
||||
updates,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
resetSandboxProfile: async (profileName: string): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.post(
|
||||
`/ai/sandbox-profiles/${encodeURIComponent(profileName)}/reset`,
|
||||
{}
|
||||
);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
applyProfile: async (
|
||||
profileName: string,
|
||||
idempotencyKey: string
|
||||
): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.post(
|
||||
`/ai/profiles/${encodeURIComponent(profileName)}/apply`,
|
||||
{},
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
getProductionDefaults: async (profileName: string): Promise<SandboxProfileParams> => {
|
||||
const { data } = await api.get(`/ai/profiles/${encodeURIComponent(profileName)}`);
|
||||
return extractData<SandboxProfileParams>(data);
|
||||
},
|
||||
|
||||
submitAiJob: async (
|
||||
type: string,
|
||||
documentPublicId?: string,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// File: frontend/lib/stores/__tests__/auth-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Add coverage for auth state transitions and permission helpers
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore, User } from '../auth-store';
|
||||
|
||||
const user: User = {
|
||||
id: '019505a1-7c3e-7000-8000-abc123def100',
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def100',
|
||||
username: 'frontend.tester',
|
||||
email: 'tester@example.local',
|
||||
firstName: 'Frontend',
|
||||
lastName: 'Tester',
|
||||
role: 'User',
|
||||
permissions: ['documents.read', 'workflow.execute'],
|
||||
primaryOrganizationName: 'NP DMS',
|
||||
};
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => {
|
||||
useAuthStore.setState({ user: null, token: null, isAuthenticated: false });
|
||||
});
|
||||
});
|
||||
|
||||
it('ควรมีค่า default เป็นสถานะยังไม่ authenticated', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('setAuth ควรบันทึก user, token และสถานะ authenticated', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
});
|
||||
expect(result.current.user?.publicId).toBe(user.publicId);
|
||||
expect(result.current.token).toBe('access-token');
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('logout ควรล้างข้อมูล session ออกจาก store', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
result.current.logout();
|
||||
});
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('hasPermission ควรตรวจ permission ของ user ปัจจุบัน', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
expect(result.current.hasPermission('documents.read')).toBe(false);
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
});
|
||||
expect(result.current.hasPermission('documents.read')).toBe(true);
|
||||
expect(result.current.hasPermission('admin.manage')).toBe(false);
|
||||
});
|
||||
|
||||
it('hasPermission ควรให้ Admin ผ่านทุก permission', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
act(() => {
|
||||
result.current.setAuth({ ...user, role: 'admin', permissions: [] }, 'access-token');
|
||||
});
|
||||
expect(result.current.hasPermission('admin.manage')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasRole ควรเทียบ role แบบตรงตัวกับ user ปัจจุบัน', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
expect(result.current.hasRole('User')).toBe(false);
|
||||
act(() => {
|
||||
result.current.setAuth(user, 'access-token');
|
||||
});
|
||||
expect(result.current.hasRole('User')).toBe(true);
|
||||
expect(result.current.hasRole('Admin')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// File: frontend/lib/stores/__tests__/draft-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for useDraftStore (Zustand)
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useDraftStore } from '../draft-store';
|
||||
|
||||
describe('useDraftStore', () => {
|
||||
beforeEach(() => {
|
||||
// รีเซ็ต store ก่อนแต่ละ test
|
||||
act(() => {
|
||||
useDraftStore.setState({ drafts: {} });
|
||||
});
|
||||
});
|
||||
|
||||
it('ค่า default ควรเป็น drafts: {}', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
expect(result.current.drafts).toEqual({});
|
||||
});
|
||||
|
||||
it('saveDraft ควรบันทึก draft data ด้วย key ที่กำหนด', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
const draftData = { title: 'Test Document', projectId: '019505a1-7c3e-7000-8000-abc123def456' };
|
||||
act(() => {
|
||||
result.current.saveDraft('rfa-new', draftData);
|
||||
});
|
||||
expect(result.current.drafts['rfa-new']).toEqual(draftData);
|
||||
});
|
||||
|
||||
it('getDraft ควรดึงข้อมูล draft ตาม key', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
const draftData = { subject: 'Correspondence Test', content: 'Body text' };
|
||||
act(() => {
|
||||
result.current.saveDraft('corr-edit', draftData);
|
||||
});
|
||||
const retrieved = result.current.getDraft('corr-edit');
|
||||
expect(retrieved).toEqual(draftData);
|
||||
});
|
||||
|
||||
it('getDraft ควร return undefined หาก key ไม่มีใน store', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
const retrieved = result.current.getDraft('non-existent-key');
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clearDraft ควรลบ draft ออกตาม key', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
act(() => {
|
||||
result.current.saveDraft('rfa-draft', { title: 'To Delete' });
|
||||
});
|
||||
expect(result.current.drafts['rfa-draft']).toBeDefined();
|
||||
act(() => {
|
||||
result.current.clearDraft('rfa-draft');
|
||||
});
|
||||
expect(result.current.drafts['rfa-draft']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('saveDraft ไม่ควรลบ draft อื่นที่ไม่ใช่ key เดียวกัน', () => {
|
||||
const { result } = renderHook(() => useDraftStore());
|
||||
act(() => {
|
||||
result.current.saveDraft('key-a', { data: 'A' });
|
||||
result.current.saveDraft('key-b', { data: 'B' });
|
||||
});
|
||||
act(() => {
|
||||
result.current.clearDraft('key-a');
|
||||
});
|
||||
expect(result.current.drafts['key-a']).toBeUndefined();
|
||||
expect(result.current.drafts['key-b']).toEqual({ data: 'B' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// File: frontend/lib/stores/__tests__/project-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for useProjectStore (Zustand)
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useProjectStore } from '../project-store';
|
||||
|
||||
describe('useProjectStore', () => {
|
||||
beforeEach(() => {
|
||||
// รีเซ็ต store ก่อนแต่ละ test
|
||||
act(() => {
|
||||
useProjectStore.setState({ selectedProjectId: null });
|
||||
});
|
||||
});
|
||||
|
||||
it('ค่า default ควรเป็น selectedProjectId: null', () => {
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
expect(result.current.selectedProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it('setSelectedProjectId ควรตั้งค่า selectedProjectId ด้วย UUIDv7 ที่กำหนด', () => {
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
const projectId = '019505a1-7c3e-7000-8000-abc123def456';
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(projectId);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBe(projectId);
|
||||
});
|
||||
|
||||
it('setSelectedProjectId ควรเปลี่ยน selectedProjectId จาก UUID เป็น null ได้', () => {
|
||||
const projectId = '019505a1-7c3e-7000-8000-abc123def456';
|
||||
act(() => {
|
||||
useProjectStore.setState({ selectedProjectId: projectId });
|
||||
});
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(null);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it('setSelectedProjectId ควรเปลี่ยน project ได้หลายครั้ง', () => {
|
||||
const { result } = renderHook(() => useProjectStore());
|
||||
const projectId1 = '019505a1-7c3e-7000-8000-abc123def001';
|
||||
const projectId2 = '019505a1-7c3e-7000-8000-abc123def002';
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(projectId1);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBe(projectId1);
|
||||
act(() => {
|
||||
result.current.setSelectedProjectId(projectId2);
|
||||
});
|
||||
expect(result.current.selectedProjectId).toBe(projectId2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// File: frontend/lib/stores/__tests__/ui-store.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for useUIStore (Zustand)
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useUIStore } from '../ui-store';
|
||||
|
||||
describe('useUIStore', () => {
|
||||
beforeEach(() => {
|
||||
// รีเซ็ต store ก่อนแต่ละ test
|
||||
act(() => {
|
||||
useUIStore.setState({ isSidebarOpen: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('ค่า default ควรเป็น isSidebarOpen: true', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
expect(result.current.isSidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleSidebar ควรสลับค่า isSidebarOpen จาก true เป็น false', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.toggleSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleSidebar ควรสลับค่า isSidebarOpen จาก false เป็น true', () => {
|
||||
act(() => {
|
||||
useUIStore.setState({ isSidebarOpen: false });
|
||||
});
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.toggleSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('closeSidebar ควรตั้งค่า isSidebarOpen เป็น false', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.closeSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('openSidebar ควรตั้งค่า isSidebarOpen เป็น true', () => {
|
||||
act(() => {
|
||||
useUIStore.setState({ isSidebarOpen: false });
|
||||
});
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
act(() => {
|
||||
result.current.openSidebar();
|
||||
});
|
||||
expect(result.current.isSidebarOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// File: frontend/lib/utils/__tests__/uuid-guard.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for assertUuid utility (pure function 100%)
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assertUuid } from '../uuid-guard';
|
||||
|
||||
describe('assertUuid', () => {
|
||||
it('ควร return UUID ที่ถูกต้องกลับมา', () => {
|
||||
const validUuid = '019505a1-7c3e-7000-8000-abc123def456';
|
||||
expect(assertUuid(validUuid)).toBe(validUuid);
|
||||
});
|
||||
|
||||
it('ควร return UUIDv4 ที่ถูกต้องกลับมา', () => {
|
||||
const uuidV4 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||
expect(assertUuid(uuidV4)).toBe(uuidV4);
|
||||
});
|
||||
|
||||
it('ควร return UUID lowercase ที่ถูกต้องกลับมา', () => {
|
||||
const lowercase = '00000000-0000-0000-0000-000000000001';
|
||||
expect(assertUuid(lowercase)).toBe(lowercase);
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ value ไม่ใช่ UUID format', () => {
|
||||
expect(() => assertUuid('not-a-uuid')).toThrow('Invalid UUID format: not-a-uuid');
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ value เป็น integer string', () => {
|
||||
expect(() => assertUuid('12345')).toThrow('Invalid UUID format: 12345');
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ value เป็น string ว่าง', () => {
|
||||
expect(() => assertUuid('')).toThrow('Invalid UUID format: ');
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ UUID มี segment ไม่ครบ', () => {
|
||||
expect(() => assertUuid('019505a1-7c3e-7000-8000')).toThrow();
|
||||
});
|
||||
|
||||
it('ควร throw Error เมื่อ UUID มีตัวอักษรที่ไม่ใช่ hex', () => {
|
||||
expect(() => assertUuid('gggggggg-gggg-gggg-gggg-gggggggggggg')).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user