From 2320f29661fc8400a846e28dd7167c2466b6057d Mon Sep 17 00:00:00 2001 From: hywznn Date: Thu, 13 Aug 2026 23:16:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(document):=20=ED=95=A9=EC=84=B1=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=9C=A0=ED=98=95=EA=B3=BC=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/documents.ts | 28 ++++++- .../DocumentDetailPage.module.css | 11 +++ .../DocumentDetailPage/DocumentDetailPage.tsx | 79 +++++++++++++++---- .../DocumentListPage/DocumentListPage.tsx | 43 +++++++--- .../overlays/RegisterDocumentModal.tsx | 43 ++++++++-- src/utils/documentLabels.ts | 5 ++ src/view-models/documentViewModel.ts | 20 ++++- 7 files changed, 194 insertions(+), 35 deletions(-) diff --git a/src/api/documents.ts b/src/api/documents.ts index 6a52985..51a0293 100644 --- a/src/api/documents.ts +++ b/src/api/documents.ts @@ -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 @@ -21,6 +28,15 @@ export interface DocumentPageResponse { total_elements: number } +export interface DocumentDetailResponse extends DocumentItemResponse { + task_id: string | null + 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 @@ -43,6 +59,10 @@ export function fetchDocuments(params: FetchDocumentsParams = {}): Promise(`/documents?${query.toString()}`) } +export function fetchDocument(documentId: string): Promise { + return apiFetch(`/documents/${encodeURIComponent(documentId)}`) +} + export interface DocumentReadinessResponse { required: DocumentType[] available: DocumentType[] @@ -53,7 +73,9 @@ export interface DocumentReadinessResponse { // Task 생성 시점 체크리스트 snapshot 기준이라 Workflow Catalog를 실시간으로 다시 읽지 않는다 (#176). export function fetchDocumentReadiness(taskId: string): Promise { - return apiFetch(`/tasks/${encodeURIComponent(taskId)}/document-readiness`) + return apiFetch( + `/tasks/${encodeURIComponent(taskId)}/document-readiness`, + ) } export interface DocumentRequestUpsertBody { diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.module.css b/src/pages/DocumentDetailPage/DocumentDetailPage.module.css index cd02cd6..355121f 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.module.css +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.module.css @@ -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; diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx index a05c01d..a9e2b0a 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' -import { fetchDocuments } from '../../api/documents' +import { fetchDocument } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' import { downloadFile } from '../../api/files' import { Button } from '../../components/ui/Button/Button' @@ -19,15 +19,41 @@ export function DocumentDetailPage() { const [downloading, setDownloading] = useState(false) const showToast = useToastStore((state) => state.showToast) - // GET /api/v1/documents/{id} 단건 조회가 없어서(#57 조사 결과), 목록을 통째로 받아 - // worker_document_id로 찾는다. const { status: fetchStatus, - data, + data: document, error, refetch, - } = useApiQuery(useCallback(() => fetchDocuments({ size: 100 }), [])) - const document = data?.items.find((item) => item.worker_document_id === documentId) ?? null + } = useApiQuery(useCallback(() => fetchDocument(documentId ?? ''), [documentId])) + const [previewUrl, setPreviewUrl] = useState(null) + const [previewError, setPreviewError] = useState(false) + + const fileId = document?.file_id ?? null + const fileMimeType = document?.file_mime_type ?? null + const canPreviewImage = Boolean(fileId && fileMimeType?.startsWith('image/')) + + useEffect(() => { + let cancelled = false + let objectUrl: string | null = null + setPreviewUrl(null) + setPreviewError(false) + if (!canPreviewImage || !fileId) return + + downloadFile(fileId) + .then((downloaded) => { + if (cancelled) return + objectUrl = URL.createObjectURL(downloaded.blob) + setPreviewUrl(objectUrl) + }) + .catch(() => { + if (!cancelled) setPreviewError(true) + }) + + return () => { + cancelled = true + if (objectUrl) URL.revokeObjectURL(objectUrl) + } + }, [canPreviewImage, fileId]) if (fetchStatus === 'loading') { return ( @@ -42,6 +68,18 @@ export function DocumentDetailPage() { ) } + if (fetchStatus === 'error' && error?.status === 404) { + return ( +
+ +
+ ) + } + if (fetchStatus === 'error') { return (
@@ -56,7 +94,7 @@ export function DocumentDetailPage() { ) } - if (!document) { + if (fetchStatus === 'empty' || !document) { return (

첨부 파일

-

{view.typeLabel}

+

{document.file_name ?? view.typeLabel}

+ {canPreviewImage && previewUrl && ( + {`${view.typeLabel} + )}

- {fileId - ? '사업장 권한을 확인한 뒤 원본 파일을 내려받습니다.' - : '이 문서에는 연결된 파일이 없습니다.'} + {!fileId && '이 문서에는 연결된 파일이 없습니다.'} + {fileId && + canPreviewImage && + !previewUrl && + !previewError && + '이미지 미리보기를 불러오는 중입니다.'} + {fileId && + canPreviewImage && + previewError && + '미리보기를 불러오지 못했습니다. 원본 다운로드를 이용해 주세요.'} + {fileId && !canPreviewImage && '사업장 권한을 확인한 뒤 원본 파일을 내려받습니다.'}

{fileId && (
- setActiveTab(id as TabId)} ariaLabel="서류 탭" /> + setActiveTab(id as TabId)} + ariaLabel="서류 탭" + />
-
@@ -166,7 +183,11 @@ export function DocumentListPage() { {status === 'empty' && (
- +
)} @@ -181,14 +202,18 @@ export function DocumentListPage() { {data && data.total_elements > data.items.length && (

- 전체 {data.total_elements}건 중 {data.items.length}건만 불러왔습니다. 찾는 서류가 안 보이면 - 검색어를 바꿔보세요. + 전체 {data.total_elements}건 중 {data.items.length}건만 불러왔습니다. 찾는 서류가 안 + 보이면 검색어를 바꿔보세요.

)} {visibleDocuments.length === 0 ? (
- +
) : (
diff --git a/src/pages/WorkerDetailPage/overlays/RegisterDocumentModal.tsx b/src/pages/WorkerDetailPage/overlays/RegisterDocumentModal.tsx index a72064e..6fe3087 100644 --- a/src/pages/WorkerDetailPage/overlays/RegisterDocumentModal.tsx +++ b/src/pages/WorkerDetailPage/overlays/RegisterDocumentModal.tsx @@ -1,5 +1,10 @@ import { useState, type ChangeEvent } from 'react' -import { registerWorkerDocument, patchWorkerDocument, type DocumentType, type SubmissionStatus } from '../../../api/documents' +import { + registerWorkerDocument, + patchWorkerDocument, + type DocumentType, + type SubmissionStatus, +} from '../../../api/documents' import { ApiError, getErrorMessage } from '../../../api/errors' import { uploadFile } from '../../../api/files' import { Button } from '../../../components/ui/Button/Button' @@ -7,11 +12,26 @@ import { Modal } from '../../../components/ui/Modal/Modal' import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL } from '../../../utils/documentLabels' import styles from './overlays.module.css' -const DOCUMENT_TYPES: DocumentType[] = ['PASSPORT_COPY', 'ARC', 'CONTRACT', 'PERMIT'] -const SUBMISSION_STATUSES: SubmissionStatus[] = ['MISSING', 'SUBMITTED', 'VERIFIED'] +const DOCUMENT_TYPES: DocumentType[] = [ + 'PASSPORT_COPY', + 'ARC', + 'CONTRACT', + 'PERMIT', + 'EMPLOYMENT_EXTENSION_APPLICATION', + 'INTEGRATED_APPLICATION', + 'RESIDENCE_PROOF', +] +const SUBMISSION_STATUSES: SubmissionStatus[] = ['DRAFT', 'MISSING', 'SUBMITTED', 'VERIFIED'] -// fowoco/server FileService 기준 첨부 파일 제약 (image/jpeg·png·webp, application/pdf, 최대 20MB). -const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'] +// fowoco/server FileService 기준 첨부 파일 제약 (image/jpeg·png·webp, PDF, HWP/HWPX, 최대 20MB). +const ALLOWED_FILE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'application/pdf', + 'application/x-hwp', + 'application/hwp+zip', +] const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024 export interface RegisterDocumentModalProps { @@ -21,7 +41,12 @@ export interface RegisterDocumentModalProps { onRegistered: () => void } -export function RegisterDocumentModal({ open, workerId, onClose, onRegistered }: RegisterDocumentModalProps) { +export function RegisterDocumentModal({ + open, + workerId, + onClose, + onRegistered, +}: RegisterDocumentModalProps) { const [documentType, setDocumentType] = useState('PASSPORT_COPY') const [submissionStatus, setSubmissionStatus] = useState('SUBMITTED') const [expiryDate, setExpiryDate] = useState('') @@ -49,7 +74,7 @@ export function RegisterDocumentModal({ open, workerId, onClose, onRegistered }: } if (!ALLOWED_FILE_TYPES.includes(chosen.type)) { setFile(null) - setFileError('지원하지 않는 파일 형식입니다 (JPEG·PNG·WEBP·PDF만 가능)') + setFileError('지원하지 않는 파일 형식입니다 (JPEG·PNG·WEBP·PDF·HWP·HWPX만 가능)') return } if (chosen.size > MAX_FILE_SIZE_BYTES) { @@ -82,7 +107,9 @@ export function RegisterDocumentModal({ open, workerId, onClose, onRegistered }: onRegistered() resetAndClose() } catch (error) { - setErrorMessage(error instanceof ApiError ? getErrorMessage(error) : '서류를 등록하지 못했습니다.') + setErrorMessage( + error instanceof ApiError ? getErrorMessage(error) : '서류를 등록하지 못했습니다.', + ) } finally { setSubmitting(false) } diff --git a/src/utils/documentLabels.ts b/src/utils/documentLabels.ts index 950da40..de6f509 100644 --- a/src/utils/documentLabels.ts +++ b/src/utils/documentLabels.ts @@ -6,15 +6,20 @@ export const DOCUMENT_TYPE_LABEL: Record = { ARC: '외국인등록증', CONTRACT: '근로계약서', PERMIT: '고용허가서', + EMPLOYMENT_EXTENSION_APPLICATION: '취업활동기간 연장신청서', + INTEGRATED_APPLICATION: '통합신청서', + RESIDENCE_PROOF: '체류지 입증자료', } export const SUBMISSION_STATUS_LABEL: Record = { + DRAFT: '초안', MISSING: '서류 없음', SUBMITTED: '승인 대기', VERIFIED: '완료', } export const SUBMISSION_STATUS_TONE: Record = { + DRAFT: 'neutral', MISSING: 'critical', SUBMITTED: 'warning', VERIFIED: 'success', diff --git a/src/view-models/documentViewModel.ts b/src/view-models/documentViewModel.ts index f0c6ec7..bd6cb9e 100644 --- a/src/view-models/documentViewModel.ts +++ b/src/view-models/documentViewModel.ts @@ -3,7 +3,8 @@ import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel' import { DOCUMENT_TYPE_LABEL } from '../utils/documentLabels' import { getOperationalDateViewModel, type OperationalDateViewModel } from './dateViewModel' -export type DocumentWorkflowState = 'NOT_SUBMITTED' | 'REVIEW_REQUIRED' | 'COMPLETED' | 'EXPIRED' +export type DocumentWorkflowState = + 'DRAFT' | 'NOT_SUBMITTED' | 'REVIEW_REQUIRED' | 'COMPLETED' | 'EXPIRED' export interface DocumentViewModel { id: string @@ -24,6 +25,23 @@ export function getDocumentViewModel(document: DocumentItemResponse): DocumentVi const expiry = getOperationalDateViewModel('DOCUMENT_EXPIRY', document.expiry_date) const fileAvailable = Boolean(document.file_id) + if (document.submission_status === 'DRAFT') { + return { + id: document.worker_document_id, + workerId: document.worker_id, + workerName: document.display_name ?? '이름 미등록', + typeLabel: DOCUMENT_TYPE_LABEL[document.document_type], + workflowState: 'DRAFT', + statusLabel: '초안', + statusTone: 'neutral', + expiry, + fileAvailable, + fileLabel: fileAvailable ? '파일 연결됨' : '파일 없음', + actionLabel: fileAvailable ? '보기' : '상세 확인', + reviewable: false, + } + } + if (document.submission_status === 'MISSING') { return { id: document.worker_document_id, From 53094d5147036f52095daff15aa8a9b1a7463c3e Mon Sep 17 00:00:00 2001 From: hywznn Date: Thu, 13 Aug 2026 23:16:40 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(document):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=99=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/documents.test.ts | 38 ++++- .../DocumentDetailPage.test.tsx | 133 +++++++++++++----- src/view-models/documentViewModel.test.ts | 44 ++++-- 3 files changed, 167 insertions(+), 48 deletions(-) diff --git a/src/api/documents.test.ts b/src/api/documents.test.ts index 2d2e2d7..7de6a51 100644 --- a/src/api/documents.test.ts +++ b/src/api/documents.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fetchDocumentRequestDraft, + fetchDocument, fetchDocuments, patchWorkerDocument, registerWorkerDocument, @@ -8,7 +9,10 @@ import { } 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, + headers: { 'Content-Type': 'application/json' }, + }) } beforeEach(() => { @@ -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() @@ -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') @@ -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( @@ -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') diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx index 2820fcb..470b702 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx @@ -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' @@ -19,6 +19,19 @@ function document(overrides: Partial): DocumentItemRespons } } +function detail(overrides: Partial): 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({ @@ -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( @@ -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() @@ -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 정보 →' })) @@ -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', + 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 실행 이력을 찾을 수 없습니다.'), ) @@ -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: '외국인등록증' }) @@ -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', + 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 실행 이력을 찾을 수 없습니다.'), ) @@ -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( + + + } /> + + , + ) + + 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 기능이 아직 활성화되지 않았습니다.'), ) @@ -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( diff --git a/src/view-models/documentViewModel.test.ts b/src/view-models/documentViewModel.test.ts index 0340081..28d59b1 100644 --- a/src/view-models/documentViewModel.test.ts +++ b/src/view-models/documentViewModel.test.ts @@ -4,8 +4,13 @@ import { getDocumentViewModel } from './documentViewModel' function document(overrides: Partial = {}): DocumentItemResponse { return { - worker_document_id: 'D-1', worker_id: 'W-1', display_name: '응웬반A', - document_type: 'PASSPORT_COPY', submission_status: 'MISSING', expiry_date: null, file_id: null, + worker_document_id: 'D-1', + worker_id: 'W-1', + display_name: '응웬반A', + document_type: 'PASSPORT_COPY', + submission_status: 'MISSING', + expiry_date: null, + file_id: null, ...overrides, } } @@ -20,12 +25,29 @@ describe('getDocumentViewModel', () => { }) }) + it('shows generated application files as drafts', () => { + expect( + getDocumentViewModel( + document({ + document_type: 'INTEGRATED_APPLICATION', + submission_status: 'DRAFT', + file_id: 'F-1', + }), + ), + ).toMatchObject({ workflowState: 'DRAFT', statusLabel: '초안', fileAvailable: true }) + }) + it('requires a real file before a submitted document can be reviewed', () => { expect(getDocumentViewModel(document({ submission_status: 'SUBMITTED' }))).toMatchObject({ - statusLabel: '파일 연결 확인', reviewable: false, + statusLabel: '파일 연결 확인', + reviewable: false, }) - expect(getDocumentViewModel(document({ submission_status: 'SUBMITTED', file_id: 'F-1' }))).toMatchObject({ - statusLabel: '승인 대기', actionLabel: '검토하기 →', reviewable: true, + expect( + getDocumentViewModel(document({ submission_status: 'SUBMITTED', file_id: 'F-1' })), + ).toMatchObject({ + statusLabel: '승인 대기', + actionLabel: '검토하기 →', + reviewable: true, }) }) @@ -38,8 +60,14 @@ describe('getDocumentViewModel', () => { String(expired.getDate()).padStart(2, '0'), ].join('-') - expect(getDocumentViewModel(document({ - submission_status: 'VERIFIED', expiry_date: expiryDate, file_id: 'F-1', - }))).toMatchObject({ workflowState: 'EXPIRED', statusLabel: '만료', actionLabel: '교체 요청' }) + expect( + getDocumentViewModel( + document({ + submission_status: 'VERIFIED', + expiry_date: expiryDate, + file_id: 'F-1', + }), + ), + ).toMatchObject({ workflowState: 'EXPIRED', statusLabel: '만료', actionLabel: '교체 요청' }) }) })