import { test } from "node:test"; import assert from "node:assert/strict"; import { fatalBrokerReason, PinMismatchError, topicMatches } from "../src/broker-nats.ts"; // novox/hq issue 058 (and its review): serve mode retries a broker that is not up yet, but must // give up at once on a failure waiting cannot fix — otherwise a permanent fault loops for ever // disguised as "not reachable". This is the classifier that draws the line; the bed cannot test it // (it starts the consumer only after the broker is up), so it is proven here. test("a broker that is not up yet is retryable, not fatal", () => { for (const err of [ Object.assign(new Error("connect ECONNREFUSED 10.42.0.1:5671"), { code: "ECONNREFUSED" }), Object.assign(new Error("connect ETIMEDOUT"), { code: "ETIMEDOUT" }), Object.assign(new Error("getaddrinfo EAI_AGAIN anchor.internal"), { code: "EAI_AGAIN" }), new Error("timed out fetching the broker's certificate"), ]) { assert.equal(fatalBrokerReason(err), null, `should retry: ${(err as Error).message}`); } }); test("a certificate that does not match the pin is fatal, by type not by message", () => { // Typed, so rewording the message cannot turn an impostor back into an infinite retry. assert.notEqual(fatalBrokerReason(new PinMismatchError("anything at all")), null); // A plain Error with pin-ish words is NOT treated as the pin case — only the type is. assert.equal(fatalBrokerReason(new Error("the pinned value was fine")), null); }); test("a malformed broker URL is fatal — it never parses on the next try", () => { assert.notEqual(fatalBrokerReason(Object.assign(new Error("Invalid URL"), { code: "ERR_INVALID_URL" })), null); assert.notEqual(fatalBrokerReason(new Error("Invalid URL: not-a-url")), null); }); test("a refused login is fatal — a wrong or revoked credential, not an absent broker", () => { // The bus refuses a login in its own words; each is final, because the next try says the same. for (const msg of ["Authorization Violation", "nats: user authentication expired", "Permissions Violation for Subscription to \"x\""]) { assert.notEqual(fatalBrokerReason(new Error(msg)), null, `should be fatal: ${msg}`); } }); test("a non-Error value does not crash the classifier", () => { assert.equal(fatalBrokerReason("just a string"), null); assert.equal(fatalBrokerReason(undefined), null); }); // **One durable consumer feeds one reader, however many patterns a module registers.** // // A module has exactly one consumer, so two readers of it would each take half the messages — and a // reader that received one its own pattern does not match acknowledges it, which is right for a // filter wider than anything registered and silent loss when it is another handler's. The matching is // therefore pure and tested as such: what a message is for is decided by the patterns registered, not // by which reader happened to fetch it. test("a message is for every pattern that matches it, and nothing else", () => { const registered = ["mesh-build-machine.built", "mesh-controller.built-before"]; const matched = (key: string) => registered.filter((p) => topicMatches(p, key)); assert.deepEqual(matched("mesh-build-machine.built"), ["mesh-build-machine.built"]); assert.deepEqual(matched("mesh-controller.built-before"), ["mesh-controller.built-before"]); // Nothing registered for it: the consumer's filter is the controller's and may be wider. assert.deepEqual(matched("mesh-catalog.upgraded"), []); // And a handler that asked for everything gets both, which is what the audit logger does. assert.deepEqual(["#"].filter((p) => topicMatches(p, "mesh-controller.built-before")), ["#"]); });