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

- Add ADR-036 unified OCR architecture (typhoon-ocr via Ollama)
- Extend AI execution profiles for OCR sandbox configuration
- Add comprehensive frontend test coverage (components, hooks, services)
- Add backend test coverage for document-numbering services
- Update OCR sidecar with typhoon-ocr integration
- Add AI policy service and execution profile management
- Update AGENTS.md and architecture documentation
This commit is contained in:
2026-06-14 06:34:07 +07:00
parent e3503b6a77
commit 7e8f4859cd
108 changed files with 33914 additions and 339 deletions
@@ -0,0 +1,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();
});
});