diff --git a/web/src/app/api/requests/route.test.ts b/web/src/app/api/requests/route.test.ts new file mode 100644 index 0000000..fb74e88 --- /dev/null +++ b/web/src/app/api/requests/route.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { POST, GET } from "./route"; + +function postReq(body: unknown) { + return new Request("http://localhost/api/requests", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("requests API", () => { + it("creates a request with an initial job", async () => { + const res = await POST(postReq({ artist: "Radiohead", album: "In Rainbows" })); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.artist).toBe("Radiohead"); + expect(body.status).toBe("pending"); + expect(body.job.state).toBe("requested"); + expect(body.job.currentStage).toBe("intake"); + }); + + it("rejects a request missing fields", async () => { + const res = await POST(postReq({ artist: "Radiohead" })); + expect(res.status).toBe(400); + }); + + it("lists requests newest first with job state", async () => { + await POST(postReq({ artist: "A", album: "1" })); + await POST(postReq({ artist: "B", album: "2" })); + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.requests).toHaveLength(2); + expect(body.requests[0].artist).toBe("B"); + expect(body.requests[0].job.state).toBe("requested"); + }); +}); diff --git a/web/src/app/api/requests/route.ts b/web/src/app/api/requests/route.ts new file mode 100644 index 0000000..8a7fbdf --- /dev/null +++ b/web/src/app/api/requests/route.ts @@ -0,0 +1,53 @@ +import { prisma } from "@/lib/db"; + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "invalid JSON" }, { status: 400 }); + } + + const { artist, album } = (body ?? {}) as { artist?: unknown; album?: unknown }; + if (typeof artist !== "string" || !artist.trim() || typeof album !== "string" || !album.trim()) { + return Response.json({ error: "artist and album are required" }, { status: 400 }); + } + + const created = await prisma.request.create({ + data: { + artist: artist.trim(), + album: album.trim(), + job: { create: {} }, + }, + include: { job: true }, + }); + + return Response.json( + { + id: created.id, + artist: created.artist, + album: created.album, + status: created.status, + job: { id: created.job!.id, state: created.job!.state, currentStage: created.job!.currentStage }, + }, + { status: 201 }, + ); +} + +export async function GET() { + const requests = await prisma.request.findMany({ + orderBy: { createdAt: "desc" }, + include: { job: true }, + }); + + return Response.json({ + requests: requests.map((r) => ({ + id: r.id, + artist: r.artist, + album: r.album, + status: r.status, + createdAt: r.createdAt, + job: r.job ? { state: r.job.state, currentStage: r.job.currentStage } : null, + })), + }); +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..2a1b805 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,8 @@ +import { beforeEach } from "vitest"; +import { prisma } from "@/lib/db"; + +beforeEach(async () => { + // Job has a cascade FK to Request; delete Job first. + await prisma.job.deleteMany(); + await prisma.request.deleteMany(); +}); diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..e5c066a --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "node", + setupFiles: ["./src/test/setup.ts"], + fileParallelism: false, + }, +});