Skip to content
Closed
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
20 changes: 16 additions & 4 deletions src/api/approvals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
approveTask,
buildTaskApprovalSnapshot,
completeTask,
recordExternalSubmission,
recordTaskEvidence,
rejectTask,
requestTaskApproval,
Expand Down Expand Up @@ -43,21 +44,32 @@ describe('approval APIs', () => {
})
})

it('uses the approval, decision, evidence and completion endpoints', async () => {
it('uses the approval, decision, external submission, evidence and completion endpoints', async () => {
vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ task_id: 'T-1' }, 201)))

await requestTaskApproval('T-1', buildTaskApprovalSnapshot(task()))
await approveTask('T-1', { expected_version: 8 })
await rejectTask('T-1', { expected_version: 8, reason: '마감일 확인 필요' })
await recordExternalSubmission('T-1', {
expected_version: 8,
destination: '수원출입국·외국인청',
safe_reference: '접수번호 1234',
})
await recordTaskEvidence('T-1', { evidence_type: 'RECEIPT', note: '접수번호 1234' })
await completeTask('T-1', 9)

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/tasks/T-1/approval-requests')
expect(String(calls[1][0])).toContain('/tasks/T-1/approve')
expect(String(calls[2][0])).toContain('/tasks/T-1/reject')
expect(String(calls[3][0])).toContain('/tasks/T-1/evidence')
expect(String(calls[4][0])).toContain('/tasks/T-1/complete')
expect(JSON.parse(calls[4][1]?.body as string)).toEqual({ expected_version: 9 })
expect(String(calls[3][0])).toContain('/tasks/T-1/external-submissions')
expect(JSON.parse(calls[3][1]?.body as string)).toEqual({
expected_version: 8,
destination: '수원출입국·외국인청',
safe_reference: '접수번호 1234',
})
expect(String(calls[4][0])).toContain('/tasks/T-1/evidence')
expect(String(calls[5][0])).toContain('/tasks/T-1/complete')
expect(JSON.parse(calls[5][1]?.body as string)).toEqual({ expected_version: 9 })
})
})
17 changes: 17 additions & 0 deletions src/api/approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface RecordTaskEvidenceBody {
recorded_at?: string
}

export interface RecordExternalSubmissionBody {
expected_version: number
destination: string
safe_reference: string
submitted_at?: string
}

export interface TaskActionResponse {
resource_id: string
task_id: string
Expand Down Expand Up @@ -105,6 +112,16 @@ export function recordTaskEvidence(
})
}

export function recordExternalSubmission(
taskId: string,
body: RecordExternalSubmissionBody,
): Promise<TaskActionResponse> {
return apiFetch<TaskActionResponse>(
`/tasks/${encodeURIComponent(taskId)}/external-submissions`,
{ method: 'POST', body: JSON.stringify(body) },
)
}

export function completeTask(taskId: string, expectedVersion: number): Promise<TaskActionResponse> {
return apiFetch<TaskActionResponse>(`/tasks/${encodeURIComponent(taskId)}/complete`, {
method: 'POST',
Expand Down
2 changes: 2 additions & 0 deletions src/api/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type AuditAction =
| 'OUTBOX_MANUAL_RETRY_REQUESTED'
| 'WORKER_LINK_RESPONSE_SUBMITTED'
| 'WORKER_LINK_RESPONSES_REVIEWED'
| 'WORKER_LINK_SENT'
| 'WORKER_LINK_ACCESSED'
| 'USER_AGREEMENTS_RECORDED'
| 'PASSWORD_RESET_REQUESTED'
Expand All @@ -40,6 +41,7 @@ export type AuditTargetType =
| 'DOCUMENT_REQUEST_DRAFT'
| 'AI_RUN'
| 'OUTBOX_EVENT'
| 'WORKER_LINK'
| 'USER_ACCOUNT'

export interface AuditEventResponse {
Expand Down
27 changes: 27 additions & 0 deletions src/api/cases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchCaseProjection, fetchCases } from './cases'

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

beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
afterEach(() => vi.unstubAllGlobals())

describe('case APIs', () => {
it('lists Cases with query parameters and fetches a projection by encoded ID', async () => {
vi.mocked(fetch).mockImplementation(() =>
Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 })),
)

await fetchCases({ keyword: '응웬 반', page: 1, size: 20 })
await fetchCaseProjection('C/1')

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/cases?keyword=%EC%9D%91%EC%9B%AC+%EB%B0%98&page=1&size=20')
expect(String(calls[1][0])).toContain('/cases/C%2F1/projection')
})
})
52 changes: 52 additions & 0 deletions src/api/workerLinks.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchTaskWorkerLinkDelivery,
fetchTaskWorkerResponses,
fetchWorkerLink,
issueWorkerLink,
markWorkerLinkSent,
markTaskWorkerResponsesRead,
resolveWorkerPortalUrl,
submitWorkerResponse,
uploadWorkerLinkDocument,
Expand All @@ -24,6 +28,37 @@ describe('worker link APIs', () => {
expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('issue-1')
})

it('gets the current delivery status and records manual delivery', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
jsonResponse({
worker_link_id: 'L-1',
link_status: 'ACTIVE',
delivery_status: 'NOT_SENT',
sent_at: null,
expires_at: '2026-08-07T00:00:00Z',
}),
)
.mockResolvedValueOnce(
jsonResponse({
worker_link_id: 'L-1',
link_status: 'ACTIVE',
delivery_status: 'SENT',
sent_at: '2026-08-05T00:00:00Z',
expires_at: '2026-08-07T00:00:00Z',
}),
)

await fetchTaskWorkerLinkDelivery('T/1')
await markWorkerLinkSent('L/1')

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-link')
expect(calls[0][1]?.method).toBeUndefined()
expect(String(calls[1][0])).toContain('/worker-links/L%2F1/sent')
expect(calls[1][1]?.method).toBe('POST')
})

it('views, uploads and submits through the public token endpoints', async () => {
vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ upload_id: 'U-1' }, 201)))

Expand All @@ -40,6 +75,23 @@ describe('worker link APIs', () => {
expect(String(calls[2][0])).toContain('/responses')
})

it('lists and marks HR worker responses as reviewed through authenticated endpoints', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
jsonResponse({ items: [], page: 1, size: 10, total_elements: 0, total_pages: 0 }),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }))

await fetchTaskWorkerResponses('T/1', 1, 10)
await markTaskWorkerResponsesRead('T/1')

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-responses?page=1&size=10')
expect(calls[0][1]?.method).toBeUndefined()
expect(String(calls[1][0])).toContain('/tasks/T%2F1/worker-responses/read')
expect(calls[1][1]?.method).toBe('POST')
})

it('turns the current backend raw-token response into a frontend route', () => {
expect(resolveWorkerPortalUrl('raw/token', 'https://fowoco.kr')).toBe(
'https://fowoco.kr/worker-portal/raw%2Ftoken',
Expand Down
70 changes: 69 additions & 1 deletion src/api/workerLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,22 @@ export interface WorkerLinkIssueBody {
}

export interface WorkerLinkIssueResponse {
worker_url: string
worker_link_id: string
worker_url: string | null
expires_at: string
delivery_status: WorkerLinkDeliveryStatus
sent_at: string | null
already_issued: boolean
}

export type WorkerLinkStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED'
export type WorkerLinkDeliveryStatus = 'NOT_SENT' | 'SENT'

export interface WorkerLinkDeliveryResponse {
worker_link_id: string
link_status: WorkerLinkStatus
delivery_status: WorkerLinkDeliveryStatus
sent_at: string | null
expires_at: string
}

Expand Down Expand Up @@ -42,6 +57,26 @@ export interface WorkerResponseSubmitResponse {
received_at: string
}

export type WorkerConversationStatus = 'WAITING_WORKER' | 'NEEDS_FOLLOWUP' | 'REOPENED'

export interface WorkerResponseItemResponse {
response_id: string
response_type: WorkerResponseType
message: string | null
upload_ids: string[]
conversation_status: WorkerConversationStatus
unread: boolean
received_at: string
}

export interface WorkerResponsePageResponse {
items: WorkerResponseItemResponse[]
page: number
size: number
total_elements: number
total_pages: number
}

export function issueWorkerLink(
taskId: string,
body: WorkerLinkIssueBody,
Expand All @@ -54,6 +89,21 @@ export function issueWorkerLink(
})
}

export function fetchTaskWorkerLinkDelivery(
taskId: string,
): Promise<WorkerLinkDeliveryResponse> {
return apiFetch<WorkerLinkDeliveryResponse>(
`/tasks/${encodeURIComponent(taskId)}/worker-link`,
)
}

export function markWorkerLinkSent(workerLinkId: string): Promise<WorkerLinkDeliveryResponse> {
return apiFetch<WorkerLinkDeliveryResponse>(
`/worker-links/${encodeURIComponent(workerLinkId)}/sent`,
{ method: 'POST' },
)
}

export function fetchWorkerLink(token: string): Promise<WorkerLinkViewResponse> {
return apiFetch<WorkerLinkViewResponse>(`/public/worker-links/${encodeURIComponent(token)}`, {
skipAuthRetry: true,
Expand Down Expand Up @@ -96,6 +146,24 @@ export function submitWorkerResponse(
)
}

export function fetchTaskWorkerResponses(
taskId: string,
page = 0,
size = 20,
): Promise<WorkerResponsePageResponse> {
const query = new URLSearchParams({ page: String(page), size: String(size) })
return apiFetch<WorkerResponsePageResponse>(
`/tasks/${encodeURIComponent(taskId)}/worker-responses?${query.toString()}`,
)
}

export function markTaskWorkerResponsesRead(taskId: string): Promise<void> {
return apiFetch<void>(
`/tasks/${encodeURIComponent(taskId)}/worker-responses/read`,
{ method: 'POST' },
)
}

export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string {
if (/^https?:\/\//i.test(workerUrlOrToken)) return workerUrlOrToken
if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString()
Expand Down
Loading