Files
tech-log-frontend/tests/unit/server-state-scope-runtime.test.ts

198 lines
6.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts";
import type { ClientScopeLifecycleEvent } from "../../src/contracts/server-state-scope.ts";
describe("server-state session generation runtime", () => {
it("fences the old generation synchronously and publishes READY after the reset order", async () => {
let sessionListener: () => void = () => {};
let completeReset: () => void = () => {};
const reset = new Promise<void>((resolve) => {
completeReset = resolve;
});
const resetLocal = vi.fn(() => reset);
const participantOrder: string[] = [];
let tokenSequence = 0;
const runtime = createServerStateScopeRuntime({
session: {
subscribe(listener) {
sessionListener = listener;
return () => {};
},
},
queryInvalidation: {
resetLocal,
},
participants: [
{
order: 9,
label: "realtime",
close: () => void participantOrder.push("realtime"),
},
{
order: 4,
label: "admission",
close: () => void participantOrder.push("admission"),
},
],
tokenFactory: () =>
`scope-token-${String(tokenSequence++).padStart(8, "0")}`,
});
const changed = vi.fn();
const lifecycle: ClientScopeLifecycleEvent[] = [];
runtime.subscribe(changed);
runtime.subscribeLifecycle((event) => lifecycle.push(event));
const before = runtime.getSnapshot();
const identity = before.identities.intern({ id: "private" });
identity.acquire();
sessionListener();
// §10.6 steps 1-3 are synchronous: the old snapshot is immediately stale,
// FENCED is published, and subscribers are notified before any await.
expect(before.isCurrent()).toBe(false);
expect(before.signal.aborted).toBe(true);
expect(runtime.getPhase()).toBe("FENCED");
expect(lifecycle[0]).toEqual({ kind: "FENCED", previousGeneration: 1 });
expect(changed).toHaveBeenCalledOnce();
// Nothing may read as current while the scope is fenced.
expect(runtime.getSnapshot().isCurrent()).toBe(false);
await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce());
// Participants close in §10.6 step order, before the local cache reset.
expect(participantOrder).toEqual(["admission", "realtime"]);
completeReset();
await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY"));
const after = runtime.getSnapshot();
expect(after.generation).toBe(before.generation + 1);
expect(after.fingerprint).not.toBe(before.fingerprint);
expect(after.isCurrent()).toBe(true);
expect(after.signal.aborted).toBe(false);
expect(after.signal).not.toBe(before.signal);
expect(before.identities.inspect().closed).toBe(true);
expect(lifecycle.at(-1)).toMatchObject({ kind: "READY" });
expect(changed).toHaveBeenCalledTimes(2);
runtime.dispose();
expect(runtime.getPhase()).toBe("DISPOSED");
expect(after.identities.inspect().closed).toBe(true);
expect(after.isCurrent()).toBe(false);
});
it("does not let a throwing snapshot subscriber prevent reset", async () => {
let sessionListener: () => void = () => {};
const resetLocal = vi.fn(async () => {});
const runtime = createServerStateScopeRuntime({
session: {
subscribe(listener) {
sessionListener = listener;
return () => {};
},
},
queryInvalidation: {
resetLocal,
},
tokenFactory: () => "scope-listener-token-0001",
});
runtime.subscribe(() => {
throw new Error("subscriber defect");
});
expect(() => sessionListener()).not.toThrow();
await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY"));
});
it("remains failed when a mandatory participant cannot close", async () => {
let sessionListener: () => void = () => {};
const resetLocal = vi.fn(async () => {});
const runtime = createServerStateScopeRuntime({
session: {
subscribe(listener) {
sessionListener = listener;
return () => {};
},
},
queryInvalidation: {
resetLocal,
},
participants: [
{
order: 4,
label: "mandatory-admission",
close: async () => {
throw new Error("close failed");
},
},
],
tokenFactory: () => "scope-participant-token-0001",
});
sessionListener();
await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce());
await vi.waitFor(() =>
expect(runtime.getPhase() as string).toBe("FAILED"),
);
expect(runtime.getSnapshot().isCurrent()).toBe(false);
});
it("remains failed when local cache reset rejects", async () => {
let sessionListener: () => void = () => {};
const runtime = createServerStateScopeRuntime({
session: {
subscribe(listener) {
sessionListener = listener;
return () => {};
},
},
queryInvalidation: {
resetLocal: async () => {
throw new Error("reset failed");
},
},
tokenFactory: () => "scope-reset-token-0000001",
});
sessionListener();
await vi.waitFor(() =>
expect(runtime.getPhase() as string).toBe("FAILED"),
);
expect(runtime.getSnapshot().isCurrent()).toBe(false);
});
it("activates the next generation after reset and fails closed on activation error", async () => {
let sessionListener: () => void = () => {};
const steps: string[] = [];
const runtime = createServerStateScopeRuntime({
session: {
subscribe(listener) {
sessionListener = listener;
return () => {};
},
},
queryInvalidation: {
resetLocal: async () => {
steps.push("reset");
},
},
activateNextGeneration: async () => {
steps.push("activate");
throw new Error("activation failed");
},
tokenFactory: () => "scope-activation-token-001",
} as Parameters<typeof createServerStateScopeRuntime>[0] & {
activateNextGeneration(): Promise<void>;
});
sessionListener();
await vi.waitFor(() => expect(steps).toEqual(["reset", "activate"]));
await vi.waitFor(() =>
expect(runtime.getPhase() as string).toBe("FAILED"),
);
});
});