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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user