feat: MusicBrainz proxy API routes (artist search + discography browse)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-11 13:45:24 +02:00
parent 341da71505
commit 8e97796bad
4 changed files with 91 additions and 0 deletions
@@ -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);
});
});
@@ -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 });
}
}
+38
View File
@@ -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);
});
});
+13
View File
@@ -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 });
}
}