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,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();
});
});