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 (), ...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) => 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 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 () }) })