import { describe, it, expect, vi, afterEach } from "vitest"; import { searchArtists, browseReleaseGroups, searchReleaseGroup, stripEditionQualifiers } from "./musicbrainz"; function jsonResponse(data: unknown) { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) } as Response); } afterEach(() => vi.unstubAllGlobals()); describe("musicbrainz module", () => { it("searchArtists maps hits and sends a User-Agent", async () => { const fetchMock = vi.fn((_url: string, _init?: RequestInit) => jsonResponse({ artists: [{ id: "a1", name: "John Mayer", disambiguation: "US singer" }] }), ); vi.stubGlobal("fetch", fetchMock); const hits = await searchArtists("john ma"); expect(hits).toEqual([{ mbid: "a1", name: "John Mayer", disambiguation: "US singer" }]); const [, init] = fetchMock.mock.calls[0]; expect((init as RequestInit).headers).toMatchObject({ "User-Agent": expect.stringContaining("Lyra") }); }); it("browseReleaseGroups paginates until release-group-count is reached", async () => { const page1 = { "release-group-count": 2, "release-groups": [{ id: "rg1", title: "A", "primary-type": "Album", "secondary-types": [], "first-release-date": "2001" }] }; const page2 = { "release-group-count": 2, "release-groups": [{ id: "rg2", title: "B", "primary-type": "EP", "secondary-types": ["Live"], "first-release-date": "" }] }; const fetchMock = vi.fn().mockReturnValueOnce(jsonResponse(page1)).mockReturnValueOnce(jsonResponse(page2)); vi.stubGlobal("fetch", fetchMock); const rels = await browseReleaseGroups("a1"); expect(fetchMock).toHaveBeenCalledTimes(2); expect(rels).toEqual([ { rgMbid: "rg1", title: "A", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2001" }, { rgMbid: "rg2", title: "B", primaryType: "EP", secondaryTypes: ["Live"], firstReleaseDate: null }, ]); }); it("throws on a non-2xx MusicBrainz response", async () => { vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status: 503 } as Response))); await expect(searchArtists("x")).rejects.toThrow(/503/); }); it("searchReleaseGroup returns the top match with artist credit or null", async () => { vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [ { id: "rg9", title: "Continuum", "primary-type": "Album", "secondary-types": [], "first-release-date": "2006-09-12", "artist-credit": [{ artist: { id: "a1", name: "John Mayer" } }] }, ] }))); const m = await searchReleaseGroup("John Mayer", "Continuum"); expect(m).toEqual({ rgMbid: "rg9", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", artistMbid: "a1", artistName: "John Mayer" }); vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [] }))); expect(await searchReleaseGroup("Nobody", "Nothing")).toBeNull(); }); it("prefers a clean studio Album over a same-title Single/comp that MB ranks first", async () => { // MB ties the "Thriller" single with the album at the same score and lists the single // first; we must resolve to the studio album, not the single. vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [ { id: "single", title: "Thriller", "primary-type": "Single", "secondary-types": [], "first-release-date": "1983", "artist-credit": [{ artist: { id: "mj", name: "Michael Jackson" } }] }, { id: "comp", title: "Thriller", "primary-type": "Album", "secondary-types": ["Compilation"], "first-release-date": "2015", "artist-credit": [{ artist: { id: "mj", name: "Michael Jackson" } }] }, { id: "album", title: "Thriller", "primary-type": "Album", "secondary-types": [], "first-release-date": "1982", "artist-credit": [{ artist: { id: "mj", name: "Michael Jackson" } }] }, ] }))); const m = await searchReleaseGroup("Michael Jackson", "Thriller"); expect(m?.rgMbid).toBe("album"); expect(m?.primaryType).toBe("Album"); }); it("falls back to a Single when no album-type release group matches", async () => { vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [ { id: "single-only", title: "Standalone", "primary-type": "Single", "secondary-types": [], "first-release-date": "2020", "artist-credit": [{ artist: { id: "a1", name: "Artist" } }] }, ] }))); expect((await searchReleaseGroup("Artist", "Standalone"))?.rgMbid).toBe("single-only"); }); it("escapes quotes in the release-group query", async () => { const fetchMock = vi.fn((_url: string, _init?: RequestInit) => jsonResponse({ "release-groups": [] })); vi.stubGlobal("fetch", fetchMock); await searchReleaseGroup("AC/DC", 'Back in "Black"'); const [url] = fetchMock.mock.calls[0]; // the decoded query must contain the escaped quote, not a bare one that closes the phrase expect(decodeURIComponent(url)).toContain('Back in \\"Black\\"'); }); it("retries with an edition-stripped title when the exact phrase finds nothing", async () => { // Last.fm hands us "The Stranger (Legacy Edition)"; the strict phrase for that returns 0, // then the stripped "The Stranger" resolves to the release group. const fetchMock = vi .fn() .mockReturnValueOnce(jsonResponse({ "release-groups": [] })) .mockReturnValueOnce(jsonResponse({ "release-groups": [ { id: "rg-str", title: "The Stranger", "primary-type": "Album", "secondary-types": [], "first-release-date": "1977", "artist-credit": [{ artist: { id: "bj", name: "Billy Joel" } }] }, ] })); vi.stubGlobal("fetch", fetchMock); const m = await searchReleaseGroup("Billy Joel", "The Stranger (Legacy Edition)"); expect(m?.rgMbid).toBe("rg-str"); // first attempt used the full title, second used the stripped title expect(decodeURIComponent(fetchMock.mock.calls[0][0])).toContain("The Stranger (Legacy Edition)"); expect(decodeURIComponent(fetchMock.mock.calls[1][0])).toContain('releasegroup:"The Stranger"'); }); }); describe("stripEditionQualifiers", () => { it("removes trailing edition parentheticals and suffixes", () => { expect(stripEditionQualifiers("The Stranger (Legacy Edition)")).toBe("The Stranger"); expect(stripEditionQualifiers("21 (Deluxe)")).toBe("21"); expect(stripEditionQualifiers("Nevermind - Remastered")).toBe("Nevermind"); expect(stripEditionQualifiers("Rumours - Remastered 2011")).toBe("Rumours"); expect(stripEditionQualifiers("Blue [Deluxe Edition]")).toBe("Blue"); expect(stripEditionQualifiers("Vitalogy (2016 Remaster)")).toBe("Vitalogy"); }); it("leaves non-edition titles untouched", () => { expect(stripEditionQualifiers("(What's the Story) Morning Glory?")).toBe("(What's the Story) Morning Glory?"); expect(stripEditionQualifiers("Continuum")).toBe("Continuum"); expect(stripEditionQualifiers("Songs in the Key of Life")).toBe("Songs in the Key of Life"); expect(stripEditionQualifiers("Give Up")).toBe("Give Up"); // "Up" is not a stray edition word }); });