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
133 changes: 133 additions & 0 deletions src/api/workerImports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
commitWorkerImport,
createWorkerImport,
fetchWorkerImport,
patchWorkerImportRows,
retryWorkerImport,
saveWorkerImportMappings,
validateWorkerImport,
type WorkerImportResponse,
} from './workerImports'

const IMPORT_RESPONSE: WorkerImportResponse = {
import_id: 'import-1',
source_file_id: 'file-1',
status: 'UPLOADED',
source_headers: ['이름', '국적'],
mappings: {},
total_rows: 1,
valid_rows: 0,
invalid_rows: 0,
excluded_rows: 0,
committed_rows: 0,
source_file_expires_at: '2026-08-10T00:00:00Z',
version: 1,
rows: [],
page: 0,
size: 100,
}

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

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse()))
})

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

describe('worker import API', () => {
it('creates an import with multipart data and an idempotency key', async () => {
const file = new File(['name,country'], 'workers.csv', { type: 'text/csv' })

await createWorkerImport(file, 'import-create-1')

const [url, init] = vi.mocked(fetch).mock.calls[0]
const headers = new Headers(init?.headers)
expect(String(url)).toContain('/imports')
expect(init?.method).toBe('POST')
expect(init?.body).toBeInstanceOf(FormData)
expect((init?.body as FormData).get('file')).toBe(file)
expect(headers.get('Idempotency-Key')).toBe('import-create-1')
expect(headers.has('Content-Type')).toBe(false)
})

it('gets an import with bounded page parameters', async () => {
await fetchWorkerImport('import/1', { page: 2, size: 50 })

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(String(url)).toContain('/imports/import%2F1?page=2&size=50')
expect(init?.method).toBeUndefined()
})

it('saves column mappings with the latest version', async () => {
await saveWorkerImportMappings('import-1', {
expected_version: 1,
mappings: { 이름: 'display_name', 국적: 'nationality_code' },
})

const [, init] = vi.mocked(fetch).mock.calls[0]
expect(init?.method).toBe('PUT')
expect(JSON.parse(init?.body as string)).toEqual({
expected_version: 1,
mappings: { 이름: 'display_name', 국적: 'nationality_code' },
})
})

it('validates an import with the latest version', async () => {
await validateWorkerImport('import-1', 2)

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(String(url)).toContain('/imports/import-1/validate')
expect(init?.method).toBe('POST')
expect(JSON.parse(init?.body as string)).toEqual({ expected_version: 2 })
})

it('patches corrected or excluded rows with the latest version', async () => {
await patchWorkerImportRows('import-1', {
expected_version: 3,
rows: [{ row_number: 2, excluded: false, values: { display_name: '응웬반A' } }],
})

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(String(url)).toContain('/imports/import-1/rows')
expect(init?.method).toBe('PATCH')
expect(JSON.parse(init?.body as string)).toEqual({
expected_version: 3,
rows: [{ row_number: 2, excluded: false, values: { display_name: '응웬반A' } }],
})
})

it('commits selected valid rows with an idempotency key and latest version', async () => {
await commitWorkerImport(
'import-1',
{ expected_version: 4, selected_row_numbers: [2, 4] },
'import-commit-1',
)

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(String(url)).toContain('/imports/import-1/commit')
expect(init?.method).toBe('POST')
expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('import-commit-1')
expect(JSON.parse(init?.body as string)).toEqual({
expected_version: 4,
selected_row_numbers: [2, 4],
})
})

it('retries validation with the latest version', async () => {
await retryWorkerImport('import-1', 5)

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(String(url)).toContain('/imports/import-1/retry')
expect(init?.method).toBe('POST')
expect(JSON.parse(init?.body as string)).toEqual({ expected_version: 5 })
})
})
158 changes: 158 additions & 0 deletions src/api/workerImports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { apiFetch } from './client'

export type WorkerImportStatus = 'UPLOADED' | 'MAPPED' | 'REVIEW_REQUIRED' | 'READY' | 'COMMITTED'

export type WorkerImportRowStatus = 'PENDING' | 'VALID' | 'INVALID' | 'EXCLUDED' | 'COMMITTED'

export type WorkerImportField =
| 'display_name'
| 'nationality_code'
| 'preferred_language'
| 'visa_type'
| 'stay_expiry_date'
| 'contract_start_date'
| 'contract_end_date'
| 'employment_permit_end_date'
| 'employment_activity_end_date'

export interface WorkerImportValidationError {
field: string
code: string
message: string
}

export interface WorkerImportRowResponse {
row_number: number
source_values: Record<string, string>
override_values: Record<string, string>
normalized_values: Record<string, string>
status: WorkerImportRowStatus
errors: WorkerImportValidationError[]
worker_id: string | null
version: number
}

export interface WorkerImportResponse {
import_id: string
source_file_id: string
status: WorkerImportStatus
source_headers: string[]
mappings: Record<string, WorkerImportField>
total_rows: number
valid_rows: number
invalid_rows: number
excluded_rows: number
committed_rows: number
source_file_expires_at: string
version: number
rows: WorkerImportRowResponse[]
page: number
size: number
}

export interface FetchWorkerImportParams {
page?: number
size?: number
}

export interface WorkerImportMappingBody {
expected_version: number
mappings: Record<string, WorkerImportField>
}

export interface WorkerImportRowPatch {
row_number: number
excluded?: boolean
values: Record<string, string>
}

export interface WorkerImportRowsBody {
expected_version: number
rows: WorkerImportRowPatch[]
}

export interface WorkerImportCommitBody {
expected_version: number
selected_row_numbers: number[]
}

function importPath(importId: string, suffix = '') {
return `/imports/${encodeURIComponent(importId)}${suffix}`
}

export function createWorkerImport(
file: File,
idempotencyKey: string,
): Promise<WorkerImportResponse> {
const body = new FormData()
body.set('file', file)

return apiFetch<WorkerImportResponse>('/imports', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body,
})
}

export function fetchWorkerImport(
importId: string,
params: FetchWorkerImportParams = {},
): Promise<WorkerImportResponse> {
const query = new URLSearchParams({
page: String(params.page ?? 0),
size: String(params.size ?? 100),
})
return apiFetch<WorkerImportResponse>(`${importPath(importId)}?${query.toString()}`)
}

export function saveWorkerImportMappings(
importId: string,
body: WorkerImportMappingBody,
): Promise<WorkerImportResponse> {
return apiFetch<WorkerImportResponse>(importPath(importId, '/mappings'), {
method: 'PUT',
body: JSON.stringify(body),
})
}

export function validateWorkerImport(
importId: string,
expectedVersion: number,
): Promise<WorkerImportResponse> {
return apiFetch<WorkerImportResponse>(importPath(importId, '/validate'), {
method: 'POST',
body: JSON.stringify({ expected_version: expectedVersion }),
})
}

export function patchWorkerImportRows(
importId: string,
body: WorkerImportRowsBody,
): Promise<WorkerImportResponse> {
return apiFetch<WorkerImportResponse>(importPath(importId, '/rows'), {
method: 'PATCH',
body: JSON.stringify(body),
})
}

export function commitWorkerImport(
importId: string,
body: WorkerImportCommitBody,
idempotencyKey: string,
): Promise<WorkerImportResponse> {
return apiFetch<WorkerImportResponse>(importPath(importId, '/commit'), {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(body),
})
}

export function retryWorkerImport(
importId: string,
expectedVersion: number,
): Promise<WorkerImportResponse> {
return apiFetch<WorkerImportResponse>(importPath(importId, '/retry'), {
method: 'POST',
body: JSON.stringify({ expected_version: expectedVersion }),
})
}