Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions src/api/documents.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchDocumentRequestDraft,
fetchDocument,
fetchDocuments,
patchWorkerDocument,
registerWorkerDocument,
upsertDocumentRequestDraft,
} from './documents'

function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
return new Response(JSON.stringify(body), {
status: 200,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

200 확인이용

headers: { 'Content-Type': 'application/json' },
})
}

beforeEach(() => {
Expand All @@ -21,7 +25,9 @@ afterEach(() => {

describe('fetchDocuments', () => {
it('requests /documents with default pagination and no filters', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 }))
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 }),
)

await fetchDocuments()

Expand All @@ -30,9 +36,17 @@ describe('fetchDocuments', () => {
})

it('adds filters when given', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 }))
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 }),
)

await fetchDocuments({ workerId: 'W-1', documentType: 'PASSPORT_COPY', status: 'MISSING', page: 1, size: 20 })
await fetchDocuments({
workerId: 'W-1',
documentType: 'PASSPORT_COPY',
status: 'MISSING',
page: 1,
size: 20,
})

const [url] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('workerId=W-1')
Expand All @@ -42,6 +56,17 @@ describe('fetchDocuments', () => {
})
})

describe('fetchDocument', () => {
it('requests the encoded document detail endpoint', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ worker_document_id: 'D/1' }))

await fetchDocument('D/1')

const [url] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('/documents/D%2F1')
})
})

describe('registerWorkerDocument', () => {
it('POSTs to /workers/{id}/documents', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
Expand All @@ -60,7 +85,10 @@ describe('registerWorkerDocument', () => {
}),
)

await registerWorkerDocument('W-1', { document_type: 'PASSPORT_COPY', submission_status: 'SUBMITTED' })
await registerWorkerDocument('W-1', {
document_type: 'PASSPORT_COPY',
submission_status: 'SUBMITTED',
})

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('/workers/W-1/documents')
Expand Down
28 changes: 25 additions & 3 deletions src/api/documents.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { apiFetch } from './client'

// fowoco/server DocumentController 기준 (#57 통합 문서함·파일 업로드·문서 준비도 구현).
export type DocumentType = 'PASSPORT_COPY' | 'ARC' | 'CONTRACT' | 'PERMIT'
export type SubmissionStatus = 'MISSING' | 'SUBMITTED' | 'VERIFIED'
export type DocumentType =
| 'PASSPORT_COPY'
| 'ARC'
| 'CONTRACT'
| 'PERMIT'
| 'EMPLOYMENT_EXTENSION_APPLICATION'
| 'INTEGRATED_APPLICATION'
| 'RESIDENCE_PROOF'
export type SubmissionStatus = 'DRAFT' | 'MISSING' | 'SUBMITTED' | 'VERIFIED'

export interface DocumentItemResponse {
worker_document_id: string
Expand All @@ -21,6 +28,15 @@ export interface DocumentPageResponse {
total_elements: number
}

export interface DocumentDetailResponse extends DocumentItemResponse {
task_id: string | null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인이용

version: number
file_name: string | null
file_mime_type: string | null
file_size: number | null
file_scan_status: 'NOT_SCANNED' | 'CLEAN' | 'INFECTED' | null
}

export interface FetchDocumentsParams {
workerId?: string
documentType?: DocumentType
Expand All @@ -43,6 +59,10 @@ export function fetchDocuments(params: FetchDocumentsParams = {}): Promise<Docum
return apiFetch<DocumentPageResponse>(`/documents?${query.toString()}`)
}

export function fetchDocument(documentId: string): Promise<DocumentDetailResponse> {
return apiFetch<DocumentDetailResponse>(`/documents/${encodeURIComponent(documentId)}`)
}

export interface DocumentReadinessResponse {
required: DocumentType[]
available: DocumentType[]
Expand All @@ -53,7 +73,9 @@ export interface DocumentReadinessResponse {

// Task 생성 시점 체크리스트 snapshot 기준이라 Workflow Catalog를 실시간으로 다시 읽지 않는다 (#176).
export function fetchDocumentReadiness(taskId: string): Promise<DocumentReadinessResponse> {
return apiFetch<DocumentReadinessResponse>(`/tasks/${encodeURIComponent(taskId)}/document-readiness`)
return apiFetch<DocumentReadinessResponse>(
`/tasks/${encodeURIComponent(taskId)}/document-readiness`,
)
}

export interface DocumentRequestUpsertBody {
Expand Down
11 changes: 11 additions & 0 deletions src/pages/DocumentDetailPage/DocumentDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@
color: var(--text-secondary);
}

.previewImage {
display: block;
width: min(100%, 760px);
max-height: 560px;
margin: 12px 0;
object-fit: contain;
background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
}

.relatedLinks {
display: flex;
flex-direction: column;
Expand Down
133 changes: 98 additions & 35 deletions src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DocumentItemResponse, DocumentPageResponse } from '../../api/documents'
import type { DocumentDetailResponse, DocumentItemResponse } from '../../api/documents'
import type { DocumentOcrRunResponse } from '../../api/documentOcr'
import { DocumentDetailPage } from './DocumentDetailPage'

Expand All @@ -19,6 +19,19 @@ function document(overrides: Partial<DocumentItemResponse>): DocumentItemRespons
}
}

function detail(overrides: Partial<DocumentDetailResponse>): DocumentDetailResponse {
return {
...document(overrides),
task_id: null,
version: 0,
file_name: null,
file_mime_type: null,
file_size: null,
file_scan_status: null,
...overrides,
}
}

const DOCUMENTS: DocumentItemResponse[] = [
document({ worker_document_id: 'D-1', worker_id: 'W-1', display_name: '응웬반A' }),
document({
Expand Down Expand Up @@ -98,10 +111,6 @@ function errorResponse(status: number, code: string, message: string) {
)
}

function pageResponse(items: DocumentItemResponse[]): DocumentPageResponse {
return { items, page: 0, size: 100, total_elements: items.length }
}

function renderPage(documentId: string) {
render(
<MemoryRouter initialEntries={[`/documents/${documentId}`]}>
Expand All @@ -126,7 +135,7 @@ afterEach(() => {

describe('DocumentDetailPage', () => {
it('renders the document type and worker name', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(detail(DOCUMENTS[0])))
renderPage('D-1')

expect(await screen.findByRole('heading', { name: '외국인등록증' })).toBeInTheDocument()
Expand All @@ -135,7 +144,7 @@ describe('DocumentDetailPage', () => {

it('navigates to the related worker', async () => {
const user = userEvent.setup()
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(detail(DOCUMENTS[0])))
renderPage('D-1')

await user.click(await screen.findByRole('button', { name: '응웬반A 정보 →' }))
Expand All @@ -145,16 +154,18 @@ describe('DocumentDetailPage', () => {

it('downloads the attached original file through the authenticated file API', async () => {
const user = userEvent.setup()
const fileDocuments = [
document({
worker_document_id: 'D-1',
display_name: '응웬반A',
submission_status: 'SUBMITTED',
file_id: 'file-1',
}),
]
const fileDocument = detail({
worker_document_id: 'D-1',
display_name: '응웬반A',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

하나만.?

submission_status: 'SUBMITTED',
file_id: 'file-1',
file_name: 'arc.pdf',
file_mime_type: 'application/pdf',
file_size: 3,
file_scan_status: 'NOT_SCANNED',
})
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments)))
.mockResolvedValueOnce(jsonResponse(fileDocument))
.mockResolvedValueOnce(
errorResponse(404, 'DOCUMENT_OCR_RUN_NOT_FOUND', 'OCR 실행 이력을 찾을 수 없습니다.'),
)
Expand All @@ -180,14 +191,14 @@ describe('DocumentDetailPage', () => {
})

it('shows an empty state when the documentId does not match any document', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
vi.mocked(fetch).mockResolvedValueOnce(errorResponse(404, 'DOCUMENT_NOT_FOUND', 'not found'))
renderPage('does-not-exist')

expect(await screen.findByText('서류를 찾을 수 없습니다')).toBeInTheDocument()
})

it('does not offer OCR review when no file is connected', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(detail(DOCUMENTS[0])))
renderPage('D-1')
await screen.findByRole('heading', { name: '외국인등록증' })

Expand All @@ -199,16 +210,18 @@ describe('DocumentDetailPage', () => {
it('runs OCR, polls until ready, submits only HR corrections, and marks review complete', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const fileDocuments = [
document({
worker_document_id: 'D-1',
display_name: '응웬반A',
submission_status: 'SUBMITTED',
file_id: 'file-1',
}),
]
const fileDocument = detail({
worker_document_id: 'D-1',
display_name: '응웬반A',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동일하게 하나만?

submission_status: 'SUBMITTED',
file_id: 'file-1',
file_name: 'arc.pdf',
file_mime_type: 'application/pdf',
file_size: 3,
file_scan_status: 'NOT_SCANNED',
})
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments)))
.mockResolvedValueOnce(jsonResponse(fileDocument))
.mockResolvedValueOnce(
errorResponse(404, 'DOCUMENT_OCR_RUN_NOT_FOUND', 'OCR 실행 이력을 찾을 수 없습니다.'),
)
Expand Down Expand Up @@ -252,12 +265,56 @@ describe('DocumentDetailPage', () => {
})
})

it('previews an authenticated image and revokes the object URL on unmount', async () => {
const imageDocument = detail({
worker_document_id: 'D-1',
submission_status: 'SUBMITTED',
file_id: 'file-1',
file_name: '외국인등록증_앞면.png',
file_mime_type: 'image/png',
file_size: 3,
file_scan_status: 'NOT_SCANNED',
})
vi.mocked(fetch).mockImplementation((url) => {
if (String(url).includes('/files/file-1/content')) {
return Promise.resolve(new Response(new Blob(['png'], { type: 'image/png' })))
}
if (String(url).includes('/ocr-runs/latest')) {
return Promise.resolve(errorResponse(404, 'DOCUMENT_OCR_RUN_NOT_FOUND', 'not found'))
}
return Promise.resolve(jsonResponse(imageDocument))
})
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview-1')
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const rendered = render(
<MemoryRouter initialEntries={['/documents/D-1']}>
<Routes>
<Route path="/documents/:documentId" element={<DocumentDetailPage />} />
</Routes>
</MemoryRouter>,
)

expect(
await screen.findByRole('img', { name: '외국인등록증 합성 원본 미리보기' }),
).toHaveAttribute('src', 'blob:preview-1')
expect(createObjectUrl).toHaveBeenCalledTimes(1)

rendered.unmount()
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:preview-1')
})

it('shows a preparing message when the OCR feature returns 503', async () => {
const fileDocuments = [
document({ worker_document_id: 'D-1', submission_status: 'SUBMITTED', file_id: 'file-1' }),
]
const fileDocument = detail({
worker_document_id: 'D-1',
submission_status: 'SUBMITTED',
file_id: 'file-1',
file_name: 'arc.pdf',
file_mime_type: 'application/pdf',
file_size: 3,
file_scan_status: 'NOT_SCANNED',
})
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments)))
.mockResolvedValueOnce(jsonResponse(fileDocument))
.mockResolvedValueOnce(
errorResponse(503, 'DOCUMENT_OCR_DISABLED', 'OCR 기능이 아직 활성화되지 않았습니다.'),
)
Expand All @@ -269,11 +326,17 @@ describe('DocumentDetailPage', () => {

it('requires a reason and submits no corrected fields when OCR is rejected', async () => {
const user = userEvent.setup()
const fileDocuments = [
document({ worker_document_id: 'D-1', submission_status: 'SUBMITTED', file_id: 'file-1' }),
]
const fileDocument = detail({
worker_document_id: 'D-1',
submission_status: 'SUBMITTED',
file_id: 'file-1',
file_name: 'arc.pdf',
file_mime_type: 'application/pdf',
file_size: 3,
file_scan_status: 'NOT_SCANNED',
})
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments)))
.mockResolvedValueOnce(jsonResponse(fileDocument))
.mockResolvedValueOnce(jsonResponse(OCR_READY))
.mockResolvedValueOnce(
jsonResponse(
Expand Down
Loading