890d671bf2
Collapse three byte-identical response-parsing blocks (getRelay, postRelay, and the tts error path) into one handleRelayResponse helper. pingBalance is deliberately left out: it records a relay error even on a parsed envelope, a different contract from the other three (updateRelayState clears lastError while recordRelayError sets it), so folding it in would change observable state. Collapse the six operator-to-relay calls into operatorPost / operatorGet, preserving their intentional split: writes (tier grant, invoice, order) throw on misconfig or non-OK so the operator action surfaces the failure; reads (tier, plans, expiring subs) return null so the caller falls back to a default. Per-function signatures, body shapes, error messages, and the throw-vs-null behavior are unchanged. Add server/test/relay.test.js (first fetch-mock harness for relay.js): 14 tests covering the tts error-path control flow, handleRelayResponse's envelope parsing and error-recording rule, and the operator throw-vs-null contract including the missing-config branch. 158 tests pass. ROADMAP gains the deferred refactor-survey items (subscription engine, /api/process pipeline, sweep middleware, transcript coalescers) and notes the relay-test coverage against the existing known-debt entries.
244 lines
9.5 KiB
JavaScript
244 lines
9.5 KiB
JavaScript
// Unit tests for the relay provider's shared response handling and the
|
|
// operator-call helpers introduced by the dedup refactor
|
|
// (handleRelayResponse / operatorPost / operatorGet). The relay is
|
|
// network-dependent, so we stub global.fetch. These nail:
|
|
// - the tts error-path control flow, which must THROW, not fall through
|
|
// to the binary arrayBuffer read (regression for the missing-return fix);
|
|
// - handleRelayResponse's envelope parsing + the recordRelayError-only-
|
|
// when-no-envelope rule;
|
|
// - the deliberate throw-vs-null split between operatorPost (writes) and
|
|
// operatorGet (reads), including the missing-config branch.
|
|
|
|
import { test, describe, afterEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
createRelayProvider,
|
|
setRelayUserTier,
|
|
getRelayUserTier,
|
|
createRelayTierInvoice,
|
|
getRelayTierPlans,
|
|
getRelayExpiringSubscriptions,
|
|
} from "../providers/relay.js";
|
|
import { getRelayState, resetRelayState } from "../relay-state.js";
|
|
import { getRelayOperatorKey } from "../relay-default.js";
|
|
|
|
const BASE = "https://relay.recaps.cc";
|
|
|
|
// Pin an operator key so the operator-helper tests exercise the configured
|
|
// path. node --test isolates each test file in its own process, so this
|
|
// doesn't leak into other suites.
|
|
process.env.RECAP_RELAY_OPERATOR_KEY = "test-op-key";
|
|
|
|
const realFetch = global.fetch;
|
|
let fetchCalls = [];
|
|
|
|
// Install a stub that records calls and returns (or runs) the given response.
|
|
function stubFetch(response) {
|
|
fetchCalls = [];
|
|
global.fetch = async (url, opts) => {
|
|
fetchCalls.push({ url: String(url), opts });
|
|
return typeof response === "function" ? response(url, opts) : response;
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
global.fetch = realFetch;
|
|
resetRelayState();
|
|
});
|
|
|
|
// Minimal Response-like stubs covering the surfaces the provider touches.
|
|
function jsonRes({ ok = true, status = 200, body = {} }) {
|
|
return { ok, status, text: async () => JSON.stringify(body), json: async () => body };
|
|
}
|
|
function textRes({ ok = false, status = 502, body = "Bad Gateway" }) {
|
|
return {
|
|
ok,
|
|
status,
|
|
text: async () => body,
|
|
json: async () => {
|
|
throw new SyntaxError("not json");
|
|
},
|
|
};
|
|
}
|
|
function binRes({ headers = {}, bytes = [1, 2, 3] }) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(headers),
|
|
arrayBuffer: async () => new Uint8Array(bytes).buffer,
|
|
};
|
|
}
|
|
|
|
const provider = () => createRelayProvider({ baseURL: BASE, installId: "test-install" });
|
|
// A never-aborting signal so tts skips its 90s AbortSignal.timeout fallback.
|
|
const noTimeout = () => new AbortController().signal;
|
|
|
|
describe("relay tts: error path throws (regression for the missing-return fix)", () => {
|
|
test("throws on a non-OK response instead of falling through to the binary read", async () => {
|
|
stubFetch(jsonRes({ ok: false, status: 500, body: { error: "kokoro busy" } }));
|
|
await assert.rejects(
|
|
() => provider().tts({ text: "hi", voice: "bm_george", signal: noTimeout() }),
|
|
(err) => {
|
|
assert.equal(err.status, 500);
|
|
assert.equal(err.message, "Relay /relay/tts 500: kokoro busy");
|
|
assert.deepEqual(err.envelope, { error: "kokoro busy" });
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
test("harvests the credit envelope on a parsed error, records NO relay error", async () => {
|
|
stubFetch(jsonRes({ ok: false, status: 402, body: { error: "out of credits", credits_remaining: 0 } }));
|
|
await assert.rejects(() => provider().tts({ text: "x", voice: "v", signal: noTimeout() }));
|
|
const state = getRelayState("inst:test-install");
|
|
assert.equal(state.creditsRemaining, 0); // updateRelayState ran off the envelope
|
|
assert.equal(state.lastError, null); // recordRelayError did NOT (envelope present)
|
|
});
|
|
|
|
test("records a relay error when the error body is not JSON", async () => {
|
|
stubFetch(textRes({ ok: false, status: 502, body: "Bad Gateway" }));
|
|
await assert.rejects(
|
|
() => provider().tts({ text: "x", voice: "v", signal: noTimeout() }),
|
|
(err) => {
|
|
assert.equal(err.status, 502);
|
|
assert.equal(err.message, "Relay /relay/tts 502: Bad Gateway");
|
|
return true;
|
|
}
|
|
);
|
|
assert.equal(getRelayState("inst:test-install").lastError, "Bad Gateway");
|
|
});
|
|
|
|
test("success path returns binary audio and never enters the error branch", async () => {
|
|
stubFetch(
|
|
binRes({
|
|
headers: {
|
|
"Content-Type": "audio/mpeg",
|
|
"X-Recap-Credits-Remaining": "7",
|
|
"X-Recap-Tier": "pro",
|
|
"X-Recap-Credit-Charged": "1",
|
|
"X-Recap-Audio-Duration": "12.5",
|
|
},
|
|
bytes: [10, 20, 30],
|
|
})
|
|
);
|
|
const out = await provider().tts({ text: "hi", voice: "bm_george", signal: noTimeout() });
|
|
assert.ok(Buffer.isBuffer(out.audio));
|
|
assert.equal(out.audio.length, 3);
|
|
assert.equal(out.contentType, "audio/mpeg");
|
|
assert.equal(out.creditCharged, 1);
|
|
assert.equal(out.durationSeconds, 12.5);
|
|
const state = getRelayState("inst:test-install");
|
|
assert.equal(state.creditsRemaining, 7);
|
|
assert.equal(state.tier, "pro");
|
|
});
|
|
});
|
|
|
|
describe("operatorPost (writes — throw on failure)", () => {
|
|
test("setRelayUserTier POSTs the right URL/headers/body and returns data on OK", async () => {
|
|
stubFetch(jsonRes({ ok: true, body: { ok: true, tier: "max" } }));
|
|
const data = await setRelayUserTier({ userId: "u1", tier: "max", expiresAt: "2026-09-01T00:00:00Z" });
|
|
assert.deepEqual(data, { ok: true, tier: "max" });
|
|
assert.equal(fetchCalls.length, 1);
|
|
const { url, opts } = fetchCalls[0];
|
|
assert.equal(url, `${BASE}/relay/user-tier`);
|
|
assert.equal(opts.method, "POST");
|
|
assert.equal(opts.headers["Content-Type"], "application/json");
|
|
assert.equal(opts.headers["X-Recap-Operator-Key"], "test-op-key");
|
|
assert.deepEqual(JSON.parse(opts.body), {
|
|
user_id: "u1",
|
|
tier: "max",
|
|
expires_at: "2026-09-01T00:00:00Z",
|
|
});
|
|
});
|
|
|
|
test("omits undefined body keys (expires_at) via JSON.stringify", async () => {
|
|
stubFetch(jsonRes({ ok: true, body: {} }));
|
|
await setRelayUserTier({ userId: "u1", tier: "pro" }); // expiresAt defaults null → undefined
|
|
assert.deepEqual(JSON.parse(fetchCalls[0].opts.body), { user_id: "u1", tier: "pro" });
|
|
});
|
|
|
|
test("throws with .status and the relay's error message on a non-OK response", async () => {
|
|
stubFetch(jsonRes({ ok: false, status: 409, body: { error: "already granted" } }));
|
|
await assert.rejects(
|
|
() => setRelayUserTier({ userId: "u1", tier: "max" }),
|
|
(err) => {
|
|
assert.equal(err.status, 409);
|
|
assert.equal(err.message, "already granted");
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
test("falls back to '<label> <status>' when the error body has no .error", async () => {
|
|
stubFetch(jsonRes({ ok: false, status: 500, body: {} }));
|
|
await assert.rejects(
|
|
() => createRelayTierInvoice({ userId: "u1", tier: "pro" }),
|
|
(err) => {
|
|
assert.equal(err.message, "relay tier-invoice 500");
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("operatorGet (reads — null on failure)", () => {
|
|
test("getRelayTierPlans returns parsed JSON on OK", async () => {
|
|
stubFetch(jsonRes({ ok: true, body: { period_days: 30, plans: [{ tier: "pro", sats: 1000 }] } }));
|
|
assert.deepEqual(await getRelayTierPlans(), {
|
|
period_days: 30,
|
|
plans: [{ tier: "pro", sats: 1000 }],
|
|
});
|
|
assert.equal(fetchCalls[0].url, `${BASE}/relay/tier-plans`);
|
|
assert.equal(fetchCalls[0].opts.headers["X-Recap-Operator-Key"], "test-op-key");
|
|
});
|
|
|
|
test("getRelayTierPlans returns null on a non-OK response", async () => {
|
|
stubFetch(jsonRes({ ok: false, status: 503, body: { error: "down" } }));
|
|
assert.equal(await getRelayTierPlans(), null);
|
|
});
|
|
|
|
test("getRelayTierPlans returns null when fetch rejects (network error)", async () => {
|
|
stubFetch(() => {
|
|
throw new TypeError("fetch failed");
|
|
});
|
|
assert.equal(await getRelayTierPlans(), null);
|
|
});
|
|
|
|
test("getRelayUserTier percent-encodes the user id into the path", async () => {
|
|
stubFetch(jsonRes({ ok: true, body: { tier: "max" } }));
|
|
assert.deepEqual(await getRelayUserTier({ userId: "user/with space" }), { tier: "max" });
|
|
assert.equal(fetchCalls[0].url, `${BASE}/relay/user-tier/user%2Fwith%20space`);
|
|
});
|
|
|
|
test("getRelayExpiringSubscriptions builds the within_days/lapsed_days query", async () => {
|
|
stubFetch(jsonRes({ ok: true, body: { subscriptions: [] } }));
|
|
await getRelayExpiringSubscriptions({ withinDays: 5, lapsedDays: 2 });
|
|
const u = new URL(fetchCalls[0].url);
|
|
assert.equal(u.pathname, "/relay/expiring-subscriptions");
|
|
assert.equal(u.searchParams.get("within_days"), "5");
|
|
assert.equal(u.searchParams.get("lapsed_days"), "2");
|
|
});
|
|
});
|
|
|
|
describe("operator helpers: missing-config contract (write throws, read returns null)", () => {
|
|
test("same misconfig → setRelayUserTier throws, getRelayTierPlans returns null, no fetch", async (t) => {
|
|
const saved = process.env.RECAP_RELAY_OPERATOR_KEY;
|
|
delete process.env.RECAP_RELAY_OPERATOR_KEY;
|
|
try {
|
|
if (getRelayOperatorKey()) {
|
|
t.skip("operator key resolved from config.js in this env — can't exercise the missing-key path");
|
|
return;
|
|
}
|
|
stubFetch(() => {
|
|
throw new Error("fetch must not be called when the operator key is absent");
|
|
});
|
|
await assert.rejects(() => setRelayUserTier({ userId: "u1", tier: "pro" }), /operator key not configured/);
|
|
assert.equal(await getRelayTierPlans(), null);
|
|
assert.equal(fetchCalls.length, 0);
|
|
} finally {
|
|
process.env.RECAP_RELAY_OPERATOR_KEY = saved;
|
|
}
|
|
});
|
|
});
|