Files
electric-cherry/tests/unit/provisioning-refresh.test.ts
T

230 lines
8.7 KiB
TypeScript

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<Detail>;
const onChange = vi.fn((value) => {
state = value;
});
const controller = createProvisioningRefresh<Detail>({
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<Response>((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<Response>((_, 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);
});
});