storebound/apps/desktop/tests/read-registry.test.ts

260 lines
13 KiB
TypeScript

// The read registry (src/lib/read-registry.ts) over the scheduler, with a fake clock: single-flight per key, scope
// overlap (tags, broad reads covering narrow keys), "read just now", invalidation, one error to every waiter,
// cancellation, the quota pause on the query, which reads a write invalidates; and the component level: two read
// buttons whose reads overlap both render busy and disabled from the registry (react-dom/server, no DOM needed).
import { describe, expect, test } from 'bun:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { readKey } from '../src/lib/freshness';
import { affectedReads, appTags, PORTFOLIO_KEY, READ_ALL_KEY, type ReadQuery, ReadRegistry, type ReadSpec } from '../src/lib/read-registry';
import { RegistryCtx, useReadState } from '../src/store';
import { type Clock, ReadScheduler } from '../src/lib/scheduler';
class FakeClock implements Clock {
t = 1_000_000;
private timers: { at: number; fn: () => void; id: number }[] = [];
private n = 0;
now() { return this.t; }
setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; }
clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); }
random() { return 0.5; }
async advance(ms: number) {
const end = this.t + ms;
for (;;) {
await flush();
const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0];
if (!next) break;
this.t = next.at;
this.timers = this.timers.filter(x => x !== next);
next.fn();
}
this.t = end;
await flush();
}
}
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
/** A registry whose reads resolve when the test says so; `calls` counts host calls per key. */
function setup(o: { justNowMs?: number } = {}) {
const clock = new FakeClock();
const sched = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 4, play: 4, local: 2 } });
const events: string[] = [];
const reg = new ReadRegistry(sched, { clock, justNowMs: o.justNowMs ?? 20_000, onEvent: (k, e) => events.push(`${e.kind}:${k}`) });
const calls = new Map<string, number>();
const pending = new Map<string, { done: (v: unknown) => void; fail: (e: unknown) => void }[]>();
const spec = (key: string, x: Partial<ReadSpec<unknown>> = {}): ReadSpec<unknown> => ({
key, lanes: ['apple'], accounts: ['apple:k1'], priority: 0, tags: x.tags ?? appTags('app1'), ...x,
run: () => new Promise((done, fail) => {
calls.set(key, (calls.get(key) ?? 0) + 1);
pending.set(key, [...(pending.get(key) ?? []), { done, fail }]);
}),
});
const finish = (key: string, v: unknown = { ok: true }) => { for (const p of pending.get(key) ?? []) p.done(v); pending.delete(key); };
const fail = (key: string, e: unknown) => { for (const p of pending.get(key) ?? []) p.fail(e); pending.delete(key); };
return { clock, sched, reg, calls, spec, finish, fail, events };
}
const K1 = readKey('app1', 'health', {});
const K2 = readKey('app1', 'reviews.list', { days: 30 });
const K3 = readKey('app2', 'health', {});
describe('ReadRegistry', () => {
test('single-flight: the same key twice is one host call and one promise; both get the result', async () => {
const { clock, reg, calls, spec, finish, events } = setup();
const a = reg.read(spec(K1));
const b = reg.read(spec(K1));
await clock.advance(0);
expect(calls.get(K1)).toBe(1);
expect(reg.state(K1).busy).toBe(true);
finish(K1, { ok: true, n: 7 });
expect(await a).toEqual({ ok: true, n: 7 });
expect(await b).toEqual({ ok: true, n: 7 });
expect(reg.state(K1).busy).toBe(false);
// the bus hears one start and one done, never a join
expect(events).toEqual([`start:${K1}`, `done:${K1}`]);
});
test('just now: a second click right after a finished read answers from it; after the window it reads again', async () => {
const { clock, reg, calls, spec, finish } = setup({ justNowMs: 10_000 });
const a = reg.read(spec(K1));
await clock.advance(0); finish(K1, 'first'); await a;
expect(reg.state(K1).justNow).toBe(true);
expect(await reg.read(spec(K1))).toBe('first');
expect(calls.get(K1)).toBe(1);
await clock.advance(10_001);
expect(reg.state(K1).justNow).toBe(false);
const c = reg.read(spec(K1));
await clock.advance(0); finish(K1, 'second');
expect(await c).toBe('second');
expect(calls.get(K1)).toBe(2);
// force skips the memo
const f = reg.read(spec(K1), { force: true });
await clock.advance(0); finish(K1, 'third');
expect(await f).toBe('third');
expect(calls.get(K1)).toBe(3);
});
test('invalidation drops the memo of the keys a write touched, and only those', async () => {
const { clock, reg, calls, spec, finish } = setup();
for (const k of [K1, K2]) { const p = reg.read(spec(k)); await clock.advance(0); finish(k); await p; }
expect(reg.invalidate(k => k === K2)).toEqual([K2]);
reg.read(spec(K1)).catch(() => {}); reg.read(spec(K2)).catch(() => {});
await clock.advance(0);
expect(calls.get(K1)).toBe(1);
expect(calls.get(K2)).toBe(2);
});
test('an error reaches every waiter, is the key\'s error, and is not memoized', async () => {
const { clock, reg, calls, spec, fail, finish } = setup();
const a = reg.read(spec(K1)), b = reg.read(spec(K1));
await clock.advance(0);
fail(K1, new Error('403 SERVICE_DISABLED'));
await expect(a).rejects.toThrow('SERVICE_DISABLED');
await expect(b).rejects.toThrow('SERVICE_DISABLED');
expect(reg.state(K1)).toMatchObject({ busy: false, error: '403 SERVICE_DISABLED', justNow: false });
const c = reg.read(spec(K1));
await clock.advance(0); finish(K1, 'ok');
expect(await c).toBe('ok');
expect(calls.get(K1)).toBe(2);
expect(reg.state(K1).error).toBeNull();
});
test('scope: a narrow read makes the broad tag busy; another app\'s query stays free', async () => {
const { clock, reg, spec, finish } = setup();
reg.read(spec(K1)).catch(() => {});
await clock.advance(0);
expect(reg.state({ tags: ['all'] }).busy).toBe(true);
expect(reg.state({ tags: ['app:app1'] }).busy).toBe(true);
expect(reg.state(K3).busy).toBe(false);
expect(reg.state({ keys: [K3, K1] }).busy).toBe(true);
finish(K1);
await flush();
expect(reg.state({ tags: ['all'] }).busy).toBe(false);
});
test('a broad read covers its narrow keys: they are busy at once, a click on one waits and does not read again', async () => {
const { clock, reg, calls, spec, finish } = setup();
let release!: (k: string) => void;
let go!: () => void;
const gate = new Promise<void>(r => { go = r; });
const all = reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [PORTFOLIO_KEY, K1, K2], run: async g => {
release = g.release;
await gate; // the TTL checks
const ps = [K1, K2].map(k => { g.release(k); return reg.read(spec(k)); });
return Promise.all(ps);
} });
await flush();
// nothing started yet, but every covered key already shows busy
expect(calls.size).toBe(0);
for (const k of [K1, K2, PORTFOLIO_KEY]) expect(reg.state(k).busy).toBe(true);
expect(reg.state({ tags: ['all'] }).busy).toBe(true);
// the card's refresh click while covered: joins the group, no read of its own
const click = reg.read(spec(K1));
// a second Refresh all joins the first
const again = reg.group({ key: READ_ALL_KEY, covers: [], run: async () => { throw new Error('must not run'); } });
expect(again).toBe(all as Promise<unknown> as typeof again);
release(PORTFOLIO_KEY);
expect(reg.state(PORTFOLIO_KEY).busy).toBe(false);
go();
await clock.advance(0);
expect(calls.get(K1)).toBe(1);
finish(K1, 'one'); finish(K2, 'two');
await all;
expect(await click).toBe('one');
expect(calls.get(K1)).toBe(1);
expect(reg.state({ tags: ['all'] }).busy).toBe(false);
});
test('cancel: a queued read is dropped and every waiter hears "cancelled"; a running one cancels its job', async () => {
const { clock, sched, reg, spec } = setup();
sched.setLimits({ apple: 1 });
let cancelled = 0;
reg.read(spec(K1, { cancel: () => { cancelled++; } })).catch(() => {});
const q1 = reg.read(spec(K3)), q2 = reg.read(spec(K3));
await clock.advance(0);
reg.cancel(K3);
await expect(q1).rejects.toThrow('cancelled');
await expect(q2).rejects.toThrow('cancelled');
await flush();
expect(reg.state(K3).busy).toBe(false);
// a cancel is not a failure the card keeps showing
expect(reg.state(K3).error).toBeNull();
reg.cancel(K1);
expect(cancelled).toBe(1);
});
test('a 429 backoff of the account shows as paused on every query that uses it', async () => {
const clock = new FakeClock();
let n = 0;
const sched = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 60_000, classify: () => ({ throttled: n++ === 0 }) });
const reg = new ReadRegistry(sched, { clock });
const p = reg.read({ key: K1, tags: appTags('app1'), lanes: ['play'], accounts: ['play:acc'], priority: 0, run: async () => 'x' });
await clock.advance(0);
const st = reg.state(K1);
expect(st.busy).toBe(true);
expect(st.pausedUntil).toBeGreaterThan(clock.now());
expect(reg.state({ tags: ['all'] }).pausedUntil).toBe(st.pausedUntil);
await clock.advance(120_000);
expect(await p).toBe('x');
expect(reg.state(K1).pausedUntil).toBeNull();
});
test('a joining click with a higher priority raises the queued read', async () => {
const { clock, sched, reg, calls, spec, finish } = setup();
sched.setLimits({ apple: 1 });
reg.read(spec('blocker')).catch(() => {});
reg.read(spec(K2, { priority: 2 })).catch(() => {});
reg.read(spec(K3, { priority: 2 })).catch(() => {});
reg.read(spec(K3, { priority: 0 })).catch(() => {});
await clock.advance(0);
expect([...calls.keys()]).toEqual(['blocker']);
expect(calls.get(K3)).toBeUndefined();
// still one queued read per key; the joined K3 now goes before K2
expect(sched.stats().queued).toBe(2);
finish('blocker');
await clock.advance(0);
expect(calls.get(K3)).toBe(1);
expect(calls.get(K2)).toBeUndefined();
});
});
describe('affectedReads', () => {
test('writes invalidate their own reads only; unknown writes everything', () => {
expect(affectedReads('reviews.reply').methods).toEqual(['reviews.list', 'reviews.drafts']);
expect(affectedReads('owner.play_rollout')).toEqual({ methods: ['apple.status', 'play.status'], portfolio: true });
expect(affectedReads('apple.testflight_distribute').methods).toEqual(['apple.testflight_status']);
expect(affectedReads('prices.import').methods).toContain('prices.current');
expect(affectedReads('prices.import').methods).not.toContain('health');
expect(affectedReads('art.pull').methods).toContain('art.live');
expect(affectedReads('project.edit')).toEqual({ methods: null, portfolio: true });
});
});
describe('two overlapping read buttons render busy together', () => {
test('a card refresh in flight: Refresh all, the card, Check again are busy and disabled; another app is not', async () => {
const { clock, reg, spec } = setup();
// the window's rule (src/ui/ReadButtons.tsx, ReadControl): useReadState(query).busy -> spinner + disabled
const Btn = ({ q, id }: { q: ReadQuery; id: string }) => {
const s = useReadState(q);
return createElement('button', { 'data-id': id, disabled: s.busy, 'aria-busy': s.busy }, s.busy ? 'spin' : 'idle');
};
const view = () => renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: reg },
createElement(Btn, { q: { tags: ['all'] }, id: 'refresh-all' }),
createElement(Btn, { q: K1, id: 'health-card' }),
createElement(Btn, { q: { keys: [K1, K3] }, id: 'check-again' }),
createElement(Btn, { q: readKey('app9', 'scan', {}), id: 'other-app' })));
expect(view()).not.toContain('disabled');
reg.read(spec(K1)).catch(() => {});
await clock.advance(0);
const html = view();
for (const id of ['refresh-all', 'health-card', 'check-again']) expect(html).toContain(`<button data-id="${id}" disabled="" aria-busy="true">spin</button>`);
expect(html).toContain('<button data-id="other-app" aria-busy="false">idle</button>');
// and the other way round: a broad read in flight makes the narrow buttons busy before any of them started
const g = setup();
g.reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [K3], run: () => new Promise(() => {}) }).catch(() => {});
await flush();
const html2 = renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: g.reg }, createElement(Btn, { q: K3, id: 'card' }), createElement(Btn, { q: { keys: [K3] }, id: 'notice' })));
expect(html2).toContain('<button data-id="card" disabled="" aria-busy="true">spin</button>');
expect(html2).toContain('<button data-id="notice" disabled="" aria-busy="true">spin</button>');
});
});