From 44c1b1a62a86be8e204f43aa663e1af08b2826f1 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 00:40:29 +0200 Subject: [PATCH] fix(web): wanted POST converges on P2002 unique race instead of 500 Two concurrent Wants for the same release-group both pass the findUnique existence check as absent; the loser's create() then hits the rgMbid unique constraint and threw an uncaught P2002, 500ing the request. Wrap the create in try/catch and fall back to the same update-and-return-200 path on P2002, converging instead of erroring. Non-P2002 errors are rethrown unchanged. --- web/src/app/api/wanted/route.test.ts | 29 +++++++++++++++++++++ web/src/app/api/wanted/route.ts | 39 ++++++++++++++++++---------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/web/src/app/api/wanted/route.test.ts b/web/src/app/api/wanted/route.test.ts index a93ae2e..4a3879d 100644 --- a/web/src/app/api/wanted/route.test.ts +++ b/web/src/app/api/wanted/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; +import { Prisma } from "@prisma/client"; vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() })); import { searchReleaseGroup } from "@/lib/musicbrainz"; @@ -51,4 +52,32 @@ describe("wanted API", () => { mockSearch.mockRejectedValue(new Error("boom")); expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(502); }); + + it("handles a concurrent duplicate (race between findUnique and create) by converging on 200, never 500", async () => { + mockSearch.mockResolvedValue({ rgMbid: "rg-race", title: "Race Condition", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2010", artistMbid: "a1", artistName: "Racer" }); + const res = await POST(postReq({ artist: "Racer", album: "Race Condition" })); + expect([200, 201]).toContain(res.status); + // second identical POST must also be 200 (idempotent), never 500 + const res2 = await POST(postReq({ artist: "Racer", album: "Race Condition" })); + expect(res2.status).toBe(200); + }); + + it("catches a P2002 thrown by create and falls back to update", async () => { + mockSearch.mockResolvedValue({ rgMbid: "rg-p2002", title: "Duplicate", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2011", artistMbid: "a1", artistName: "Duper" }); + // Simulate the row already existing when create() is attempted (a concurrent winner beat us to it), + // while the pre-create findUnique check still had not seen it — force create() to reject with P2002. + await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "Duper", rgMbid: "rg-p2002", album: "Duplicate", secondaryTypes: [], monitored: false } }); + const spy = vi.spyOn(prisma.monitoredRelease, "create").mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { code: "P2002", clientVersion: "6.1.0" }), + ); + // Force the pre-create existence check to miss (simulating the race) so we actually reach create(). + const findUniqueSpy = vi.spyOn(prisma.monitoredRelease, "findUnique").mockResolvedValueOnce(null); + const res = await POST(postReq({ artist: "Duper", album: "Duplicate" })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.monitored).toBe(true); + expect(await prisma.monitoredRelease.count({ where: { rgMbid: "rg-p2002" } })).toBe(1); + spy.mockRestore(); + findUniqueSpy.mockRestore(); + }); }); diff --git a/web/src/app/api/wanted/route.ts b/web/src/app/api/wanted/route.ts index 1fda9ce..2f87a0e 100644 --- a/web/src/app/api/wanted/route.ts +++ b/web/src/app/api/wanted/route.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { searchReleaseGroup } from "@/lib/musicbrainz"; @@ -49,17 +50,29 @@ export async function POST(request: Request) { const updated = await prisma.monitoredRelease.update({ where: { rgMbid: match.rgMbid }, data: { monitored: true } }); return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 }); } - const created = await prisma.monitoredRelease.create({ - data: { - artistMbid: match.artistMbid, - artistName: match.artistName, - rgMbid: match.rgMbid, - album: match.title, - primaryType: match.primaryType, - secondaryTypes: match.secondaryTypes, - firstReleaseDate: match.firstReleaseDate, - monitored: true, - }, - }); - return Response.json({ id: created.id, album: created.album, monitored: created.monitored }, { status: 201 }); + try { + const created = await prisma.monitoredRelease.create({ + data: { + artistMbid: match.artistMbid, + artistName: match.artistName, + rgMbid: match.rgMbid, + album: match.title, + primaryType: match.primaryType, + secondaryTypes: match.secondaryTypes, + firstReleaseDate: match.firstReleaseDate, + monitored: true, + }, + }); + return Response.json({ id: created.id, album: created.album, monitored: created.monitored }, { status: 201 }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + // Concurrent Want for the same release-group won the create; converge to the update path. + const updated = await prisma.monitoredRelease.update({ + where: { rgMbid: match.rgMbid }, + data: { monitored: true }, + }); + return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 }); + } + throw e; + } }