// Unit tests for the Spotify token single-flight + search-with-retry helpers (run with // `node --test`). Zero-dep, hermetic: a counting fake fetchToken/doSearch + an injected clock, // no network and no real timers (sleep is injected as a zero-delay stub). import { test } from "node:test"; import assert from "node:assert/strict"; import { makeSpotifyToken, makeSpotifySearch, retryAfterMs } from "./spotify.js"; // A controllable clock: starts at 0, advance() bumps it. function fakeClock(start = 0) { let t = start; return { now: () => t, advance: (ms) => { t += ms; } }; } // A fetchToken that counts calls and yields a token + expires_in; optionally defers via a gate // so concurrent callers actually overlap on the in-flight promise before it settles. function countingFetchToken({ expiresIn = 3600, token = "tok", gate = null } = {}) { const state = { calls: 0 }; const fetchToken = async () => { state.calls += 1; const n = state.calls; if (gate) await gate; // hold all in-flight calls open until the test releases the gate return { access_token: `${token}-${n}`, expires_in: expiresIn }; }; return { fetchToken, state }; } test("single-flight: N concurrent cold getToken share exactly one fetch", async () => { let release; const gate = new Promise((r) => { release = r; }); const { fetchToken, state } = countingFetchToken({ gate }); const clock = fakeClock(); const { getToken } = makeSpotifyToken({ fetchToken, now: clock.now }); // Fire 100 concurrent cold lookups; they all enter while the fetch is gated open. const pending = Array.from({ length: 100 }, () => getToken()); release(); // let the single in-flight fetch resolve const tokens = await Promise.all(pending); assert.equal(state.calls, 1, "exactly one POST /api/token for 100 concurrent cold callers"); assert.ok(tokens.every((t) => t === "tok-1"), "every caller got the same token"); }); test("warm hit serves the cached value without any fetch", async () => { const { fetchToken, state } = countingFetchToken(); const clock = fakeClock(); const { getToken } = makeSpotifyToken({ fetchToken, now: clock.now }); const a = await getToken(); const b = await getToken(); // still fresh -> no new fetch assert.equal(a, "tok-1"); assert.equal(b, "tok-1"); assert.equal(state.calls, 1); }); test("re-fetch after expiry: advancing the clock past exp triggers exactly one more fetch", async () => { const { fetchToken, state } = countingFetchToken({ expiresIn: 3600 }); const clock = fakeClock(); const { getToken } = makeSpotifyToken({ fetchToken, now: clock.now }); await getToken(); assert.equal(state.calls, 1); // exp = now + (3600-60)*1000 = 3_540_000 ms. Advance just past it. clock.advance(3_540_001); const t2 = await getToken(); assert.equal(state.calls, 2, "one more fetch after expiry"); assert.equal(t2, "tok-2", "the fresh token is served"); }); test("failure clears the in-flight promise: a rejected fetch does not poison the cache", async () => { let failNext = true; const state = { calls: 0 }; const fetchToken = async () => { state.calls += 1; if (failNext) { failNext = false; throw new Error("token http 503"); } return { access_token: "tok-ok", expires_in: 3600 }; }; const clock = fakeClock(); const { getToken } = makeSpotifyToken({ fetchToken, now: clock.now }); await assert.rejects(getToken(), /token http 503/); // The poisoned promise must be cleared -> the next call retries and succeeds. const t = await getToken(); assert.equal(t, "tok-ok"); assert.equal(state.calls, 2, "the rejected fetch did not stick — a fresh fetch ran"); }); // --- search-with-retry -------------------------------------------------------- // A doSearch that returns a scripted sequence of { ok, status, headers?, body? } responses, // counting calls and recording which token each call saw. function scriptedSearch(responses) { const state = { calls: 0, tokensSeen: [] }; const doSearch = async (_name, token) => { state.tokensSeen.push(token); const r = responses[state.calls] ?? responses[responses.length - 1]; state.calls += 1; return r; }; return { doSearch, state }; } const okResp = (body) => ({ ok: true, status: 200, body }); const errResp = (status, headers) => ({ ok: false, status, headers, body: null }); const hdr = (obj) => ({ get: (k) => obj[k.toLowerCase()] ?? null }); test("401 retry: a stale-token 401 invalidates, refreshes, and retries once with a fresh token", async () => { const { fetchToken, state: tokState } = countingFetchToken(); const tokens = makeSpotifyToken({ fetchToken, now: fakeClock().now }); const { doSearch, state } = scriptedSearch([errResp(401), okResp({ hit: true })]); const search = makeSpotifySearch({ doSearch, tokens, sleep: async () => {} }); const body = await search("Tool"); assert.deepEqual(body, { hit: true }); assert.equal(state.calls, 2, "exactly one retry after the 401"); assert.equal(tokState.calls, 2, "the token was invalidated and re-fetched"); assert.equal(state.tokensSeen[0], "tok-1"); assert.equal(state.tokensSeen[1], "tok-2", "the retry used the FRESH token"); }); test("persistent 401: a second 401 throws (no infinite retry), so resolve-core nulls the candidate", async () => { const { fetchToken } = countingFetchToken(); const tokens = makeSpotifyToken({ fetchToken, now: fakeClock().now }); const { doSearch, state } = scriptedSearch([errResp(401), errResp(401)]); const search = makeSpotifySearch({ doSearch, tokens, sleep: async () => {} }); await assert.rejects(search("Tool"), /spotify search http 401/); assert.equal(state.calls, 2, "retried exactly once, then gave up"); }); test("429 backoff: a Retry-After 429 waits (bounded) then retries once and succeeds", async () => { const { fetchToken } = countingFetchToken(); const tokens = makeSpotifyToken({ fetchToken, now: fakeClock().now }); const { doSearch, state } = scriptedSearch([errResp(429, hdr({ "retry-after": "1" })), okResp({ hit: true })]); let slept = null; const search = makeSpotifySearch({ doSearch, tokens, sleep: async (ms) => { slept = ms; } }); const body = await search("Tool"); assert.deepEqual(body, { hit: true }); assert.equal(state.calls, 2, "one retry after the 429"); assert.equal(slept, 1000, "honored Retry-After: 1 second"); }); test("429 retry-after is capped, and a persistent 429 throws without unbounded retry", async () => { const { fetchToken } = countingFetchToken(); const tokens = makeSpotifyToken({ fetchToken, now: fakeClock().now }); // Retry-After: 9999s would be huge — must cap at maxRetryAfterMs (2000 by default). const { doSearch, state } = scriptedSearch([errResp(429, hdr({ "retry-after": "9999" })), errResp(429)]); let slept = null; const search = makeSpotifySearch({ doSearch, tokens, sleep: async (ms) => { slept = ms; } }); await assert.rejects(search("Tool"), /spotify search http 429/); assert.equal(slept, 2000, "the wait was capped at maxRetryAfterMs"); assert.equal(state.calls, 2, "retried exactly once, then gave up"); }); test("happy path: a 200 on the first try returns the body with no retry", async () => { const { fetchToken } = countingFetchToken(); const tokens = makeSpotifyToken({ fetchToken, now: fakeClock().now }); const { doSearch, state } = scriptedSearch([okResp({ artists: { items: [] } })]); const search = makeSpotifySearch({ doSearch, tokens, sleep: async () => {} }); const body = await search("Tool"); assert.deepEqual(body, { artists: { items: [] } }); assert.equal(state.calls, 1, "no retry on a clean 200"); }); test("retryAfterMs parses, caps, and rejects garbage", () => { assert.equal(retryAfterMs(hdr({ "retry-after": "2" }), 5000), 2000); assert.equal(retryAfterMs(hdr({ "retry-after": "100" }), 5000), 5000, "capped at maxMs"); assert.equal(retryAfterMs(hdr({ "retry-after": "junk" }), 5000), null); assert.equal(retryAfterMs(hdr({ "retry-after": "-1" }), 5000), null); assert.equal(retryAfterMs(hdr({}), 5000), null, "absent header -> null"); assert.equal(retryAfterMs(null, 5000), null, "no headers object -> null"); });