Files
clean-architecture-frontend…/tests/unit/tanstack-cache-coordinator.test.ts
T
2026-08-01 19:39:59 +09:00

272 lines
8.4 KiB
TypeScript

import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import type {
BrowserCrossContextInvalidation,
CrossContextInvalidationDelivery,
} from "../../src/adapters/cross-context-invalidation/index.ts";
import { createTanStackCacheCoordinator } from "../../src/adapters/query-cache/tanstack-cache-coordinator.ts";
import { defineQueryInvalidationTopic } from "../../src/contracts/query-invalidation.ts";
const TOPIC_A = defineQueryInvalidationTopic("qinv.topic-a");
const TOPIC_B = defineQueryInvalidationTopic("qinv.topic-b");
function queryRegistry() {
return Object.freeze({
A: Object.freeze({
namespace: Object.freeze(["resource-a", 1] as const),
invalidationTopic: TOPIC_A,
crossContext: "invalidate-only" as const,
version: 1,
persistence: "disabled" as const,
}),
B: Object.freeze({
namespace: Object.freeze(["resource-b", 1] as const),
invalidationTopic: TOPIC_B,
crossContext: "invalidate-only" as const,
version: 1,
persistence: "disabled" as const,
}),
});
}
function crossContextHarness() {
let listener:
| ((delivery: CrossContextInvalidationDelivery) => void)
| undefined;
const publish = vi.fn(() => ({
ok: true as const,
transport: "BROADCAST" as const,
}));
const close = vi.fn();
const transport: BrowserCrossContextInvalidation = {
getStatus: () => "ACTIVE_BROADCAST",
publish,
subscribe(next) {
listener = next;
return () => {
listener = undefined;
};
},
close,
};
return Object.freeze({
transport,
publish,
close,
deliver(topic: string, ordering: "NEXT" | "GAP" = "NEXT") {
listener?.({
ordering,
transport: "BROADCAST",
event: {
protocolVersion: 1,
eventId: `event-${topic}-${ordering}`,
sourceId: "remote-source",
sourceEpoch: "remote-epoch",
sequence: 1,
cacheEpoch: "cache-epoch",
topic,
topicVersion: 1,
emittedAt: 1,
expiresAt: 60_001,
},
});
},
});
}
function createClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity, gcTime: Infinity },
mutations: { retry: false },
},
});
}
describe("TanStack cross-context cache coordinator", () => {
it("maps a local opaque topic to one namespace and publishes no query key", async () => {
const client = createClient();
const harness = crossContextHarness();
client.setQueryData(["resource-a", 1, "list"], ["a"]);
client.setQueryData(["resource-b", 1, "list"], ["b"]);
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
queryRegistry: queryRegistry(),
crossContext: harness.transport,
});
await coordinator.invalidate([TOPIC_A]);
expect(
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
).toBe(true);
expect(
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
).toBe(false);
expect(harness.publish).toHaveBeenCalledWith({
topic: TOPIC_A,
topicVersion: 1,
});
expect(JSON.stringify(harness.publish.mock.calls)).not.toContain(
"resource-a",
);
});
it("applies a remote hint without publishing an echo", async () => {
const client = createClient();
const harness = crossContextHarness();
client.setQueryData(["resource-a", 1, "detail", "opaque"], {
value: true,
});
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
queryRegistry: queryRegistry(),
crossContext: harness.transport,
});
harness.deliver(TOPIC_A);
await vi.waitFor(() =>
expect(
client.getQueryState([
"resource-a",
1,
"detail",
"opaque",
])?.isInvalidated,
).toBe(true),
);
expect(harness.publish).not.toHaveBeenCalled();
coordinator.dispose();
expect(harness.close).toHaveBeenCalledOnce();
});
it("coalesces remote hints while a local mutation lease is held", async () => {
const client = createClient();
const harness = crossContextHarness();
client.setQueryData(["resource-a", 1, "list"], ["a"]);
const invalidate = vi.spyOn(client, "invalidateQueries");
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
queryRegistry: queryRegistry(),
crossContext: harness.transport,
});
const lease = coordinator.beginMutation([TOPIC_A]);
harness.deliver(TOPIC_A);
harness.deliver(TOPIC_A);
await Promise.resolve();
expect(invalidate).not.toHaveBeenCalled();
await lease.release();
expect(invalidate).toHaveBeenCalledTimes(1);
expect(harness.publish).not.toHaveBeenCalled();
});
it("reconciles every registered namespace when a source sequence has a gap", async () => {
const client = createClient();
const harness = crossContextHarness();
client.setQueryData(["resource-a", 1, "list"], ["a"]);
client.setQueryData(["resource-b", 1, "list"], ["b"]);
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
queryRegistry: queryRegistry(),
crossContext: harness.transport,
});
harness.deliver(TOPIC_A, "GAP");
await vi.waitFor(() => {
expect(
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
).toBe(true);
expect(
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
).toBe(true);
});
});
it("fences remote delivery until a local reset has cancelled and cleared the cache", async () => {
const client = createClient();
const harness = crossContextHarness();
client.setQueryData(["resource-a", 1, "list"], ["private-old-scope"]);
let finishCancellation: (() => void) | undefined;
const cancelQueries = vi
.spyOn(client, "cancelQueries")
.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishCancellation = resolve;
}),
);
const invalidate = vi.spyOn(client, "invalidateQueries");
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
queryRegistry: queryRegistry(),
crossContext: harness.transport,
});
const reset = coordinator.resetLocal();
await vi.waitFor(() => expect(cancelQueries).toHaveBeenCalledOnce());
harness.deliver(TOPIC_A);
await Promise.resolve();
expect(invalidate).not.toHaveBeenCalled();
finishCancellation?.();
await reset;
expect(client.getQueryData(["resource-a", 1, "list"])).toBeUndefined();
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledOnce());
expect(harness.publish).not.toHaveBeenCalled();
});
it("rejects a mandatory local reset when query cancellation fails", async () => {
const client = createClient();
client.setQueryData(["resource-a", 1, "list"], ["private-old-scope"]);
vi.spyOn(client, "cancelQueries").mockRejectedValue(
new Error("cancellation failed"),
);
const clear = vi.spyOn(client, "clear");
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
queryRegistry: queryRegistry(),
});
await expect(coordinator.resetLocal()).rejects.toThrow(
"mandatory query cancellation failed",
);
expect(clear).toHaveBeenCalledOnce();
});
it("composes with no installed query topics", () => {
// §24.12: removing the reference feature leaves the common runtime intact.
// A template with no installed feature has zero invalidation topics, which
// is a legitimate state, not a configuration defect.
const harness = crossContextHarness();
const coordinator = createTanStackCacheCoordinator({
queryClient: createClient(),
queryRegistry: Object.freeze({}),
crossContext: harness.transport,
});
// A remote hint for a topic this build does not install is ignored, not fatal.
harness.deliver("qinv.topic-a");
expect(() => coordinator.beginMutation([])).not.toThrow();
coordinator.dispose();
});
it("rejects an unregistered topic before opening a mutation lease", () => {
const coordinator = createTanStackCacheCoordinator({
queryClient: createClient(),
queryRegistry: queryRegistry(),
});
expect(() =>
coordinator.beginMutation([
defineQueryInvalidationTopic("unknown-topic"),
]),
).toThrow(
"Unregistered query invalidation topic",
);
});
});