Skip to content
Draft
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
22 changes: 21 additions & 1 deletion src/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { apiFetch, getAccessToken, setAccessToken, setAuthExpiredHandler } from './client'
import {
apiFetch,
apiFetchBlob,
getAccessToken,
setAccessToken,
setAuthExpiredHandler,
} from './client'
import { ApiError } from './errors'

function jsonResponse(body: unknown, init: ResponseInit = {}) {
Expand Down Expand Up @@ -52,6 +58,20 @@ describe('apiFetch', () => {
expect(headers.get('Authorization')).toBe('Bearer token-abc')
})

it('returns a binary response without JSON parsing and requests any content type', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(new Blob(['file-content']), {
headers: { 'Content-Type': 'application/pdf' },
}),
)

const response = await apiFetchBlob('/files/file-1/content')

expect((await response.blob()).size).toBeGreaterThan(0)
const [, init] = vi.mocked(fetch).mock.calls[0]
expect(new Headers(init?.headers).get('Accept')).toBe('*/*')
})

it('returns undefined for 204 No Content', async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 204 }))

Expand Down
22 changes: 20 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function parseErrorBody(response: Response, path: string): Promise<ApiErro

async function rawFetch(path: string, init: RequestInit): Promise<Response> {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
if (init.body && !headers.has('Content-Type') && !(init.body instanceof FormData)) {
headers.set('Content-Type', 'application/json')
}
Expand All @@ -90,7 +90,10 @@ export interface ApiFetchOptions extends RequestInit {
skipAuthRetry?: boolean
}

export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
async function requestWithAuth(
path: string,
options: ApiFetchOptions = {},
): Promise<Response> {
const { skipAuthRetry, ...init } = options

let response: Response
Expand Down Expand Up @@ -119,6 +122,12 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
throw await parseErrorBody(response, path)
}

return response
}

export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const response = await requestWithAuth(path, options)

// 204는 물론이고, password-reset-requests처럼 body 없이 202만 내려주는 응답도 있어
// status 코드로만 분기하지 않고 실제 body가 비어있는지로 판단한다.
const text = await response.text()
Expand All @@ -128,3 +137,12 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):

return JSON.parse(text) as T
}

export function apiFetchBlob(path: string, options: ApiFetchOptions = {}): Promise<Response> {
const headers = new Headers(options.headers)
headers.set('Accept', '*/*')
return requestWithAuth(path, {
...options,
headers,
})
}
21 changes: 20 additions & 1 deletion src/api/files.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { uploadFile } from './files'
import { downloadFile, uploadFile } from './files'

function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), { status: 201, headers: { 'Content-Type': 'application/json' } })
Expand Down Expand Up @@ -30,3 +30,22 @@ describe('uploadFile', () => {
expect(headers.has('Content-Type')).toBe(false)
})
})

describe('downloadFile', () => {
it('downloads encoded file content and reads the server filename', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(new Blob(['pdf']), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': "attachment; filename*=UTF-8''passport%20copy.pdf",
},
}),
)

const result = await downloadFile('file/1')

expect(result.file_name).toBe('passport copy.pdf')
expect(result.blob.size).toBeGreaterThan(0)
expect(String(vi.mocked(fetch).mock.calls[0][0])).toContain('/files/file%2F1/content')
})
})
34 changes: 31 additions & 3 deletions src/api/files.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { apiFetch } from './client'
import { apiFetch, apiFetchBlob } from './client'

// fowoco/server FileController 기준 — 분석·증빙·근로자 제출용 공통 파일 업로드.
// 허용 형식은 image/jpeg·png·webp, application/pdf, 최대 20MB로 서버에 고정돼 있다.
// fowoco/server FileController/FileService 기준 — 분석·증빙·근로자 제출용 공통 파일 업로드.
// JPEG·PNG·WEBP·PDF와 유효한 HWP/HWPX 시그니처를 허용하며 최대 크기는 20MB다.
export type ScanStatus = 'NOT_SCANNED' | 'CLEAN' | 'INFECTED'

export interface FileUploadResponse {
Expand All @@ -27,3 +27,31 @@ export function uploadFile(params: UploadFileParams): Promise<FileUploadResponse
if (params.workerId) formData.append('workerId', params.workerId)
return apiFetch<FileUploadResponse>('/files', { method: 'POST', body: formData })
}

export interface FileDownloadResponse {
blob: Blob
file_name: string | null
}

function getDownloadFileName(contentDisposition: string | null): string | null {
if (!contentDisposition) return null

const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try {
return decodeURIComponent(encoded)
} catch {
return encoded
}
}

return contentDisposition.match(/filename="([^"]+)"/i)?.[1] ?? null
}

export async function downloadFile(fileId: string): Promise<FileDownloadResponse> {
const response = await apiFetchBlob(`/files/${encodeURIComponent(fileId)}/content`)
return {
blob: await response.blob(),
file_name: getDownloadFileName(response.headers.get('Content-Disposition')),
}
}
1 change: 1 addition & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@
.documentRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: var(--fowoco-spacing-16) var(--fowoco-spacing-24);
background: var(--surface-default);
Expand Down
12 changes: 12 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ beforeEach(() => {
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

Expand Down Expand Up @@ -309,6 +310,11 @@ describe('CaseDetailPage', () => {

it('switches to the document tab and shows real document content', async () => {
const user = userEvent.setup()
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:file-1')
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickAnchor = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
mockTaskAndActivities({}, [], {}, [
{
worker_document_id: 'doc-1',
Expand All @@ -327,6 +333,12 @@ describe('CaseDetailPage', () => {

expect(await screen.findByText('여권 사본')).toBeInTheDocument()
expect(screen.getByText('완료')).toBeInTheDocument()

await user.click(screen.getByRole('button', { name: '다운로드' }))

expect(createObjectUrl).toHaveBeenCalledTimes(1)
expect(clickAnchor).toHaveBeenCalledTimes(1)
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:file-1')
})

it('shows the document-readiness gate and saves a document request draft when documents are missing', async () => {
Expand Down
31 changes: 31 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
upsertDocumentRequestDraft,
} from '../../api/documents'
import { ApiError, getErrorMessage } from '../../api/errors'
import { downloadFile } from '../../api/files'
import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks'
import { issueWorkerLink, resolveWorkerPortalUrl } from '../../api/workerLinks'
import { AgentSourceLabel } from '../../components/ui/AgentSourceLabel/AgentSourceLabel'
Expand All @@ -30,6 +31,7 @@ import { useApiQuery } from '../../hooks/useApiQuery'
import { useToastStore } from '../../store/toastStore'
import { ACTOR_TYPE_TO_AGENT_SOURCE, AUDIT_ACTION_LABEL } from '../../utils/auditLabels'
import { formatEventTime } from '../../utils/datetime'
import { saveBlobAsFile } from '../../utils/fileDownload'
import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus'
import { getDocumentViewModel } from '../../view-models/documentViewModel'
import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
Expand Down Expand Up @@ -78,6 +80,7 @@ export function CaseDetailPage() {
const [lastReissue, setLastReissue] = useState<ReissueSubmission | null>(null)
const [issuedWorkerUrl, setIssuedWorkerUrl] = useState<string | null>(null)
const [issuedExpiresAt, setIssuedExpiresAt] = useState<string | null>(null)
const [downloadingFileId, setDownloadingFileId] = useState<string | null>(null)
const moreMenuRef = useRef<HTMLDivElement>(null)
const showToast = useToastStore((state) => state.showToast)

Expand Down Expand Up @@ -273,6 +276,23 @@ export function CaseDetailPage() {
}
}

async function handleDownloadDocument(fileId: string, fallbackName: string) {
if (downloadingFileId) return
setDownloadingFileId(fileId)
try {
const downloaded = await downloadFile(fileId)
saveBlobAsFile(downloaded.blob, downloaded.file_name ?? fallbackName)
} catch (error) {
showToast(
error instanceof ApiError
? getErrorMessage(error)
: '첨부 파일을 내려받지 못했습니다.',
)
} finally {
setDownloadingFileId(null)
}
}

function handleOpenLinkReissue() {
setLinkOverlay('reissue')
}
Expand Down Expand Up @@ -682,11 +702,22 @@ export function CaseDetailPage() {
<div className={styles.documentList}>
{documents.map((document) => {
const view = getDocumentViewModel(document)
const fileId = document.file_id
return (
<div key={view.id} className={styles.documentRow}>
<span className={styles.documentName}>{view.typeLabel}</span>
<StatusLabel tone={view.statusTone}>{view.statusLabel}</StatusLabel>
<span className={styles.documentUpdatedAt}>{view.expiry.display}</span>
{fileId && (
<button
type="button"
className={styles.contextLink}
disabled={downloadingFileId !== null}
onClick={() => handleDownloadDocument(fileId, view.typeLabel)}
>
{downloadingFileId === fileId ? '다운로드 중…' : '다운로드'}
</button>
)}
</div>
)
})}
Expand Down
35 changes: 34 additions & 1 deletion src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function renderPage(documentId: string) {
<Routes>
<Route path="/documents/:documentId" element={<DocumentDetailPage />} />
<Route path="/documents" element={<p>서류 목록</p>} />
<Route path="/workers/:workerId" element={<p>근로자 상세</p>} />
<Route path="/workers/:workerId/detail" element={<p>근로자 상세</p>} />
</Routes>
</MemoryRouter>,
)
Expand All @@ -55,6 +55,7 @@ beforeEach(() => {
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

Expand All @@ -77,6 +78,38 @@ describe('DocumentDetailPage', () => {
expect(await screen.findByText('근로자 상세')).toBeInTheDocument()
})

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',
}),
]
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments)))
.mockResolvedValueOnce(
new Response(new Blob(['pdf']), {
headers: { 'Content-Disposition': 'attachment; filename="arc.pdf"' },
}),
)
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:file-1')
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickAnchor = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
renderPage('D-1')

await user.click(await screen.findByRole('button', { name: '원본 다운로드' }))

expect(createObjectUrl).toHaveBeenCalledTimes(1)
expect(clickAnchor).toHaveBeenCalledTimes(1)
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:file-1')
expect(String(vi.mocked(fetch).mock.calls[1][0])).toContain('/files/file-1/content')
})

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