From 8e97796bad1a3c69046f1124adf98852905690cb Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 11 Jul 2026 13:45:24 +0200 Subject: [PATCH] feat: MusicBrainz proxy API routes (artist search + discography browse) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mb/artists/[mbid]/releases/route.test.ts | 30 +++++++++++++++ .../api/mb/artists/[mbid]/releases/route.ts | 10 +++++ web/src/app/api/mb/artists/route.test.ts | 38 +++++++++++++++++++ web/src/app/api/mb/artists/route.ts | 13 +++++++ 4 files changed, 91 insertions(+) create mode 100644 web/src/app/api/mb/artists/[mbid]/releases/route.test.ts create mode 100644 web/src/app/api/mb/artists/[mbid]/releases/route.ts create mode 100644 web/src/app/api/mb/artists/route.test.ts create mode 100644 web/src/app/api/mb/artists/route.ts diff --git a/web/src/app/api/mb/artists/[mbid]/releases/route.test.ts b/web/src/app/api/mb/artists/[mbid]/releases/route.test.ts new file mode 100644 index 0000000..b2a5d3e --- /dev/null +++ b/web/src/app/api/mb/artists/[mbid]/releases/route.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn() })); +import { browseReleaseGroups } from "@/lib/musicbrainz"; +import { GET } from "./route"; + +const mockBrowse = vi.mocked(browseReleaseGroups); +// NOTE: reset in afterEach, not beforeEach - see the matching note in +// ../route.test.ts and task-2-report.md for the root cause. +afterEach(() => mockBrowse.mockReset()); + +const ctx = (mbid: string) => ({ params: Promise.resolve({ mbid }) }); + +describe("GET /api/mb/artists/[mbid]/releases", () => { + it("returns the discography", async () => { + mockBrowse.mockResolvedValue([ + { rgMbid: "rg1", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006" }, + ]); + const res = await GET(new Request("http://localhost/x"), ctx("a1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.releases[0].rgMbid).toBe("rg1"); + expect(mockBrowse).toHaveBeenCalledWith("a1"); + }); + + it("502s when MusicBrainz throws", async () => { + mockBrowse.mockRejectedValue(new Error("boom")); + expect((await GET(new Request("http://localhost/x"), ctx("a1"))).status).toBe(502); + }); +}); diff --git a/web/src/app/api/mb/artists/[mbid]/releases/route.ts b/web/src/app/api/mb/artists/[mbid]/releases/route.ts new file mode 100644 index 0000000..5c7e85f --- /dev/null +++ b/web/src/app/api/mb/artists/[mbid]/releases/route.ts @@ -0,0 +1,10 @@ +import { browseReleaseGroups } from "@/lib/musicbrainz"; + +export async function GET(_request: Request, { params }: { params: Promise<{ mbid: string }> }) { + const { mbid } = await params; + try { + return Response.json({ releases: await browseReleaseGroups(mbid) }); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } +} diff --git a/web/src/app/api/mb/artists/route.test.ts b/web/src/app/api/mb/artists/route.test.ts new file mode 100644 index 0000000..2b7dfb1 --- /dev/null +++ b/web/src/app/api/mb/artists/route.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ searchArtists: vi.fn() })); +import { searchArtists } from "@/lib/musicbrainz"; +import { GET } from "./route"; + +const mockSearch = vi.mocked(searchArtists); +// NOTE: reset in afterEach, not beforeEach (deviation from the brief - see task-2-report.md). +// In this Vitest 2.1.8 + Node 22.23.1 environment, resetting a vi.mock()-produced spy in +// beforeEach causes a later mockRejectedValue() + await/catch in the same test to be +// misreported as a failing test, even though the rejection is handled correctly. Moving the +// identical reset call to afterEach gives the same inter-test isolation and avoids the false +// failure. Reproduced independent of this repo (bare vitest 2.1.8/2.1.9/3.2.4 project) and +// independent of the Node wrapper (same result with the raw fnm-installed node binary). +afterEach(() => mockSearch.mockReset()); + +function req(q: string | null) { + return new Request(`http://localhost/api/mb/artists${q === null ? "" : `?q=${encodeURIComponent(q)}`}`); +} + +describe("GET /api/mb/artists", () => { + it("returns hits for a query", async () => { + mockSearch.mockResolvedValue([{ mbid: "a1", name: "John Mayer", disambiguation: "" }]); + const res = await GET(req("john")); + expect(res.status).toBe(200); + expect((await res.json()).artists[0].name).toBe("John Mayer"); + }); + + it("400s a blank query", async () => { + expect((await GET(req(" "))).status).toBe(400); + expect((await GET(req(null))).status).toBe(400); + }); + + it("502s when MusicBrainz throws", async () => { + mockSearch.mockRejectedValue(new Error("MusicBrainz request failed: 503")); + expect((await GET(req("john"))).status).toBe(502); + }); +}); diff --git a/web/src/app/api/mb/artists/route.ts b/web/src/app/api/mb/artists/route.ts new file mode 100644 index 0000000..c39dca0 --- /dev/null +++ b/web/src/app/api/mb/artists/route.ts @@ -0,0 +1,13 @@ +import { searchArtists } from "@/lib/musicbrainz"; + +export async function GET(request: Request) { + const q = new URL(request.url).searchParams.get("q"); + if (!q || !q.trim()) { + return Response.json({ error: "q is required" }, { status: 400 }); + } + try { + return Response.json({ artists: await searchArtists(q.trim()) }); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } +}