diff --git a/src/broker-amqp.ts b/src/broker-amqp.ts index 62c5e51..9b8db7c 100644 --- a/src/broker-amqp.ts +++ b/src/broker-amqp.ts @@ -373,7 +373,7 @@ function toEnvelope(msg: amqp.ConsumeMessage): Envelope { /** AMQP topic matching: `*` matches one word, `#` zero or more. Used to fan a shared queue's * deliveries out to the handlers whose pattern actually matches the routing key. */ -function topicMatches(pattern: string, key: string): boolean { +export function topicMatches(pattern: string, key: string): boolean { return matchFrom(pattern.split("."), 0, key.split("."), 0); } @@ -404,19 +404,19 @@ function matchFrom(p: string[], pi: number, k: string[], ki: number): boolean { * **Why it exists at all.** Until 04-ISSUES/127 every module passed the routing key itself, which * worked on this bus and derived into a namespace nobody owns on the one being built. Converting the * modules to local names without this would have broken the mesh that is actually running. */ -function routingKeyFor(key: string, self: string): string { +export function routingKeyFor(key: string, self: string): string { return key.startsWith("module.") ? key : `module.${self}.${key}`; } /** A local pattern as this bus's binding. `**` is the mesh's wildcard for the rest of a name; here * that is `#`, and on the bus being built it is `>`. Neither spelling appears in a manifest. */ -function bindingFor(pattern: string): string { +export function bindingFor(pattern: string): string { const here = pattern.split(".").map((part) => (part === "**" ? "#" : part)).join("."); if (here === "#") return "#"; return here.startsWith("module.") ? here : `module.${here}`; } /** A routing key as the local name a handler and a manifest both use: the emitter and the event. */ -function localKeyFor(routingKey: string): string { +export function localKeyFor(routingKey: string): string { return routingKey.startsWith("module.") ? routingKey.slice("module.".length) : routingKey; } diff --git a/test/wire-unchanged.test.ts b/test/wire-unchanged.test.ts new file mode 100644 index 0000000..2d80144 --- /dev/null +++ b/test/wire-unchanged.test.ts @@ -0,0 +1,68 @@ +/** + * **The old bus's wire is byte-identical after the rename, and this is the test that lets the change + * be merged to a running mesh.** + * + * Every module's event names were converted from the old bus's routing keys to local names + * (novox/hq 04-ISSUES/127), and the old bus's client maps them back. If that mapping is wrong + * anywhere, a live mesh's events stop being delivered — silently, because a binding that matches + * nothing is not an error. + * + * So this pins the mapping against the literal routing keys the mesh used before, taken from the + * manifests as they were. It needs no bus: it is about a string. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { routingKeyFor, bindingFor, localKeyFor, topicMatches } from "../dist/broker-amqp.js"; + +test("a converted emit produces the routing key the mesh published before", () => { + // left: what the module's code says now. right: what went on the wire before, unchanged. + const same: [string, string, string][] = [ + ["plex", "playback.started", "module.plex.playback.started"], + ["sonarr", "download.completed", "module.sonarr.download.completed"], + ["builder", "built", "module.builder.built"], + ["mesh-catalog", "upgraded", "module.mesh-catalog.upgraded"], + ["keycloak", "user.created", "module.keycloak.user.created"], + ["mesh-vault", "secret.rotated", "module.mesh-vault.secret.rotated"], + ]; + for (const [self, local, before] of same) { + assert.equal(routingKeyFor(local, self), before, `${self} emitting ${local}`); + } +}); + +test("a converted subscription binds what it bound before", () => { + const same: [string, string][] = [ + ["builder.built", "module.builder.built"], + ["*.download.completed", "module.*.download.completed"], + ["*.usage.*", "module.*.usage.*"], + // The audit logger's "everything": `#` on this bus, and it must stay `#`. + ["**", "#"], + ]; + for (const [declared, before] of same) { + assert.equal(bindingFor(declared), before, `consuming ${declared}`); + } +}); + +test("a handler still matches what the bus delivers", () => { + // The key a handler is given is the local one now, and the pattern it compares against is local + // too — so the pair must still meet for every case the mesh actually has. + const pairs: [string, string][] = [ + ["builder.built", "module.builder.built"], + ["*.download.completed", "module.sonarr.download.completed"], + ["*.usage.*", "module.anthropic-consumer.usage.session"], + ["**", "module.anything.at.all"], + ]; + for (const [pattern, delivered] of pairs) { + assert.ok( + topicMatches(pattern, localKeyFor(delivered)), + `${pattern} no longer matches ${delivered}, so a running module would stop reacting`, + ); + } +}); + +test("a routing key already in the old form is left alone", () => { + // Belt for the transition: anything not yet converted still goes out as it did, so a module built + // from an older manifest keeps working beside one built from a current manifest. + assert.equal(routingKeyFor("module.plex.playback.started", "plex"), "module.plex.playback.started"); + assert.equal(bindingFor("module.builder.built"), "module.builder.built"); +});