import { afterEach, describe, expect, it, vi } from 'vitest'; import { createProvisioningRefresh, type ProvisioningRefreshState } from '../../src/lib/ui/provisioning-refresh.js'; type Detail = { id: string; revision: number }; const detail = (id: string, revision = 1): Detail => ({ id, revision }); const response = (id: string, revision = 1) => Response.json(detail(id, revision)); const controllers: { dispose: () => void }[] = []; afterEach(() => { controllers.splice(0).forEach(controller => controller.dispose()); vi.useRealTimers(); }); function setup(fetcher: typeof fetch) { let state!: ProvisioningRefreshState; const onChange = vi.fn(value => { state = value; }); const controller = createProvisioningRefresh({ fetcher, onChange, timeoutMs: 100, validate: (value, id): value is Detail => !!value && typeof value === 'object' && (value as Detail).id === id, now: () => '2026-09-28T12:00:00Z' }); controllers.push(controller); return { ...controller, onChange, state: () => state }; } function deferred() { let resolve!: (value: Response) => void; let reject!: (error: Error) => void; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } describe('provisioning detail refresh ownership', () => { it('requests a rebuild retry and refreshes the original failed operation', async () => { const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true, deploymentId: 'recovery-a' })).mockResolvedValueOnce(response('a', 2)); const page = setup(fetcher); page.setOperation('a', detail('a')); await page.act('retry-rebuild', 'Follow the linked recovery operation.'); expect(fetcher).toHaveBeenNthCalledWith(1, '/api/provisioning/a/retry-rebuild', expect.objectContaining({ method: 'POST' })); expect(fetcher).toHaveBeenNthCalledWith(2, '/api/provisioning/a?events=recent', expect.anything()); expect(page.state()).toMatchObject({ id: 'a', detail: detail('a', 2), notice: 'Follow the linked recovery operation.', busy: null }); }); it('sends explicit override confirmation and refreshes the operation after acceptance', async () => { const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true })).mockResolvedValueOnce(response('a', 2)); const page = setup(fetcher); page.setOperation('a', detail('a')); await page.act('override', 'Override enabled.', { confirmed: true }); expect(fetcher).toHaveBeenNthCalledWith(1, '/api/provisioning/a/override', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"confirmed":true}' })); expect(page.state()).toMatchObject({ detail: detail('a', 2), notice: 'Override enabled.', busy: null }); }); it('uses the server snapshot without another initial request and coalesces background reads', async () => { const pending = deferred(); const fetcher = vi.fn(() => pending.promise); const page = setup(fetcher); page.setOperation('a', detail('a'), '2026-09-28T11:59:00Z'); expect(fetcher).not.toHaveBeenCalled(); const first = page.refresh(); const second = page.refresh(); expect(fetcher).toHaveBeenCalledTimes(1); pending.resolve(response('a', 2)); await Promise.all([first, second]); expect(page.state()).toMatchObject({ detail: detail('a', 2), refreshing: false, refreshedAt: '2026-09-28T12:00:00Z' }); }); it('ignores obsolete success and failure across A → B → A, even when transport ignores abort', async () => { const oldA = deferred(), oldB = deferred(), newA = deferred(); const fetcher = vi.fn().mockReturnValueOnce(oldA.promise).mockReturnValueOnce(oldB.promise).mockReturnValueOnce(newA.promise); const page = setup(fetcher); page.setOperation('a', detail('a')); const first = page.refresh(); page.setOperation('b', detail('b')); const second = page.refresh(); page.setOperation('a', detail('a', 3)); const third = page.refresh(); newA.resolve(response('a', 4)); await third; oldA.resolve(response('a', 0)); oldB.reject(new Error('Obsolete failure')); await Promise.all([first, second]); expect(page.state()).toMatchObject({ id: 'a', detail: detail('a', 4), loadError: '', refreshing: false }); }); it('invalidates a pending poll and reconciles an accepted mutation before old data can return', async () => { const old = deferred(); const fetcher = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(Response.json({ ok: true })) .mockResolvedValueOnce(response('a', 3)); const page = setup(fetcher); page.setOperation('a', detail('a')); const polling = page.refresh(); await page.act('resume', 'Verification resumed.'); old.resolve(response('a', 2)); await polling; expect(fetcher).toHaveBeenCalledTimes(3); expect(page.state()).toMatchObject({ detail: detail('a', 3), notice: 'Verification resumed.', busy: null, loadError: '', actionError: '' }); }); it('retains accepted-action feedback if reconciliation fails, and action errors survive successful polls', async () => { const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true })) .mockRejectedValueOnce(new Error('Offline')) .mockResolvedValueOnce(Response.json({ error: 'Removal already began.' }, { status: 409 })) .mockResolvedValueOnce(response('a', 2)).mockResolvedValueOnce(response('a', 3)); const page = setup(fetcher); page.setOperation('a', detail('a')); await page.act('resume', 'Resume accepted.'); expect(page.state()).toMatchObject({ notice: 'Resume accepted.', loadError: 'Offline', actionError: '', detail: detail('a') }); await page.act('cancel', 'Cancelled.'); await page.refresh(); expect(page.state()).toMatchObject({ actionError: 'Removal already began.', notice: '', loadError: '', detail: detail('a', 3) }); }); it('does not duplicate mutations or poll during them and ignores completion after navigation', async () => { const action = deferred(); const fetcher = vi.fn(() => action.promise); const page = setup(fetcher); page.setOperation('a', detail('a')); const first = page.act('resume', 'Resumed.'); await page.act('resume', 'Resumed twice.'); await page.refresh(); expect(fetcher).toHaveBeenCalledTimes(1); page.setOperation('b', detail('b')); action.resolve(Response.json({ ok: true })); await first; expect(fetcher).toHaveBeenCalledTimes(1); expect(page.state()).toMatchObject({ id: 'b', busy: null, notice: '', actionError: '' }); }); it('times out reads without discarding the last snapshot and suppresses updates after unmount', async () => { vi.useFakeTimers(); const fetcher = vi.fn((_url, init) => new Promise((_, reject) => { init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); })) as typeof fetch; const page = setup(fetcher); page.setOperation('a', detail('a')); const pending = page.refresh(); await vi.advanceTimersByTimeAsync(100); await pending; expect(page.state()).toMatchObject({ detail: detail('a'), loadError: 'Operation refresh timed out. Try again.', refreshing: false }); const next = page.refresh(); page.dispose(); const calls = page.onChange.mock.calls.length; await next; expect(page.onChange).toHaveBeenCalledTimes(calls); }); });