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
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,
})
}
60 changes: 59 additions & 1 deletion src/api/documents.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 { fetchDocuments, patchWorkerDocument, registerWorkerDocument } from './documents'
import {
fetchDocumentRequestDraft,
fetchDocuments,
patchWorkerDocument,
registerWorkerDocument,
upsertDocumentRequestDraft,
} from './documents'

function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
Expand Down Expand Up @@ -87,3 +93,55 @@ describe('patchWorkerDocument', () => {
expect(init?.method).toBe('PATCH')
})
})

describe('document request draft', () => {
it('GETs the saved content and version for recovery', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({
draft_id: 'draft-1',
language: 'vi',
document_types: ['PASSPORT_COPY', 'CONTRACT'],
message: 'Vui lòng nộp hồ sơ.',
version: 3,
review_status: 'DRAFT',
updated_at: '2026-08-08T00:00:00Z',
}),
)

const draft = await fetchDocumentRequestDraft('T/1')

expect(draft.version).toBe(3)
const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('/tasks/T%2F1/document-request-draft')
expect(init?.method).toBeUndefined()
})

it('PUTs the restored expected version with the edited message', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({
draft_id: 'draft-1',
language: 'ko',
document_types: ['ARC'],
message: '외국인등록증을 제출해 주세요.',
version: 2,
review_status: 'DRAFT',
updated_at: '2026-08-08T00:00:00Z',
}),
)

await upsertDocumentRequestDraft('T/1', {
language: 'ko',
document_types: ['ARC'],
message: '외국인등록증을 제출해 주세요.',
expected_version: 1,
})

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('/tasks/T%2F1/document-request-draft')
expect(init?.method).toBe('PUT')
expect(JSON.parse(String(init?.body))).toMatchObject({
message: '외국인등록증을 제출해 주세요.',
expected_version: 1,
})
})
})
14 changes: 12 additions & 2 deletions src/api/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,22 @@ export interface DocumentRequestUpsertBody {

export interface DocumentRequestDraftResponse {
draft_id: string
language: string
document_types: DocumentType[]
message: string | null
version: number
review_status: string
updated_at: string
}

export function fetchDocumentRequestDraft(taskId: string): Promise<DocumentRequestDraftResponse> {
return apiFetch<DocumentRequestDraftResponse>(
`/tasks/${encodeURIComponent(taskId)}/document-request-draft`,
)
}

// 초안 저장만 하고 실제 발송·Worker Link 생성은 하지 않는다 (#176 스코프 아님).
// 최초 생성 시 expected_version은 관례상 0을 보낸다.
// 초안 저장만 하고 실제 발송·Worker Link 생성은 하지 않는다. 최초 생성 시
// expected_version은 0, 이후에는 fetchDocumentRequestDraft가 반환한 최신 version을 보낸다.
export function upsertDocumentRequestDraft(
taskId: string,
body: DocumentRequestUpsertBody,
Expand Down
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')),
}
}
3 changes: 3 additions & 0 deletions src/api/workerLinks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { apiFetch } from './client'
import type { DocumentType } from './documents'

export type WorkerResponseType =
| 'ACKNOWLEDGED'
Expand Down Expand Up @@ -34,7 +35,9 @@ export interface WorkerLinkDeliveryResponse {

export interface WorkerLinkViewResponse {
guidance: string
language: string
due_date: string | null
requested_document_types: DocumentType[]
allowed_responses: WorkerResponseType[]
}

Expand Down
Loading