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