146 lines
5.8 KiB
TypeScript
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);
|