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