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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() }));
|
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() }));
|
||||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||||
@@ -51,4 +52,32 @@ describe("wanted API", () => {
|
|||||||
mockSearch.mockRejectedValue(new Error("boom"));
|
mockSearch.mockRejectedValue(new Error("boom"));
|
||||||
expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(502);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
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 } });
|
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 });
|
return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 });
|
||||||
}
|
}
|
||||||
const created = await prisma.monitoredRelease.create({
|
try {
|
||||||
data: {
|
const created = await prisma.monitoredRelease.create({
|
||||||
artistMbid: match.artistMbid,
|
data: {
|
||||||
artistName: match.artistName,
|
artistMbid: match.artistMbid,
|
||||||
rgMbid: match.rgMbid,
|
artistName: match.artistName,
|
||||||
album: match.title,
|
rgMbid: match.rgMbid,
|
||||||
primaryType: match.primaryType,
|
album: match.title,
|
||||||
secondaryTypes: match.secondaryTypes,
|
primaryType: match.primaryType,
|
||||||
firstReleaseDate: match.firstReleaseDate,
|
secondaryTypes: match.secondaryTypes,
|
||||||
monitored: true,
|
firstReleaseDate: match.firstReleaseDate,
|
||||||
},
|
monitored: true,
|
||||||
});
|
},
|
||||||
return Response.json({ id: created.id, album: created.album, monitored: created.monitored }, { status: 201 });
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user