Files
growqr-backend/scripts/event-ingress-route-integration.test.ts

146 lines
5.8 KiB
TypeScript

import { normalizeGrowEvent } from "../src/events/normalize.js";
// Mirrors /v1/events/track's accepted body shape without requiring Postgres.
{
const event = normalizeGrowEvent({
source: "interview-service",
type: "interview.session.completed",
correlation: { task_id: "task-nested", keep: "preserve", session_id: "session-1" },
payload: { status: "completed" },
});
if (event.correlation?.task_id !== "task-nested" || event.correlation.keep !== "preserve" || event.correlation.session_id !== "session-1") {
throw new Error("nested correlation fields were not preserved at ingress");
}
}
import assert from "node:assert/strict";
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Cross-file integration test: exercises the full ingress → route → actor-enqueue
* side-effect chain for the duplicate-routing regression.
*
* The persistence layer (recordGrowEventWithResult) requires Postgres and cannot
* run in a tsx test. Instead we simulate its {event, inserted} output contract —
* the same contract the three ingress callers (redis-consumer recordAndRoute,
* routes/events.ts ingest, v1/events/events-routes.ts /track) consume — and prove
* that the routing layer enqueues exactly once across a duplicate ingest pair.
*
* routeGrowEventToUserActor accepts an injectable `enqueue`, so we can observe
* real side effects (the actor queue send) without a live Rivet cluster.
*/
type RouteDecision = { routed: true } | { routed: false; reason: string };
// Simulate the ingress caller's routing block (mirrors redis-consumer.recordAndRoute
// and routes/events.ts.ingest): gate on shouldRouteGrowEvent, then call route.
async function ingressRoute(
event: Pick<GrowEventRow, "id" | "userId" | "processingStatus">,
inserted: boolean,
enqueueCalls: Array<{ userId: string; eventId: string }>,
): Promise<RouteDecision> {
if (!shouldRouteGrowEvent(event, inserted)) {
return { routed: false, reason: "dedupe_or_unresolved" };
}
return routeGrowEventToUserActor(event, {
timeoutMs: 500,
enqueue: async (input) => {
enqueueCalls.push({ userId: input.userId, eventId: input.eventId });
return { queued: true };
},
});
}
function makeEvent(overrides: Partial<GrowEventRow> = {}): Pick<GrowEventRow, "id" | "userId" | "processingStatus"> {
return {
id: "evt-cross-file-test",
userId: "user-cross-file",
processingStatus: "pending",
...overrides,
};
}
// ── 1. First ingest routes, second (dedupe) does NOT — proving no double-enqueue ─
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const event = makeEvent();
// First ingest: recordGrowEventWithResult returns inserted=true
const first = await ingressRoute(event, true, enqueueCalls);
// Second ingest: same event, dedupe hit → inserted=false
const second = await ingressRoute(event, false, enqueueCalls);
assert.equal(first.routed, true, "first ingest must route to actor");
assert.equal(second.routed, false, "dedupe ingest must NOT route");
assert.equal(enqueueCalls.length, 1, "exactly one enqueue call — duplicate must not double-enqueue");
assert.deepEqual(enqueueCalls[0], { userId: "user-cross-file", eventId: "evt-cross-file-test" });
}
// ── 2. Unresolved event (no userId) never enqueues ────────────────────────────
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const event = makeEvent({ userId: null, processingStatus: "unresolved" });
const result = await ingressRoute(event, true, enqueueCalls);
assert.equal(result.routed, false, "unresolved event must not route");
assert.equal(enqueueCalls.length, 0, "unresolved event must not enqueue");
}
// ── 3. Three distinct events all route (no false-negative) ────────────────────
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const events = [
makeEvent({ id: "evt-a", userId: "user-1" }),
makeEvent({ id: "evt-b", userId: "user-1" }),
makeEvent({ id: "evt-c", userId: "user-2" }),
];
for (const event of events) {
await ingressRoute(event, true, enqueueCalls);
}
assert.equal(enqueueCalls.length, 3, "three distinct inserted events must each enqueue once");
assert.deepEqual(
enqueueCalls.map((c) => c.eventId),
["evt-a", "evt-b", "evt-c"],
);
}
// ── 4. Same event ingested 5 times enqueues exactly once ──────────────────────
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const event = makeEvent({ id: "evt-repeated" });
// First is inserted, remaining 4 are dedupe hits
await ingressRoute(event, true, enqueueCalls);
for (let i = 0; i < 4; i++) {
await ingressRoute(event, false, enqueueCalls);
}
assert.equal(enqueueCalls.length, 1, "5 duplicate ingests must enqueue exactly once");
}
// ── 5. Mixed: event A first-insert, event B dedupe, event C first-insert ──────
// Proves the gate doesn't carry state between decisions.
{
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
const eventA = makeEvent({ id: "evt-mixed-a" });
const eventB = makeEvent({ id: "evt-mixed-b" });
const eventC = makeEvent({ id: "evt-mixed-c" });
await ingressRoute(eventA, true, enqueueCalls);
await ingressRoute(eventB, false, enqueueCalls);
await ingressRoute(eventC, true, enqueueCalls);
assert.equal(enqueueCalls.length, 2, "only first-insert events A and C enqueue; B (dedupe) skipped");
assert.deepEqual(
enqueueCalls.map((c) => c.eventId),
["evt-mixed-a", "evt-mixed-c"],
);
}
console.log("event-ingress-route-integration tests passed");
process.exit(0);