ファイル
btrc-hub/frontend/src/components/counts/CountsExportProgress.test.tsx
T
2026-10-31 13:50:10 +09:00

199 行
8.8 KiB
TypeScript

import { act, cleanup, screen } from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CountsExportProgress from '@/components/counts/CountsExportProgress'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (), apiGet: vi.fn (), apiDelete: vi.fn (), startAPIDownload: vi.fn () }))
vi.mock ('@/lib/api', async original => ({
...await original<typeof import ('@/lib/api')> (), ...api }))
const ready = { status: 'ready', processed: 5, total: 5, token: 'file' }
const receipt = { downloadAttempt: 'attempt', downloadStatus: 'pending', token: 'file' }
const controls = () => ({ close: vi.fn (), setActions: vi.fn (), confirm: vi.fn () })
const flush = async (milliseconds = 0) => act (async () => {
await vi.advanceTimersByTimeAsync (milliseconds)
})
beforeEach (() => {
vi.useFakeTimers ()
Object.values (api).forEach (mock => mock.mockReset ())
api.apiPost.mockImplementation (async (path: string) =>
path.endsWith ('/download') ? receipt : { job: 'job' })
api.apiGet.mockResolvedValue (ready)
api.apiDelete.mockResolvedValue ({ status: 'cancelled', processed: 5, total: 5 })
api.startAPIDownload.mockReturnValue (vi.fn ())
})
afterEach (() => { cleanup (); vi.useRealTimers () })
const renderProgress = (format = 'csv') => {
const actions = controls ()
const handle = { cancel: async () => false }
const element = (next = actions) => <StrictMode>
<CountsExportProgress payload={{ resource: 'posts', format }} locale="ja"
handle={handle} controls={next}/>
</StrictMode>
return { ...renderWithProviders (element ()), actions, handle, element }
}
describe ('Counts native download acknowledgement', () => {
it.each (['csv', 'json']) ('closes %s after the matching GET acknowledgement', async format => {
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValue ({ ...ready,
downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress (format)
await flush ()
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
expect (view.actions.close).not.toHaveBeenCalled ()
const query = api.startAPIDownload.mock.calls[0][3] as URLSearchParams
expect (Object.fromEntries (query)).toEqual ({ job: 'job', attempt: 'attempt' })
const next = controls ()
view.rerender (view.element (next))
await flush (800)
expect (next.close).toHaveBeenCalledTimes (1)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('times out independently of pending status requests', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockImplementation (() => new Promise (() => {}))
const view = renderProgress ()
await flush ()
await flush (30_000)
expect (screen.getByRole ('alert')).toHaveTextContent ('タイムアウト')
expect (view.actions.close).not.toHaveBeenCalled ()
expect (api.startAPIDownload.mock.results[0].value).toHaveBeenCalled ()
})
it ('retains the modal after acceptance failure without repeated POSTs', async () => {
api.apiPost.mockResolvedValueOnce ({ job: 'job' })
.mockRejectedValueOnce (new Error ('offline'))
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).not.toHaveBeenCalled ()
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('retries a temporary status transport failure without a second native GET', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValueOnce (new Error ('offline'))
.mockResolvedValue ({ ...ready, downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress ()
await flush ()
await flush (2800)
expect (view.actions.close).toHaveBeenCalledTimes (1)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('cancels an acceptance in flight and never starts the resulting receipt', async () => {
let resolve!: (value: typeof receipt) => void
api.apiPost.mockResolvedValueOnce ({ job: 'job' }).mockImplementationOnce (
() => new Promise (complete => { resolve = complete }))
const view = renderProgress ()
await flush ()
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
await act (async () => { resolve (receipt) })
await flush (1000)
expect (api.startAPIDownload).not.toHaveBeenCalled ()
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('releases an unmounted iframe and ignores late acknowledgements', async () => {
const view = renderProgress ()
await flush ()
const release = api.startAPIDownload.mock.results[0].value
view.unmount ()
await flush ()
expect (release).toHaveBeenCalledTimes (1)
await flush (40_000)
expect (view.actions.close).not.toHaveBeenCalled ()
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('keeps authentication expiry and iframe errors visible', async () => {
const view = renderProgress ()
await flush ()
act (() => { api.startAPIDownload.mock.calls[0][2] () })
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('shows the worker-specific reason instead of a download-success message', async () => {
api.apiPost.mockRejectedValueOnce ({ isAxiosError: true,
response: { status: 503, data: { error: 'export_worker_unavailable' } } })
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('ワーカーが稼働していません')
expect (view.actions.close).not.toHaveBeenCalled ()
expect (await view.handle.cancel ()).toBe (true)
})
it ('shows revoked authority and does not close after a successful acceptance', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockRejectedValue ({ isAxiosError: true,
response: { status: 404 } })
const view = renderProgress ()
await flush ()
await flush (800)
expect (screen.getByRole ('alert')).toHaveTextContent ('認可が失われたか')
expect (view.actions.close).not.toHaveBeenCalled ()
})
it ('stops after cancellation failure and permits an explicit cancellation retry', async () => {
const view = renderProgress ()
await flush ()
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
await act (async () => { expect (await view.handle.cancel ()).toBe (false) })
const requests = api.apiGet.mock.calls.length
await flush (40_000)
expect (api.apiGet).toHaveBeenCalledTimes (requests)
expect (screen.getByRole ('alert')).toBeInTheDocument ()
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
expect (api.apiDelete).toHaveBeenCalledTimes (2)
expect (api.startAPIDownload).toHaveBeenCalledTimes (1)
})
it ('ignores a different attempt even when it reports started', async () => {
api.apiGet.mockResolvedValueOnce (ready).mockResolvedValueOnce ({ ...ready,
downloadAttempt: 'another', downloadStatus: 'started' }).mockResolvedValue ({ ...ready,
downloadAttempt: 'attempt', downloadStatus: 'started' })
const view = renderProgress ()
await flush ()
await flush (800)
expect (view.actions.close).not.toHaveBeenCalled ()
await flush (800)
expect (view.actions.close).toHaveBeenCalledTimes (1)
})
it ('keeps the created job available for cancellation retry during initial registration', async () => {
let resolve!: (value: { job: string }) => void
api.apiPost.mockImplementationOnce (() => new Promise (complete => { resolve = complete }))
api.apiDelete.mockRejectedValueOnce (new Error ('offline'))
const view = renderProgress ()
await flush ()
let pending!: Promise<boolean>
act (() => { pending = view.handle.cancel () })
await act (async () => {
resolve ({ job: 'created-during-cancel' })
expect (await pending).toBe (false)
})
await act (async () => { expect (await view.handle.cancel ()).toBe (true) })
expect (api.apiDelete.mock.calls.map (call => call[0])).toEqual ([
'/counts/export/jobs/created-during-cancel', '/counts/export/jobs/created-during-cancel'])
expect (api.startAPIDownload).not.toHaveBeenCalled ()
})
it ('stops immediately if native download navigation cannot be created', async () => {
api.startAPIDownload.mockImplementationOnce (() => { throw new Error ('navigation blocked') })
const view = renderProgress ()
await flush ()
expect (screen.getByRole ('alert')).toHaveTextContent ('開始できません')
await flush (40_000)
expect (api.apiGet).toHaveBeenCalledTimes (1)
expect (view.actions.close).not.toHaveBeenCalled ()
})
})