// Unit tests for the resolve-core orchestration (run with `node --test`). Zero-dep, hermetic: // the live Spotify/ollama clients are replaced by injected fake search/classify functions, so no // network and no real timers. Covers the album routing the HTTP server can't unit-test directly. import { test } from "node:test"; import assert from "node:assert/strict"; import { resolveAll, resolveOne, pickDirect, cacheKey } from "./resolve-core.js"; const ARTIST_URL = "https://open.spotify.com/artist/abc"; const ALBUM_URL = "https://open.spotify.com/album/xyz"; // A fake `search` from a { norm(name) -> { artist, album } } table (urls or null). const fakeSearch = (table) => (name) => Promise.resolve(table[name.trim().toLowerCase()] ?? { artist: null, album: null }); // A fake `classify` returning a Map(norm(name) -> kind) from a plain { name: kind } object. const fakeClassify = (verdicts) => (names) => { void names; return Promise.resolve(new Map(Object.entries(verdicts).map(([n, k]) => [n.toLowerCase(), k]))); }; test("pickDirect tie-break: kindHint='album' prefers the album, else artist-first", () => { const both = { artist: ARTIST_URL, album: ALBUM_URL }; assert.deepEqual(pickDirect(both, "album"), { url: ALBUM_URL, kind: "album" }); assert.deepEqual(pickDirect(both, "artist"), { url: ARTIST_URL, kind: "artist" }); assert.deepEqual(pickDirect(both, undefined), { url: ARTIST_URL, kind: "artist" }); // single-sided hits ignore the hint assert.deepEqual(pickDirect({ artist: ARTIST_URL, album: null }, "album"), { url: ARTIST_URL, kind: "artist" }); assert.equal(pickDirect({ artist: null, album: null }, "album"), null); }); test("cacheKey separates the same name by kind hint, defaults to artist", () => { assert.equal(cacheKey("Tool", "album"), "album:tool"); assert.equal(cacheKey("Tool", "artist"), "artist:tool"); assert.equal(cacheKey("Tool", undefined), "artist:tool"); }); test("self-titled artist+album tie-break routes to the kindHint'd URL", async () => { // A self-titled name that is BOTH an exact artist hit and an exact album hit on Spotify. const search = fakeSearch({ "bad religion": { artist: ARTIST_URL, album: ALBUM_URL } }); // kindHint='album' -> the album URL. const asAlbum = await resolveOne("Bad Religion", { search, kindHint: "album", allowFallback: true }); assert.equal(asAlbum.kind, "album"); assert.equal(asAlbum.spotify, ALBUM_URL); assert.deepEqual(asAlbum.primary, { platform: "spotify", url: ALBUM_URL }); // undefined and 'artist' both -> the artist URL (artist-first default). for (const hint of [undefined, "artist"]) { const asArtist = await resolveOne("Bad Religion", { search, kindHint: hint, allowFallback: true }); assert.equal(asArtist.kind, "artist", `hint=${hint}`); assert.equal(asArtist.spotify, ARTIST_URL); assert.deepEqual(asArtist.primary, { platform: "spotify", url: ARTIST_URL }); } }); test("album confirmed by the LLM but absent from Spotify falls back to a Google search", async () => { // The LLM gate confirms the name is an album; Spotify has no exact hit -> Google fallback. const search = fakeSearch({}); // nothing on Spotify const classify = fakeClassify({ "Some Local Release": "album" }); const out = await resolveAll(["Some Local Release"], { search, classify }); const r = out["Some Local Release"]; assert.ok(r, "confirmed name should resolve, not drop"); assert.equal(r.kind, "album"); assert.equal(r.primary.platform, "google"); assert.match(r.primary.url, /^https:\/\/www\.google\.com\/search\?q=/); assert.ok(r.primary.url.includes("bandcamp")); assert.equal(r.spotify, undefined); }); test("LLM confirms artist+album for two names and routes each kind's Spotify lookup", async () => { const search = fakeSearch({ devourment: { artist: ARTIST_URL, album: null }, "reek of putrefaction": { artist: null, album: ALBUM_URL }, }); const classify = fakeClassify({ Devourment: "artist", "Reek of Putrefaction": "album" }); const out = await resolveAll(["Devourment", "Reek of Putrefaction"], { search, classify }); assert.equal(out["Devourment"].kind, "artist"); assert.equal(out["Devourment"].spotify, ARTIST_URL); assert.equal(out["Reek of Putrefaction"].kind, "album"); assert.equal(out["Reek of Putrefaction"].spotify, ALBUM_URL); }); test("names the LLM did not confirm are dropped (null)", async () => { const search = fakeSearch({ "real band": { artist: ARTIST_URL, album: null } }); const classify = fakeClassify({ "Real Band": "artist" }); // "the best part" not in the map const out = await resolveAll(["Real Band", "the best part"], { search, classify }); assert.ok(out["Real Band"]); assert.equal(out["the best part"], null); }); test("cache-key kind separation: same name as artist vs album does not cross-contaminate", async () => { const cache = new Map(); // One name exists on Spotify as BOTH; the kind hint must keep the two lookups distinct. const search = fakeSearch({ "self titled": { artist: ARTIST_URL, album: ALBUM_URL } }); const asArtist = await resolveOne("Self Titled", { search, cache, kindHint: "artist", allowFallback: true }); const asAlbum = await resolveOne("Self Titled", { search, cache, kindHint: "album", allowFallback: true }); assert.equal(asArtist.spotify, ARTIST_URL); assert.equal(asArtist.kind, "artist"); assert.equal(asAlbum.spotify, ALBUM_URL); assert.equal(asAlbum.kind, "album"); // Two distinct cache entries, keyed by kind — no cross-contamination. assert.equal(cache.size, 2); assert.deepEqual(cache.get("artist:self titled"), { url: ARTIST_URL, kind: "artist" }); assert.deepEqual(cache.get("album:self titled"), { url: ALBUM_URL, kind: "album" }); }); test("with no LLM gate (classify null) the Spotify exact-match IS the gate; unconfirmed -> null", async () => { // On Spotify -> links; not on Spotify and no confirmation -> null (no Google fallback). const search = fakeSearch({ devourment: { artist: ARTIST_URL, album: null } }); const out = await resolveAll(["Devourment", "Not A Band"], { search, classify: null }); assert.equal(out["Devourment"].kind, "artist"); assert.equal(out["Devourment"].spotify, ARTIST_URL); assert.equal(out["Not A Band"], null); // not confirmed, not on Spotify -> dropped }); test("LLM-down (classify throws) falls back to the Spotify exact-match gate", async () => { const search = fakeSearch({ devourment: { artist: ARTIST_URL, album: null } }); const classify = () => Promise.reject(new Error("ollama down")); const out = await resolveAll(["Devourment", "Not A Band"], { search, classify }); assert.equal(out["Devourment"].spotify, ARTIST_URL); // Spotify hit still links assert.equal(out["Not A Band"], null); // no fallback once the LLM gate is gone }); test("resolveAll dedups, trims, drops blanks, and caps the candidate list", async () => { const search = fakeSearch({ tool: { artist: ARTIST_URL, album: null } }); const out = await resolveAll(["Tool", " Tool ", "", " "], { search, classify: null }); assert.deepEqual(Object.keys(out), ["Tool"]); // " Tool " collapses to "Tool"; blanks gone assert.equal(out["Tool"].spotify, ARTIST_URL); }); test("resolveOne swallows a search error and yields null for that name", async () => { const search = () => Promise.reject(new Error("spotify 503")); const r = await resolveOne("Tool", { search, allowFallback: true }); assert.equal(r, null); });