test(events): cover cross-replica Redis delivery

This commit is contained in:
2026-08-29 06:06:42 +00:00 Unverified
parent 1e486c267a
commit 5cdd1a0272
+130
View File
@@ -0,0 +1,130 @@
import { randomUUID } from "node:crypto";
import Redis from "ioredis";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { pageCacheTag } from "@/lib/cache/tags";
import { parseInvalidationEvent } from "@/lib/events/invalidation";
import {
createRedisOutboxTransport,
processOutboxEvent,
} from "@/lib/outbox/processor";
import {
redisCacheTagStateKey,
redisEventChannel,
} from "@/lib/redis/client";
const integrationUrl = process.env.REDIS_INTEGRATION_URL;
const describeWithRedis = integrationUrl ? describe : describe.skip;
function integrationClient(name: string): Redis {
const client = new Redis(integrationUrl!, {
connectionName: name,
enableOfflineQueue: false,
lazyConnect: true,
maxRetriesPerRequest: 1,
});
client.on("error", () => undefined);
return client;
}
async function closeClient(client: Redis | undefined): Promise<void> {
if (!client) return;
if (client.status === "wait" || client.status === "end") {
client.disconnect();
return;
}
await client.quit().catch(() => client.disconnect());
}
describeWithRedis("cross-replica Redis invalidation delivery", () => {
const runId = randomUUID();
const originalCachePrefix = process.env.REDIS_CACHE_PREFIX;
const originalEventPrefix = process.env.REDIS_EVENT_PREFIX;
const pageId = `integration-${runId}`;
const topic = `page:${pageId}`;
const cacheTag = pageCacheTag(pageId);
const invalidatedAt = 1_725_000_000_000;
let replicaA: Redis;
let replicaBEvents: Redis;
let replicaBCache: Redis;
beforeAll(async () => {
process.env.REDIS_CACHE_PREFIX = `buzz:test:${runId}:cache`;
process.env.REDIS_EVENT_PREFIX = `buzz:test:${runId}:events`;
replicaA = integrationClient(`buzz-sheet-test-a-${runId}`);
replicaBEvents = integrationClient(`buzz-sheet-test-b-events-${runId}`);
replicaBCache = integrationClient(`buzz-sheet-test-b-cache-${runId}`);
await Promise.all([
replicaA.connect(),
replicaBEvents.connect(),
replicaBCache.connect(),
]);
});
afterAll(async () => {
if (replicaA?.status === "ready") {
await replicaA.del(redisCacheTagStateKey());
}
await Promise.all([
closeClient(replicaA),
closeClient(replicaBEvents),
closeClient(replicaBCache),
]);
if (originalCachePrefix === undefined) delete process.env.REDIS_CACHE_PREFIX;
else process.env.REDIS_CACHE_PREFIX = originalCachePrefix;
if (originalEventPrefix === undefined) delete process.env.REDIS_EVENT_PREFIX;
else process.env.REDIS_EVENT_PREFIX = originalEventPrefix;
});
it("shares tag state and publishes only the typed event envelope", async () => {
const channel = redisEventChannel(topic);
await replicaBEvents.subscribe(channel);
const received = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for the replica event.")),
5_000,
);
replicaBEvents.on("message", (receivedChannel, value) => {
if (receivedChannel !== channel) return;
clearTimeout(timeout);
resolve(value);
});
});
const processed = await processOutboxEvent(
{
aggregateId: pageId,
eventType: "page.updated",
payload: {
id: pageId,
version: 42,
privateContent: "must remain in PostgreSQL",
},
topic,
},
createRedisOutboxTransport(replicaA),
invalidatedAt,
);
const rawEvent = await received;
expect(parseInvalidationEvent(rawEvent)).toEqual(processed);
expect(JSON.parse(rawEvent)).toEqual({
type: "page.updated",
id: pageId,
version: 42,
});
const tagState = await replicaBCache.hget(
redisCacheTagStateKey(),
cacheTag,
);
expect(tagState && JSON.parse(tagState)).toEqual({
expired: invalidatedAt,
});
});
});