Files
2026-09-16 01:47:28 -07:00

217 lines
7.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { UNCLASSIFIED_ERROR_PHRASE } from '../src/errors/publicPhrase.js';
import { HealthRegistry } from '../src/health/registry.js';
import { DeltaPoller } from '../src/poller/deltaPoller.js';
describe('DeltaPoller', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('polls on the configured interval after start and stops cleanly', async () => {
const poll = vi.fn().mockResolvedValue(undefined);
const poller = new DeltaPoller({ intervalMs: 1000, poll });
expect(poller.running).toBe(false);
poller.start();
poller.start(); // idempotent
expect(poller.running).toBe(true);
await vi.advanceTimersByTimeAsync(3000);
expect(poll).toHaveBeenCalledTimes(3);
await poller.stop();
await poller.stop(); // idempotent
expect(poller.running).toBe(false);
await vi.advanceTimersByTimeAsync(3000);
expect(poll).toHaveBeenCalledTimes(3);
});
it('skips a tick while the previous poll is still in flight', async () => {
let release!: () => void;
const poll = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const poller = new DeltaPoller({ intervalMs: 1000, poll });
poller.start();
await vi.advanceTimersByTimeAsync(2500); // ticks at 1000 and 2000 — second skipped
expect(poll).toHaveBeenCalledTimes(1);
release();
await vi.advanceTimersByTimeAsync(1000);
expect(poll).toHaveBeenCalledTimes(2);
// stop() now awaits the in-flight (second) tick, so release it first.
const stopped = poller.stop();
release();
await stopped;
});
it('reports poll errors via onError and keeps polling', async () => {
const onError = vi.fn();
const poll = vi.fn().mockRejectedValue(new Error('arena unreachable'));
const poller = new DeltaPoller({ intervalMs: 1000, poll, onError });
poller.start();
await vi.advanceTimersByTimeAsync(2000);
expect(poll).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(2);
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
await poller.stop();
});
describe('cursor ownership', () => {
it('starts the cursor at construction time and advances it to the tick time only after a successful poll', async () => {
let nowMs = 1_000;
const poll = vi.fn().mockResolvedValue(undefined);
const poller = new DeltaPoller({ intervalMs: 1000, poll, now: () => new Date(nowMs) });
expect(poller.nextSince.getTime()).toBe(1_000);
poller.start();
nowMs = 2_000;
await vi.advanceTimersByTimeAsync(1000); // tick 1 at t=2000: since=1000
nowMs = 3_000;
await vi.advanceTimersByTimeAsync(1000); // tick 2 at t=3000: since=2000
expect(poll.mock.calls.map((call) => (call[0] as Date).getTime())).toEqual([1_000, 2_000]);
expect(poller.nextSince.getTime()).toBe(3_000);
await poller.stop();
});
it('keeps the same `since` across consecutive ticks while the poll keeps rejecting', async () => {
let nowMs = 1_000;
const poll = vi.fn().mockRejectedValue(new Error('arena unreachable'));
const poller = new DeltaPoller({
intervalMs: 1000,
poll,
onError: () => undefined,
now: () => new Date(nowMs),
});
poller.start();
nowMs = 2_000;
await vi.advanceTimersByTimeAsync(1000);
nowMs = 3_000;
await vi.advanceTimersByTimeAsync(1000);
nowMs = 4_000;
await vi.advanceTimersByTimeAsync(1000);
// A failed poll must not lose its window: every tick re-covers from 1000.
expect(poll.mock.calls.map((call) => (call[0] as Date).getTime())).toEqual([1_000, 1_000, 1_000]);
expect(poller.nextSince.getTime()).toBe(1_000);
// First success advances to that tick's `asOf`, and the next tick uses it.
poll.mockResolvedValue(undefined);
nowMs = 5_000;
await vi.advanceTimersByTimeAsync(1000);
nowMs = 6_000;
await vi.advanceTimersByTimeAsync(1000);
expect(poll.mock.calls.slice(3).map((call) => (call[0] as Date).getTime())).toEqual([1_000, 5_000]);
await poller.stop();
});
it('captures asOf BEFORE the fetch so changes arriving mid-poll are not skipped', async () => {
let nowMs = 1_000;
let release!: () => void;
const poll = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const poller = new DeltaPoller({ intervalMs: 1000, poll, now: () => new Date(nowMs) });
poller.start();
nowMs = 2_000;
await vi.advanceTimersByTimeAsync(1000); // tick starts at t=2000
nowMs = 2_900; // time passes while the poll is in flight
release();
await vi.advanceTimersByTimeAsync(0);
// Cursor is the tick's start time, not its completion time.
expect(poller.nextSince.getTime()).toBe(2_000);
await poller.stop();
});
});
describe('stop()', () => {
it('resolves immediately when no tick is in flight', async () => {
const poller = new DeltaPoller({ intervalMs: 1000, poll: vi.fn() });
poller.start();
await expect(poller.stop()).resolves.toBeUndefined();
expect(poller.running).toBe(false);
});
it('returns the in-flight tick promise so shutdown can await it', async () => {
let release!: () => void;
const poll = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const poller = new DeltaPoller({ intervalMs: 1000, poll });
poller.start();
await vi.advanceTimersByTimeAsync(1000);
expect(poll).toHaveBeenCalledTimes(1);
let settled = false;
const stopped = poller.stop().then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(5000);
expect(poller.running).toBe(false);
expect(settled).toBe(false); // still waiting on the tick
expect(poll).toHaveBeenCalledTimes(1); // and no new ticks were scheduled
release();
await stopped;
expect(settled).toBe(true);
});
});
describe('health reporting', () => {
it('records success and failure with the tick start time', async () => {
let nowMs = 10_000;
const health = new HealthRegistry();
const poll = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined);
const poller = new DeltaPoller({
intervalMs: 1000,
poll,
health,
onError: () => undefined,
now: () => new Date(nowMs),
});
poller.start();
nowMs = 11_000;
await vi.advanceTimersByTimeAsync(1000);
expect(health.status()).toBe('degraded');
// B5: the registry stores a fixed public phrase, never the raw message.
expect(health.pollerSnapshot()).toEqual({
lastRunAt: new Date(11_000).toISOString(),
lastError: UNCLASSIFIED_ERROR_PHRASE,
});
nowMs = 12_000;
await vi.advanceTimersByTimeAsync(1000);
expect(health.status()).toBe('ok');
expect(health.pollerSnapshot()).toEqual({
lastRunAt: new Date(12_000).toISOString(),
lastSuccessAt: new Date(12_000).toISOString(),
});
await poller.stop();
});
});
});