199 行
8.8 KiB
TypeScript
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 ()
|
|
})
|
|
|
|
})
|