diff --git a/app/(ui)/deck/[id]/play/__tests__/playtest-reducer.test.ts b/app/(ui)/deck/[id]/play/__tests__/playtest-reducer.test.ts index 834287f..0be76a7 100644 --- a/app/(ui)/deck/[id]/play/__tests__/playtest-reducer.test.ts +++ b/app/(ui)/deck/[id]/play/__tests__/playtest-reducer.test.ts @@ -246,6 +246,18 @@ describe("playtestReducer", () => { const next = playtestReducer(state, { type: "tapCard", id: "bf-1" }); expect(next.battlefield[0]!.tapped).toBe(true); }); + + it("leaves other battlefield cards untapped", () => { + const state = makeState({ + battlefield: [ + makeCard("bf-1", { zone: "battlefield" }), + makeCard("bf-2", { zone: "battlefield" }), + ], + }); + const next = playtestReducer(state, { type: "tapCard", id: "bf-1" }); + expect(next.battlefield[0]!.tapped).toBe(true); + expect(next.battlefield[1]!.tapped).toBe(false); + }); }); describe("untapCard", () => { @@ -293,6 +305,14 @@ describe("playtestReducer", () => { const next = playtestReducer(state, { type: "sendTo", id: "nonexistent", zone: "exile" }); expect(next.exile).toHaveLength(0); }); + + it("preserves tapped status when moving onto the battlefield", () => { + const state = makeState({ + hand: [makeCard("hand-1", { zone: "hand", tapped: true })], + }); + const next = playtestReducer(state, { type: "sendTo", id: "hand-1", zone: "battlefield" }); + expect(next.battlefield[next.battlefield.length - 1]!.tapped).toBe(true); + }); }); describe("castCommander", () => { @@ -317,6 +337,15 @@ describe("playtestReducer", () => { const next = playtestReducer(state, { type: "castCommander", idx: 5 }); expect(next).toBe(state); }); + + it("leaves other commanders untouched", () => { + const a: CommanderEntry = { card: makeCard("cmd-a"), castCount: 0 }; + const b: CommanderEntry = { card: makeCard("cmd-b"), castCount: 2 }; + const state = makeState({ commanders: [a, b] }); + const next = playtestReducer(state, { type: "castCommander", idx: 0 }); + expect(next.commanders[0]!.castCount).toBe(1); + expect(next.commanders[1]!.castCount).toBe(2); + }); }); describe("decrementTax", () => { @@ -333,6 +362,15 @@ describe("playtestReducer", () => { const next = playtestReducer(state, { type: "decrementTax", idx: 0 }); expect(next.commanders[0]!.castCount).toBe(0); }); + + it("leaves other commanders untouched", () => { + const a: CommanderEntry = { card: makeCard("cmd-a"), castCount: 3 }; + const b: CommanderEntry = { card: makeCard("cmd-b"), castCount: 1 }; + const state = makeState({ commanders: [a, b] }); + const next = playtestReducer(state, { type: "decrementTax", idx: 0 }); + expect(next.commanders[0]!.castCount).toBe(2); + expect(next.commanders[1]!.castCount).toBe(1); + }); }); describe("nextTurn", () => { diff --git a/app/(ui)/deck/[id]/play/playtest-reducer.ts b/app/(ui)/deck/[id]/play/playtest-reducer.ts index 66331c1..0ff6a78 100644 --- a/app/(ui)/deck/[id]/play/playtest-reducer.ts +++ b/app/(ui)/deck/[id]/play/playtest-reducer.ts @@ -191,18 +191,14 @@ const handlers: ActionHandlers = { return withPrev(baseState, { ...snapshot(baseState), library: [...top, ...rest, ...bottom] }); } - if (mode === "surveil") { - const top = pick((d) => d !== "graveyard"); - const toGrave = pick((d) => d === "graveyard"); - return withPrev(baseState, { - ...snapshot(baseState), - library: [...top, ...rest], - graveyard: [...state.graveyard, ...toGrave.map((c) => ({ ...c, zone: "graveyard" as PlaytestZone }))], - }); - } - - /* v8 ignore next -- LookaheadMode is exhaustive; this branch is unreachable */ - return state; + // mode === "surveil" (LookaheadMode is exhaustive; scry already returned) + const top = pick((d) => d !== "graveyard"); + const toGrave = pick((d) => d === "graveyard"); + return withPrev(baseState, { + ...snapshot(baseState), + library: [...top, ...rest], + graveyard: [...state.graveyard, ...toGrave.map((c) => ({ ...c, zone: "graveyard" as PlaytestZone }))], + }); }, moveToTop: (state, action) => { diff --git a/app/(ui)/decks/explore/__tests__/actions.test.ts b/app/(ui)/decks/explore/__tests__/actions.test.ts index 79c94b1..987252e 100644 --- a/app/(ui)/decks/explore/__tests__/actions.test.ts +++ b/app/(ui)/decks/explore/__tests__/actions.test.ts @@ -55,6 +55,32 @@ describe("loadMorePublicDecks", () => { expect(out.hasMore).toBe(false); }); + it("forwards every optional filter when all are defined", async () => { + mockGet.mockResolvedValue({ decks: [], total: 0 } as never); + await loadMorePublicDecks( + { + q: "dragon", + format: Format.COMMANDER, + colors: ["W", "U"], + commander: "Niv-Mizzet", + source: "official", + sort: "created", + }, + 1, + 20, + ); + expect(mockGet).toHaveBeenCalledWith({ + page: 1, + pageSize: 20, + q: "dragon", + format: Format.COMMANDER, + colors: ["W", "U"], + commander: "Niv-Mizzet", + source: "official", + sort: "created", + }); + }); + it("hasMore=true when loaded < total at the page boundary", async () => { mockGet.mockResolvedValue({ decks: [deck({ id: "d1" }), deck({ id: "d2" })], @@ -112,3 +138,32 @@ describe("loadMorePublicDecks", () => { expect(out.decks[0]!.releasedAt).toBeNull(); }); }); + +describe("loadMorePublicDecks — arg validation", () => { + beforeEach(() => { + mockGet.mockResolvedValue({ decks: [], total: 0 } as never); + }); + + it("clamps pageSize=1e6 to 48", async () => { + await loadMorePublicDecks({}, 1, 1e6); + expect(mockGet).toHaveBeenCalledWith( + expect.objectContaining({ pageSize: 48 }), + ); + }); + + it("clamps page=NaN to 1", async () => { + await loadMorePublicDecks({}, NaN, 24); + expect(mockGet).toHaveBeenCalledWith( + expect.objectContaining({ page: 1 }), + ); + }); + + it("ignores an invalid format value without throwing", async () => { + await expect( + loadMorePublicDecks({ format: "BOGUS" as unknown as Format }, 1, 24), + ).resolves.not.toThrow(); + // BOGUS is stripped; format key should be absent or undefined + const call = mockGet.mock.calls[0]?.[0]; + expect(call?.format).toBeUndefined(); + }); +}); diff --git a/app/(ui)/decks/explore/actions.ts b/app/(ui)/decks/explore/actions.ts index 3351435..eba65f5 100644 --- a/app/(ui)/decks/explore/actions.ts +++ b/app/(ui)/decks/explore/actions.ts @@ -1,11 +1,46 @@ "use server"; +import { z } from "zod"; import { getPublicDecksWithPreview, selectDeckPreviewImages, type PublicDeckWithPreview, } from "@/lib/deck/queries"; -import { type Format } from "@/lib/generated/prisma/enums"; +import { Format } from "@/lib/generated/prisma/enums"; + +const argsSchema = z.object({ + page: z + .number() + .int() + .positive() + .transform((v) => Math.min(v, 10_000)) + .catch(1), + pageSize: z + .number() + .int() + .positive() + .transform((v) => Math.min(v, 48)) + .catch(24), + filters: z + .object({ + q: z.string().max(200).optional(), + format: z.enum(Format).optional().catch(undefined), + colors: z + .array(z.enum(["W", "U", "B", "R", "G"])) + .optional() + .catch(undefined), + commander: z.string().max(200).optional(), + source: z + .enum(["all", "community", "official"]) + .optional() + .catch(undefined), + sort: z + .enum(["updated", "created", "released"]) + .optional() + .catch(undefined), + }) + .default({}), +}); export interface ParsedFilters { q?: string; @@ -61,13 +96,21 @@ export async function loadMorePublicDecks( page: number, pageSize: number, ): Promise { + const parsed = argsSchema.parse({ page, pageSize, filters }); + + const f = parsed.filters; const { decks, total } = await getPublicDecksWithPreview({ - page, - pageSize, - ...filters, + page: parsed.page, + pageSize: parsed.pageSize, + ...(f.q !== undefined && { q: f.q }), + ...(f.format !== undefined && { format: f.format }), + ...(f.colors !== undefined && { colors: f.colors }), + ...(f.commander !== undefined && { commander: f.commander }), + ...(f.source !== undefined && { source: f.source }), + ...(f.sort !== undefined && { sort: f.sort }), }); - const loaded = (page - 1) * pageSize + decks.length; + const loaded = (parsed.page - 1) * parsed.pageSize + decks.length; return { decks: decks.map(serialize), hasMore: loaded < total, diff --git a/app/_actions/__tests__/inventory.test.ts b/app/_actions/__tests__/inventory.test.ts index f7fbe6f..95cfff9 100644 --- a/app/_actions/__tests__/inventory.test.ts +++ b/app/_actions/__tests__/inventory.test.ts @@ -230,4 +230,16 @@ describe("setWishlist", () => { "Printing not found", ); }); + + it("throws when the printing vanishes between the foil check and the cardId lookup", async () => { + // First lookup (foil finishes) succeeds; second (cardId) returns null. + mockPrintingFindUnique + .mockResolvedValueOnce({ finishes: ["nonfoil", "foil"] } as never) + .mockResolvedValueOnce(null); + + await expect(setWishlist(PRINTING_ID, false, true)).rejects.toThrow( + "Printing not found", + ); + expect(mockDeckCardCreate).not.toHaveBeenCalled(); + }); }); diff --git a/app/_components/header-search/use-card-search.test.tsx b/app/_components/header-search/use-card-search.test.tsx index 1c04ade..733a49a 100644 --- a/app/_components/header-search/use-card-search.test.tsx +++ b/app/_components/header-search/use-card-search.test.tsx @@ -139,6 +139,76 @@ describe("useCardSearch", () => { ); }); + it("treats a non-array JSON payload as empty results", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockRes({ ok: true, status: 200, json: { oops: true } })); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useCardSearch("bolt")); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it("ignores a fetch that resolves after unmount", async () => { + let resolveFetch!: (r: Response) => void; + const fetchMock = vi.fn( + () => new Promise((r) => { resolveFetch = r; }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { unmount } = renderHook(() => useCardSearch("bolt")); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + // Cleanup flips the cancelled guard before the fetch settles. + unmount(); + resolveFetch(mockRes({ ok: true, status: 200, json: [CARD] })); + await new Promise((r) => setTimeout(r, 0)); + // No state update / throw — the post-fetch cancelled guard short-circuits. + }); + + it("ignores a JSON body that resolves after unmount", async () => { + let resolveJson!: (v: unknown) => void; + const res = { + ok: true, + status: 200, + headers: { get: () => null }, + json: () => new Promise((r) => { resolveJson = r; }), + } as unknown as Response; + const fetchMock = vi.fn().mockResolvedValue(res); + vi.stubGlobal("fetch", fetchMock); + + const { unmount } = renderHook(() => useCardSearch("bolt")); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + // Let the fetch resolve (passes the first guard), then unmount while json() pends. + await new Promise((r) => setTimeout(r, 0)); + unmount(); + resolveJson([CARD]); + await new Promise((r) => setTimeout(r, 0)); + // The post-json cancelled guard short-circuits — no state update. + }); + + it("ignores a fetch that rejects after unmount", async () => { + let rejectFetch!: (e: unknown) => void; + const fetchMock = vi.fn( + () => new Promise((_, rej) => { rejectFetch = rej; }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { result, unmount } = renderHook(() => useCardSearch("bolt")); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + unmount(); + rejectFetch(new Error("late failure")); + await new Promise((r) => setTimeout(r, 0)); + // The catch-block cancelled guard short-circuits before surfacing an error. + expect(result.current.error).toBeNull(); + }); + it("swallows abort errors without surfacing them", async () => { const fetchMock = vi.fn().mockRejectedValue(abortError()); vi.stubGlobal("fetch", fetchMock); diff --git a/app/api/ingest/[runId]/progress/route.ts b/app/api/ingest/[runId]/progress/route.ts index 392702c..add8bfa 100644 --- a/app/api/ingest/[runId]/progress/route.ts +++ b/app/api/ingest/[runId]/progress/route.ts @@ -1,24 +1,48 @@ import { getRun } from "workflow/api"; +import { getEnv } from "@/lib/env"; +import { bearerMatches } from "../../_auth"; // Streams per-batch progress entries written by the ingest workflow on the // `progress` namespace. The shape of each chunk is owned by the workflow // (workflows/scryfall/steps.ts → upsertBatch); this route just plumbs the // readable side through. // +// The stream is bounded to 5 minutes via AbortSignal.timeout, mirroring the +// BULK_DOWNLOAD_TIMEOUT_MS pattern in workflows/scryfall/steps.ts:42. +// An unbound SSE stream holds a pooled connection indefinitely. +// // Refs: // - node_modules/workflow/docs/api-reference/workflow-api/get-run.mdx // - node_modules/workflow/docs/foundations/streaming.mdx (lines 218–289 for // namespaced streams) + +const PROGRESS_STREAM_TIMEOUT_MS = 5 * 60_000; + export async function GET( - _: Request, + req: Request, { params }: { params: Promise<{ runId: string }> }, ) { + if (!bearerMatches(req.headers.get("authorization"), getEnv().CRON_SECRET)) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + const { runId } = await params; const run = getRun(runId); if (!(await run.exists)) { return new Response("Not found", { status: 404 }); } - const readable = run.getReadable({ namespace: "progress" }); + + const signal = AbortSignal.timeout(PROGRESS_STREAM_TIMEOUT_MS); + const source = run.getReadable({ namespace: "progress" }); + + // Bound stream lifetime: pipe source through a TransformStream and let + // pipeTo's own AbortSignal tear down both sides when the timeout fires. + // Calling source.cancel() ourselves would conflict with the lock pipeTo + // holds on the source and reject with a TypeError, so we delegate to the + // signal instead. The .catch swallows the expected AbortError. + const { readable, writable } = new TransformStream(); + void source.pipeTo(writable, { signal }).catch(() => undefined); + return new Response(readable, { headers: { "content-type": "text/event-stream" }, }); diff --git a/app/api/ingest/__tests__/progress.test.ts b/app/api/ingest/__tests__/progress.test.ts new file mode 100644 index 0000000..aba9901 --- /dev/null +++ b/app/api/ingest/__tests__/progress.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "../[runId]/progress/route"; + +const SECRET = "test-token"; + +const mockReadable = new ReadableStream({ + start(controller) { + controller.close(); + }, +}); + +const mockRun = { + exists: Promise.resolve(true), + getReadable: vi.fn(() => mockReadable), +}; + +const mockMissingRun = { + exists: Promise.resolve(false), + getReadable: vi.fn(), +}; + +vi.mock("workflow/api", () => ({ + getRun: vi.fn((runId: string) => + runId === "missing-run" ? mockMissingRun : mockRun, + ), +})); + +beforeEach(() => { + vi.stubEnv("CRON_SECRET", SECRET); + mockRun.getReadable.mockClear(); +}); + +function req( + runId: string, + headers: Record = {}, +): [Request, { params: Promise<{ runId: string }> }] { + return [ + new Request(`http://localhost/api/ingest/${runId}/progress`, { + method: "GET", + headers, + }), + { params: Promise.resolve({ runId }) }, + ]; +} + +describe("GET /api/ingest/[runId]/progress", () => { + it("returns 401 when the authorization header is missing", async () => { + const res = await GET(...req("run-1")); + expect(res.status).toBe(401); + }); + + it("returns 401 when the bearer token is garbage", async () => { + const res = await GET(...req("run-1", { authorization: "Bearer garbage" })); + expect(res.status).toBe(401); + }); + + it("returns 401 when the authorization header lacks the Bearer prefix", async () => { + const res = await GET(...req("run-1", { authorization: SECRET })); + expect(res.status).toBe(401); + }); + + it("returns 401 when the token is the right length but wrong value", async () => { + const wrongSameLength = "x".repeat(SECRET.length); + const res = await GET( + ...req("run-1", { authorization: `Bearer ${wrongSameLength}` }), + ); + expect(res.status).toBe(401); + }); + + it("returns 404 when the run does not exist", async () => { + const res = await GET( + ...req("missing-run", { authorization: `Bearer ${SECRET}` }), + ); + expect(res.status).toBe(404); + }); + + it("returns 200 with text/event-stream when the bearer token matches", async () => { + const res = await GET(...req("run-1", { authorization: `Bearer ${SECRET}` })); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + expect(mockRun.getReadable).toHaveBeenCalledWith({ + namespace: "progress", + }); + }); +}); diff --git a/app/api/ingest/__tests__/status.test.ts b/app/api/ingest/__tests__/status.test.ts new file mode 100644 index 0000000..64c8339 --- /dev/null +++ b/app/api/ingest/__tests__/status.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "../status/route"; + +const SECRET = "test-token"; + +// Mock getWorld().runs.list to return a minimal valid response +vi.mock("workflow/runtime", () => ({ + getWorld: vi.fn(() => ({ + runs: { + list: vi.fn().mockResolvedValue({ data: [] }), + }, + })), +})); + +vi.mock("workflow/observability", () => ({ + parseWorkflowName: vi.fn(() => null), +})); + +beforeEach(() => { + vi.stubEnv("CRON_SECRET", SECRET); +}); + +function req(headers: Record = {}): Request { + return new Request("http://localhost/api/ingest/status", { + method: "GET", + headers, + }); +} + +describe("GET /api/ingest/status", () => { + it("returns 401 when the authorization header is missing", async () => { + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + it("returns 401 when the bearer token is garbage", async () => { + const res = await GET(req({ authorization: "Bearer garbage" })); + expect(res.status).toBe(401); + }); + + it("returns 401 when the authorization header lacks the Bearer prefix", async () => { + const res = await GET(req({ authorization: SECRET })); + expect(res.status).toBe(401); + }); + + it("returns 401 when the token is the right length but wrong value", async () => { + const wrongSameLength = "x".repeat(SECRET.length); + const res = await GET(req({ authorization: `Bearer ${wrongSameLength}` })); + expect(res.status).toBe(401); + }); + + it("returns 200 with workflows shape when the bearer token matches", async () => { + const res = await GET(req({ authorization: `Bearer ${SECRET}` })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("workflows"); + expect(body.workflows).toHaveProperty("scryfallIngestWorkflow"); + expect(body.workflows).toHaveProperty("preconIngestWorkflow"); + }); +}); diff --git a/app/api/ingest/_auth.ts b/app/api/ingest/_auth.ts new file mode 100644 index 0000000..ca0f763 --- /dev/null +++ b/app/api/ingest/_auth.ts @@ -0,0 +1,14 @@ +import { timingSafeEqual } from "node:crypto"; + +const BEARER_PREFIX = "Bearer "; + +export function bearerMatches( + header: string | null, + expected: string, +): boolean { + if (header === null || !header.startsWith(BEARER_PREFIX)) return false; + const a = Buffer.from(header.slice(BEARER_PREFIX.length)); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/app/api/ingest/_handler.ts b/app/api/ingest/_handler.ts index 56543cb..f0ec198 100644 --- a/app/api/ingest/_handler.ts +++ b/app/api/ingest/_handler.ts @@ -1,16 +1,6 @@ -import { timingSafeEqual } from "node:crypto"; import { start } from "workflow/api"; import { getEnv } from "@/lib/env"; - -const BEARER_PREFIX = "Bearer "; - -function bearerMatches(header: string | null, expected: string): boolean { - if (header === null || !header.startsWith(BEARER_PREFIX)) return false; - const a = Buffer.from(header.slice(BEARER_PREFIX.length)); - const b = Buffer.from(expected); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); -} +import { bearerMatches } from "./_auth"; export function createCronWorkflowHandler( workflow: Parameters[0], diff --git a/app/api/ingest/status/route.ts b/app/api/ingest/status/route.ts index 3ffface..92d2122 100644 --- a/app/api/ingest/status/route.ts +++ b/app/api/ingest/status/route.ts @@ -1,5 +1,7 @@ import { getWorld } from "workflow/runtime"; import { parseWorkflowName } from "workflow/observability"; +import { getEnv } from "@/lib/env"; +import { bearerMatches } from "../_auth"; // External monitors (and operators) need to alert on ingest failures separately // from the cron handoff. Cron success only means `start()` enqueued a run; the @@ -28,7 +30,10 @@ type RunSummary = { completedAt: string | null; }; -export async function GET() { +export async function GET(req: Request) { + if (!bearerMatches(req.headers.get("authorization"), getEnv().CRON_SECRET)) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } const world = getWorld(); const result = await world.runs.list({ pagination: { limit: SCAN_LIMIT, sortOrder: "desc" }, diff --git a/docs/runbook/ingest.md b/docs/runbook/ingest.md index 523c51b..ac2414d 100644 --- a/docs/runbook/ingest.md +++ b/docs/runbook/ingest.md @@ -76,10 +76,11 @@ The lock-skip path (`skipped: true, reason: "another ingest run holds the lock"` ## 4. Status route -`GET /api/ingest/status` returns the latest 5 runs per ingest workflow as JSON. No auth — it returns metadata only (no input/output payloads). +`GET /api/ingest/status` returns the latest 5 runs per ingest workflow as JSON. Guarded by `CRON_SECRET` (same Bearer compare as the ingest routes); it returns metadata only (no input/output payloads). ```bash -curl https:///api/ingest/status +curl -H "Authorization: Bearer $CRON_SECRET" \ + https:///api/ingest/status ``` Response shape: @@ -110,7 +111,8 @@ The route lives at `app/api/ingest/status/route.ts` and uses `getWorld().runs.li `GET /api/ingest//progress` streams namespaced (`progress`) chunks emitted by `upsertBatch` while a Scryfall ingest is running. Useful for watching a long ingest from a terminal: ```bash -curl -N https:///api/ingest//progress +curl -N -H "Authorization: Bearer $CRON_SECRET" \ + https:///api/ingest//progress ``` The route lives at `app/api/ingest/[runId]/progress/route.ts` and uses `getRun(runId).getReadable({ namespace: 'progress' })` — see `node_modules/workflow/docs/foundations/streaming.mdx` (lines 218–289) for the namespaced-stream contract. diff --git a/lib/__tests__/concurrency.test.ts b/lib/__tests__/concurrency.test.ts new file mode 100644 index 0000000..2c2f0b5 --- /dev/null +++ b/lib/__tests__/concurrency.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { runWithConcurrency } from "../concurrency"; + +describe("runWithConcurrency", () => { + it("maps over items preserving input order", async () => { + const out = await runWithConcurrency([1, 2, 3, 4], 2, async (n) => n * 10); + expect(out).toEqual([10, 20, 30, 40]); + }); + + it("caps in-flight tasks at the concurrency limit", async () => { + let inFlight = 0; + let peak = 0; + await runWithConcurrency(Array.from({ length: 8 }, (_, i) => i), 3, async (n) => { + inFlight++; + peak = Math.max(peak, inFlight); + await Promise.resolve(); + inFlight--; + return n; + }); + expect(peak).toBeLessThanOrEqual(3); + }); + + it("floors a fractional concurrency", async () => { + const fn = vi.fn(async (n: number) => n); + const out = await runWithConcurrency([1, 2], 2.9, fn); + expect(out).toEqual([1, 2]); + }); + + it("handles an empty input without invoking fn", async () => { + const fn = vi.fn(async (n: number) => n); + expect(await runWithConcurrency([], 4, fn)).toEqual([]); + expect(fn).not.toHaveBeenCalled(); + }); + + it.each([0, -1, NaN, Infinity, 0.5])( + "throws RangeError for non-positive-integer concurrency %p", + async (bad) => { + await expect( + runWithConcurrency([1], bad, async (n) => n), + ).rejects.toThrow(RangeError); + }, + ); +}); diff --git a/lib/concurrency.ts b/lib/concurrency.ts new file mode 100644 index 0000000..d4f76ee --- /dev/null +++ b/lib/concurrency.ts @@ -0,0 +1,31 @@ +/** + * Runs `fn` over `items` with at most `concurrency` tasks in-flight at once. + * Preserves input order in the returned results array. + */ +export async function runWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const limit = Math.floor(concurrency); + if (!Number.isFinite(limit) || limit <= 0) { + throw new RangeError( + `runWithConcurrency: concurrency must be a positive integer, got ${concurrency}`, + ); + } + const results: R[] = new Array(items.length); + let cursor = 0; + const worker = async (): Promise => { + while (true) { + const i = cursor++; + if (i >= items.length) return; + results[i] = await fn(items[i] as T); + } + }; + const workers = Array.from( + { length: Math.min(limit, items.length) }, + worker, + ); + await Promise.all(workers); + return results; +} diff --git a/lib/deck/__tests__/partner-keywords.test.ts b/lib/deck/__tests__/partner-keywords.test.ts index 9020631..372a309 100644 --- a/lib/deck/__tests__/partner-keywords.test.ts +++ b/lib/deck/__tests__/partner-keywords.test.ts @@ -3,12 +3,12 @@ import { canHavePartner } from "../partner-keywords"; describe("canHavePartner — keyword matching", () => { it.each([ - // Scryfall stores generic Partner, Partner—[X], and Friends forever all as "Partner" ["Partner"], - ["Partner with Rograkh, Son of Rohgahh"], ["Doctor's companion"], // Scryfall stores "Choose a background" with a lowercase b ["Choose a background"], + // Rowan, Scholar of Sparks / Will, Scholar of Frost + ["Friends forever"], ])("returns true for keyword %s", (keyword) => { expect(canHavePartner([keyword])).toBe(true); }); @@ -20,6 +20,13 @@ describe("canHavePartner — keyword matching", () => { it("returns false for empty keywords and no typeLine", () => { expect(canHavePartner([])).toBe(false); }); + + it("returns false for 'Partner with [Name]' — named pairing is not freely combinable", () => { + // "Partner with X" cards can only pair with their named partner, not any commander. + // Named-pairing validation is tracked as a follow-up ticket. + expect(canHavePartner(["Partner with Rograkh, Son of Rohgahh"])).toBe(false); + expect(canHavePartner(["Partner with Akiri, Line-Slinger"])).toBe(false); + }); }); describe("canHavePartner — Background enchantment (typeLine check)", () => { diff --git a/lib/deck/io/__tests__/card-resolver.test.ts b/lib/deck/io/__tests__/card-resolver.test.ts index 911f28a..5fdb7fc 100644 --- a/lib/deck/io/__tests__/card-resolver.test.ts +++ b/lib/deck/io/__tests__/card-resolver.test.ts @@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({ import { prisma } from "@/lib/db"; import { resolveCardNames } from "../card-resolver"; +// Must match FUZZY_CONCURRENCY in lib/deck/io/card-resolver.ts (kept private +// there); update both together if the resolver's concurrency cap changes. +const FUZZY_CONCURRENCY = 25; + const mockFindMany = vi.mocked(prisma.card.findMany); function parsed(name: string, overrides: Partial = {}): ParsedCard { @@ -44,18 +48,37 @@ describe("resolveCardNames", () => { expect(mockFindMany).toHaveBeenCalledTimes(1); }); - it("falls back to fuzzy when no exact match", async () => { + it("falls back to fuzzy when no exact match and confidence is high enough", async () => { + // "Shockwav" (8) -> "Shockwave" (9): delta=1, confidence=1-1/8=0.875 >= 0.7 mockFindMany .mockResolvedValueOnce([] as never) - .mockResolvedValueOnce([{ id: 42, name: "Lightning Helix" }] as never); + .mockResolvedValueOnce([{ id: 42, name: "Shockwave" }] as never); - const [row] = await resolveCardNames([parsed("Lightnin")]); + const [row] = await resolveCardNames([parsed("Shockwav")]); expect(row).toMatchObject({ cardId: 42, - matchedName: "Lightning Helix", + matchedName: "Shockwave", }); expect(row!.match.kind).toBe("fuzzy"); + expect(row!.warnings).toHaveLength(1); + expect(row!.warnings[0]).toContain("Shockwave"); + }); + + it("returns kind:none for low-confidence fuzzy matches (below threshold)", async () => { + // "Lightnin" (8) -> "Lightning Helix" (15): delta=7, confidence=1-7/8=0.125 < 0.7 + mockFindMany + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([{ id: 42, name: "Lightning Helix" }] as never); + + const [row] = await resolveCardNames([parsed("Lightnin")]); + + expect(row).toMatchObject({ + cardId: null, + matchedName: null, + match: { kind: "none" }, + }); + expect(row!.warnings).toHaveLength(0); }); it("picks the closest-length candidate", async () => { @@ -86,6 +109,36 @@ describe("resolveCardNames", () => { }); }); + it("issues at most FUZZY_CONCURRENCY in-flight fuzzy queries at once", async () => { + // 100 unresolved unique names → exact returns empty, then 100 fuzzy queries + // should be throttled to ≤25 concurrent. + const N = 100; + let peakConcurrent = 0; + let currentConcurrent = 0; + + // exact-match call + mockFindMany.mockResolvedValueOnce([] as never); + + // Each subsequent call tracks concurrent in-flight requests + mockFindMany.mockImplementation(() => { + currentConcurrent++; + peakConcurrent = Math.max(peakConcurrent, currentConcurrent); + return new Promise((resolve) => + setTimeout(() => { + currentConcurrent--; + resolve([] as never); + }, 0), + ) as never; + }); + + const cards = Array.from({ length: N }, (_, i) => + parsed(`UniqueCard${i}`), + ); + await resolveCardNames(cards); + + expect(peakConcurrent).toBeLessThanOrEqual(FUZZY_CONCURRENCY); + }); + it("dedupes lookups when multiple rows reference the same name", async () => { mockFindMany.mockResolvedValueOnce([ { id: 1, name: "Lightning Bolt" }, diff --git a/lib/deck/io/__tests__/intake.test.ts b/lib/deck/io/__tests__/intake.test.ts index b73f3ee..94a9d49 100644 --- a/lib/deck/io/__tests__/intake.test.ts +++ b/lib/deck/io/__tests__/intake.test.ts @@ -23,6 +23,7 @@ vi.mock("@/lib/deck/mutation", async () => { import { prisma } from "@/lib/db"; import { applyChanges } from "@/lib/deck/mutation"; import { intakeDecklist } from "../intake"; +import { MAX_CARD_LINES } from "../consts"; const mockCardFindMany = vi.mocked(prisma.card.findMany); const mockPrintingFindMany = vi.mocked(prisma.printing.findMany); @@ -193,6 +194,32 @@ describe("intakeDecklist — append mode", () => { }); }); +describe("intakeDecklist — line cap", () => { + it("truncates to MAX_CARD_LINES and pushes a warning when input exceeds the limit", async () => { + // Build a 5000-line input of unique card names so they all pass through parse. + const lines = Array.from( + { length: 5000 }, + (_, i) => `1 FakeCard${i}`, + ).join("\n"); + + // exact-match batch returns empty → fuzzy also returns empty → all unmatched + mockCardFindMany.mockResolvedValue([] as never); + + const result = await intakeDecklist({ + deckId: "deck-1", + userId: "user-1", + text: lines, + mode: "append", + }); + + // The resolver sees at most MAX_CARD_LINES distinct names + // (exact-match call) plus up to MAX_CARD_LINES fuzzy calls. + // All resolved to unmatched, so applied=0. + expect(result.unmatchedNames.length).toBeLessThanOrEqual(MAX_CARD_LINES); + expect(result.warnings).toContain(`import truncated to ${MAX_CARD_LINES} lines`); + }); +}); + describe("intakeDecklist — replace mode", () => { it("diffs resolved cards against existing deck rows", async () => { mockCardFindMany.mockResolvedValueOnce([ diff --git a/lib/deck/io/__tests__/line-parser.test.ts b/lib/deck/io/__tests__/line-parser.test.ts index 28868a8..eb0ad59 100644 --- a/lib/deck/io/__tests__/line-parser.test.ts +++ b/lib/deck/io/__tests__/line-parser.test.ts @@ -69,6 +69,11 @@ describe("parseDeckList", () => { expect(parseDeckList("0 Sol Ring")).toEqual([]); }); + it("rejects quantities exceeding MAX_CARD_QTY (overflow guard)", () => { + // "9".repeat(21) parses to 9e20 in JS which is not an integer, so skipped. + expect(parseDeckList("99999999999999999999 Sol Ring")).toEqual([]); + }); + it("trims surrounding whitespace on each line", () => { const out = parseDeckList(" 3 Brainstorm "); expect(out).toEqual([{ name: "Brainstorm", quantity: 3, isFoil: false }]); diff --git a/lib/deck/io/__tests__/resolve.test.ts b/lib/deck/io/__tests__/resolve.test.ts index 15b59a3..a0b04a6 100644 --- a/lib/deck/io/__tests__/resolve.test.ts +++ b/lib/deck/io/__tests__/resolve.test.ts @@ -77,21 +77,42 @@ describe("resolveDecklist", () => { expect(mockFindMany).toHaveBeenCalledTimes(1); }); - it("falls back to prefix fuzzy match when no exact hit", async () => { + it("falls back to prefix fuzzy match when no exact hit and confidence is high enough", async () => { + // "Shockwav" (8) → "Shockwave" (9): confidence=0.875 ≥ 0.7 mockFindMany .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([ - { id: 42, name: "Lightning Helix" }, + { id: 42, name: "Shockwave" }, ] as never); - const result = await resolveDecklist(decklist([parsed("Lightnin")])); + const result = await resolveDecklist(decklist([parsed("Shockwav")])); expect(result.cards[0]).toMatchObject({ cardId: 42, - matchedName: "Lightning Helix", + matchedName: "Shockwave", }); expect(result.cards[0]!.match.kind).toBe("fuzzy"); expect(result.unmatched).toEqual([]); + expect(result.warnings).toContain( + '"Shockwav" was not found; substituted "Shockwave" (fuzzy match)', + ); + }); + + it("rejects low-confidence fuzzy matches as unmatched", async () => { + // "Lightnin" (8) → "Lightning Helix" (15): confidence=0.125 < 0.7 + mockFindMany + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([ + { id: 42, name: "Lightning Helix" }, + ] as never); + + const result = await resolveDecklist(decklist([parsed("Lightnin")])); + + expect(result.cards[0]).toMatchObject({ + cardId: null, + match: { kind: "none" }, + }); + expect(result.unmatched).toHaveLength(1); }); it("picks the closest-length candidate among multiple fuzzy hits", async () => { diff --git a/lib/deck/io/adapters/__tests__/json.test.ts b/lib/deck/io/adapters/__tests__/json.test.ts new file mode 100644 index 0000000..b1f2e1f --- /dev/null +++ b/lib/deck/io/adapters/__tests__/json.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { Zone } from "@/lib/generated/prisma/client"; +import { jsonAdapter, MaindeckJsonSchema } from "../json"; +import { toMaindeckJson } from "../../serialize"; +import type { DeckWithCards } from "../types"; + +function makeDeck(overrides: Partial = {}): DeckWithCards { + return { + name: "Test Deck", + format: "COMMANDER", + visibility: "PRIVATE", + description: null, + cards: [ + { + quantity: 1, + zone: Zone.COMMANDER, + category: null, + isFoil: false, + printingId: null, + card: { name: "Atraxa, Praetors' Voice" }, + printing: null, + }, + { + quantity: 4, + zone: Zone.MAINBOARD, + category: "Ramp", + isFoil: true, + printingId: 999, + card: { name: "Sol Ring" }, + printing: { setCode: "C21", collectorNumber: "263" }, + }, + { + quantity: 2, + zone: Zone.SIDEBOARD, + category: null, + isFoil: false, + printingId: null, + card: { name: "Duress" }, + printing: null, + }, + { + quantity: 1, + zone: Zone.CONSIDERING, + category: null, + isFoil: false, + printingId: null, + card: { name: "Brainstorm" }, + printing: null, + }, + ], + categories: [{ name: "Ramp", sortOrder: 0 }], + ...overrides, + }; +} + +describe("jsonAdapter.detect", () => { + it("returns 1 for JSON objects", () => { + expect(jsonAdapter.detect('{"name":"Test"}')).toBe(1); + expect(jsonAdapter.detect(' { "foo": 1 }')).toBe(1); + }); + + it("returns 0 for non-JSON input", () => { + expect(jsonAdapter.detect("4 Lightning Bolt")).toBe(0); + expect(jsonAdapter.detect("")).toBe(0); + expect(jsonAdapter.detect("Deck\n4 Lightning Bolt")).toBe(0); + }); +}); + +describe("jsonAdapter.parse — round-trip fidelity", () => { + it("round-trips toMaindeckJson output preserving zones, categories, and foil", () => { + const deck = makeDeck(); + const json = toMaindeckJson(deck); + const result = jsonAdapter.parse(json); + + expect(result.format).toBe("json"); + expect(result.warnings).toHaveLength(0); + expect(result.cards).toHaveLength(4); + + const commander = result.cards.find((c) => c.zone === Zone.COMMANDER); + expect(commander).toMatchObject({ + name: "Atraxa, Praetors' Voice", + quantity: 1, + zone: Zone.COMMANDER, + isFoil: false, + category: null, + }); + + const solRing = result.cards.find((c) => c.name === "Sol Ring"); + expect(solRing).toMatchObject({ + quantity: 4, + zone: Zone.MAINBOARD, + category: "Ramp", + isFoil: true, + set: "C21", + collectorNumber: "263", + }); + + const sideboard = result.cards.find((c) => c.zone === Zone.SIDEBOARD); + expect(sideboard).toMatchObject({ name: "Duress", quantity: 2 }); + + const considering = result.cards.find((c) => c.zone === Zone.CONSIDERING); + expect(considering).toMatchObject({ name: "Brainstorm", quantity: 1 }); + }); +}); + +describe("jsonAdapter.parse — error handling", () => { + it("returns 0 cards and a warning for malformed JSON without throwing", () => { + const result = jsonAdapter.parse("{not valid json"); + expect(result.cards).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("JSON"); + }); + + it("returns 0 cards and a warning for valid JSON that fails schema validation", () => { + const result = jsonAdapter.parse(JSON.stringify({ totally: "wrong shape" })); + expect(result.cards).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("validation"); + }); + + it("returns 0 cards and a warning for a JSON array (not an object)", () => { + const result = jsonAdapter.parse(JSON.stringify([1, 2, 3])); + expect(result.cards).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + }); + + it("rejects a card whose quantity exceeds the max without throwing", () => { + const result = jsonAdapter.parse( + JSON.stringify({ + name: "Deck", + format: "commander", + visibility: "PRIVATE", + description: null, + cards: [ + { + name: "Sol Ring", + quantity: 999999, + zone: Zone.MAINBOARD, + category: null, + isFoil: false, + }, + ], + categories: [], + }), + ); + expect(result.cards).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("validation"); + }); +}); + +describe("MaindeckJsonSchema", () => { + it("accepts a valid MaindeckJson payload", () => { + const deck = makeDeck(); + const raw = JSON.parse(toMaindeckJson(deck)); + expect(MaindeckJsonSchema.safeParse(raw).success).toBe(true); + }); +}); diff --git a/lib/deck/io/adapters/dek.ts b/lib/deck/io/adapters/dek.ts index 1644939..d9dae0b 100644 --- a/lib/deck/io/adapters/dek.ts +++ b/lib/deck/io/adapters/dek.ts @@ -1,4 +1,5 @@ import { Zone } from "@/lib/generated/prisma/enums"; +import { MAX_CARD_QTY } from "../consts"; import type { ParsedCard, ParsedDecklist } from "../parse"; import type { DecklistParser } from "./types"; @@ -22,7 +23,7 @@ function parse(input: string): ParsedDecklist { if (quantityStr === undefined || name === undefined) continue; const quantity = parseInt(quantityStr, 10); /* c8 ignore next */ - if (!Number.isFinite(quantity) || quantity <= 0) continue; + if (!Number.isInteger(quantity) || quantity <= 0 || quantity > MAX_CARD_QTY) continue; cards.push({ name, quantity, diff --git a/lib/deck/io/adapters/index.ts b/lib/deck/io/adapters/index.ts index 74c5c83..913bfa2 100644 --- a/lib/deck/io/adapters/index.ts +++ b/lib/deck/io/adapters/index.ts @@ -1,9 +1,13 @@ import { arenaAdapter } from "./arena"; import { dekAdapter } from "./dek"; +import { jsonAdapter } from "./json"; import { textAdapter } from "./text"; import type { AdapterId, DecklistParser, DecklistSerializer } from "./types"; +// json adapter listed first so its detect score of 1 wins over dek's 0.95 +// for `{`-prefixed inputs. export const adapters: readonly DecklistParser[] = [ + jsonAdapter, dekAdapter, arenaAdapter, textAdapter, @@ -13,6 +17,7 @@ export const ADAPTER_BY_ID: Record = { text: textAdapter, arena: arenaAdapter, dek: dekAdapter, + json: jsonAdapter, }; export const serializers: readonly DecklistSerializer[] = [ diff --git a/lib/deck/io/adapters/json.ts b/lib/deck/io/adapters/json.ts new file mode 100644 index 0000000..c19fffe --- /dev/null +++ b/lib/deck/io/adapters/json.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { Zone } from "@/lib/generated/prisma/enums"; +import type { ParsedCard, ParsedDecklist } from "../parse"; +import { MAX_CARD_QTY } from "../consts"; +import type { DecklistParser } from "./types"; + +const JsonCardSchema = z.object({ + name: z.string(), + quantity: z.number().int().positive().max(MAX_CARD_QTY), + zone: z.nativeEnum(Zone), + category: z.string().nullable(), + set: z.string().optional(), + collectorNumber: z.string().optional(), + isFoil: z.boolean(), + printingId: z.number().int().optional(), +}); + +export const MaindeckJsonSchema = z.object({ + name: z.string(), + format: z.string(), + visibility: z.string(), + description: z.string().nullable(), + cards: z.array(JsonCardSchema), + categories: z.array(z.object({ name: z.string(), sortOrder: z.number() })), +}); + +export type MaindeckJson = z.infer; + +function detect(input: string): number { + return input.trimStart().startsWith("{") ? 1 : 0; +} + +function parse(input: string): ParsedDecklist { + const warnings: string[] = []; + let raw: unknown; + + try { + raw = JSON.parse(input); + } catch { + warnings.push("Decklist looks like JSON but could not be parsed"); + return { format: "json", cards: [], unmatchedLines: [], warnings }; + } + + const result = MaindeckJsonSchema.safeParse(raw); + if (!result.success) { + warnings.push( + // safeParse failure always carries at least one issue. + `JSON decklist failed validation: ${result.error.issues[0]!.message}`, + ); + return { format: "json", cards: [], unmatchedLines: [], warnings }; + } + + const cards: ParsedCard[] = result.data.cards.map((c) => ({ + name: c.name, + quantity: c.quantity, + zone: c.zone, + category: c.category, + ...(c.set !== undefined && { set: c.set }), + ...(c.collectorNumber !== undefined && { collectorNumber: c.collectorNumber }), + isFoil: c.isFoil, + })); + + return { format: "json", cards, unmatchedLines: [], warnings }; +} + +export const jsonAdapter: DecklistParser = { + id: "json", + detect, + parse, +}; diff --git a/lib/deck/io/adapters/types.ts b/lib/deck/io/adapters/types.ts index d4c4cbd..50cf5ce 100644 --- a/lib/deck/io/adapters/types.ts +++ b/lib/deck/io/adapters/types.ts @@ -1,8 +1,8 @@ import type { Zone } from "@/lib/generated/prisma/enums"; import type { ParsedDecklist } from "../parse"; -export type AdapterId = "text" | "arena" | "dek"; -export type SerializerId = "text" | "arena"; +export type AdapterId = "text" | "arena" | "dek" | "json"; +type SerializerId = "text" | "arena"; type SerializeCard = { name: string }; diff --git a/lib/deck/io/card-resolver.ts b/lib/deck/io/card-resolver.ts index b94974d..bd008e3 100644 --- a/lib/deck/io/card-resolver.ts +++ b/lib/deck/io/card-resolver.ts @@ -1,6 +1,9 @@ import { prisma } from "@/lib/db"; +import { runWithConcurrency } from "@/lib/concurrency"; import type { ParsedCard } from "./parse"; +const FUZZY_CONCURRENCY = 25; + export type Match = | { kind: "exact" } | { kind: "fuzzy"; confidence: number } @@ -12,10 +15,13 @@ type CardResolution = { cardId: number | null; matchedName: string | null; match: Match; + /** Warnings emitted during resolution (e.g. accepted fuzzy substitutions). */ + warnings: string[]; }; const FUZZY_PREFIX_LEN = 4; const FUZZY_TAKE = 20; +const FUZZY_MIN_CONFIDENCE = 0.7; /** Pick the candidate whose name length is closest to the target name. */ function pickClosest( @@ -62,8 +68,10 @@ export async function resolveCardNames( const fuzzyByLower = new Map(); if (unresolved.length > 0) { - const fuzzyResults = await Promise.all( - unresolved.map((name) => + const fuzzyResults = await runWithConcurrency( + unresolved, + FUZZY_CONCURRENCY, + (name) => prisma.card.findMany({ where: { name: { @@ -74,7 +82,6 @@ export async function resolveCardNames( select: { id: true, name: true }, take: FUZZY_TAKE, }), - ), ); for (let i = 0; i < unresolved.length; i++) { @@ -96,23 +103,39 @@ export async function resolveCardNames( parsed: card, cardId: exact.id, matchedName: exact.name, - match: { kind: "exact" }, + match: { kind: "exact" } as Match, + warnings: [], }; } const fuzzy = fuzzyByLower.get(lower); if (fuzzy) { + const confidence = fuzzyConfidence(fuzzy.name, lower); + if (confidence < FUZZY_MIN_CONFIDENCE) { + // Low-confidence fuzzy hit — treat as unmatched to avoid silent substitution. + return { + parsed: card, + cardId: null, + matchedName: null, + match: { kind: "none" } as Match, + warnings: [], + }; + } return { parsed: card, cardId: fuzzy.id, matchedName: fuzzy.name, - match: { kind: "fuzzy", confidence: fuzzyConfidence(fuzzy.name, lower) }, + match: { kind: "fuzzy", confidence } as Match, + warnings: [ + `"${card.name}" was not found; substituted "${fuzzy.name}" (fuzzy match)`, + ], }; } return { parsed: card, cardId: null, matchedName: null, - match: { kind: "none" }, + match: { kind: "none" } as Match, + warnings: [], }; }); } diff --git a/lib/deck/io/consts.ts b/lib/deck/io/consts.ts new file mode 100644 index 0000000..e615926 --- /dev/null +++ b/lib/deck/io/consts.ts @@ -0,0 +1,5 @@ +/** Maximum number of card lines accepted from a single import. */ +export const MAX_CARD_LINES = 1000; + +/** Maximum quantity accepted for a single card line. */ +export const MAX_CARD_QTY = 1000; diff --git a/lib/deck/io/intake.ts b/lib/deck/io/intake.ts index 2ac7edf..e305d88 100644 --- a/lib/deck/io/intake.ts +++ b/lib/deck/io/intake.ts @@ -11,6 +11,7 @@ import { formatLegalityIssue } from "@/lib/deck/legality/shared"; import type { PlannedChange } from "@/lib/deck/mutation/types"; import { detectFormat, parseDecklist } from "./parse"; import { resolveDecklist, type ResolvedCard, type ResolvedDecklist } from "./resolve"; +import { MAX_CARD_LINES } from "./consts"; type IntakeMode = "append" | "replace"; @@ -101,7 +102,15 @@ function tally(changes: readonly PlannedChange[]): { export async function intakeDecklist(input: IntakeInput): Promise { const { deckId, userId, text, mode, applyOptions } = input; - const parsed = parseDecklist(text, detectFormat(text)); + const rawParsed = parseDecklist(text, detectFormat(text)); + let parsed = rawParsed; + if (rawParsed.cards.length > MAX_CARD_LINES) { + parsed = { + ...rawParsed, + cards: rawParsed.cards.slice(0, MAX_CARD_LINES), + warnings: [...rawParsed.warnings, `import truncated to ${MAX_CARD_LINES} lines`], + }; + } const resolved = await resolveDecklist(parsed); const warnings = [...resolved.warnings]; const unmatchedNames = resolved.unmatched.map((c) => c.name); diff --git a/lib/deck/io/line-parser.ts b/lib/deck/io/line-parser.ts index da139d1..67efcff 100644 --- a/lib/deck/io/line-parser.ts +++ b/lib/deck/io/line-parser.ts @@ -1,3 +1,5 @@ +import { MAX_CARD_QTY } from "./consts"; + type SubCard = { name: string; quantity: number; @@ -30,7 +32,7 @@ export function parseDeckList(input: string): SubCard[] { /* c8 ignore next */ if (quantityStr === undefined || rawName === undefined) continue; const quantity = parseInt(quantityStr, 10); - if (!Number.isFinite(quantity) || quantity <= 0) continue; + if (!Number.isInteger(quantity) || quantity <= 0 || quantity > MAX_CARD_QTY) continue; const name = normalizeMdfcName(rawName); const resolvedSet = set ?? alternateSet; diff --git a/lib/deck/io/resolve.ts b/lib/deck/io/resolve.ts index 5e12fab..9c98c9f 100644 --- a/lib/deck/io/resolve.ts +++ b/lib/deck/io/resolve.ts @@ -54,6 +54,7 @@ export async function resolveDecklist( const pinIdx = requestIndexByRow.get(i); const pin = pinIdx !== undefined ? pins[pinIdx] : undefined; if (pin?.warning) resolveWarnings.push(pin.warning); + resolveWarnings.push(...row.warnings); return { parsed: row.parsed, cardId: row.cardId, diff --git a/lib/deck/io/serialize.ts b/lib/deck/io/serialize.ts index 3496628..1838c72 100644 --- a/lib/deck/io/serialize.ts +++ b/lib/deck/io/serialize.ts @@ -1,5 +1,6 @@ import type { Zone } from "@/lib/generated/prisma/enums"; import { arenaAdapter, textAdapter } from "./adapters"; +import type { MaindeckJson } from "./adapters/json"; import type { DeckWithCards } from "./adapters/types"; const ZONE_ORDER: Zone[] = ["COMMANDER", "MAINBOARD", "SIDEBOARD", "CONSIDERING"]; @@ -21,25 +22,7 @@ export function stripCommentHeaders(text: string): string { .trim(); } -type JsonCard = { - name: string; - quantity: number; - zone: Zone; - category: string | null; - set?: string; - collectorNumber?: string; - isFoil: boolean; - printingId?: number; -}; - -type MaindeckJson = { - name: string; - format: string; - visibility: string; - description: string | null; - cards: JsonCard[]; - categories: Array<{ name: string; sortOrder: number }>; -}; +type JsonCard = MaindeckJson["cards"][number]; export function toMaindeckJson(deck: DeckWithCards): string { const cards: JsonCard[] = deck.cards diff --git a/lib/deck/legality/__tests__/shared.test.ts b/lib/deck/legality/__tests__/shared.test.ts index 20eece6..af1bf08 100644 --- a/lib/deck/legality/__tests__/shared.test.ts +++ b/lib/deck/legality/__tests__/shared.test.ts @@ -6,6 +6,7 @@ import { isBasicLandCard, legalityKindForStatus, offIdentityColors, + singletonRule, } from "../shared"; import { Zone } from "@/lib/generated/prisma/enums"; import type { DeckSnapshot, LegalityIssue } from "@/lib/deck/mutation/types"; @@ -190,6 +191,59 @@ describe("colorIdentityRule", () => { }); }); +describe("singletonRule", () => { + function snap( + cards: Array<{ + cardName: string; + zone: Zone; + typeLine?: string | null; + quantity?: number; + }>, + ): DeckSnapshot { + return { + cards: cards.map((c) => ({ + cardName: c.cardName, + zone: c.zone, + colorIdentity: [], + typeLine: c.typeLine ?? null, + quantity: c.quantity ?? 1, + })), + } as unknown as DeckSnapshot; + } + + it("flags a non-basic with more than one copy across MAINBOARD + COMMANDER", () => { + const issues = singletonRule( + snap([ + { cardName: "Sol Ring", zone: Zone.MAINBOARD, quantity: 1 }, + { cardName: "Sol Ring", zone: Zone.COMMANDER, quantity: 1 }, + ]), + ); + expect(issues).toEqual([ + { kind: "singleton_violation", cardName: "Sol Ring", quantity: 2 }, + ]); + }); + + it("ignores cards outside MAINBOARD + COMMANDER", () => { + const issues = singletonRule( + snap([ + { cardName: "Brainstorm", zone: Zone.SIDEBOARD, quantity: 1 }, + { cardName: "Brainstorm", zone: Zone.CONSIDERING, quantity: 1 }, + { cardName: "Brainstorm", zone: Zone.MAINBOARD, quantity: 1 }, + ]), + ); + expect(issues).toEqual([]); + }); + + it("never flags duplicate basic lands", () => { + const issues = singletonRule( + snap([ + { cardName: "Forest", zone: Zone.MAINBOARD, typeLine: "Basic Land — Forest", quantity: 10 }, + ]), + ); + expect(issues).toEqual([]); + }); +}); + describe("isBasicLandCard", () => { it("detects via type line", () => { expect(isBasicLandCard("Basic Land — Forest", "Forest")).toBe(true); diff --git a/lib/deck/manabase/__tests__/candidates.test.ts b/lib/deck/manabase/__tests__/candidates.test.ts index 0438608..0b918aa 100644 --- a/lib/deck/manabase/__tests__/candidates.test.ts +++ b/lib/deck/manabase/__tests__/candidates.test.ts @@ -228,6 +228,24 @@ describe("getBasicLandImages", () => { }); }); + it("ignores rows whose name is not a known basic land", async () => { + mockQueryRaw.mockResolvedValue([ + ...BASIC_ROWS, + { name: "Snow-Covered Plains", image_uri: "/snow.webp" }, + ] as never); + + const result = await getBasicLandImages(); + + expect(result).toEqual({ + W: "/plains.webp", + U: "/island.webp", + B: "/swamp.webp", + R: "/mountain.webp", + G: "/forest.webp", + C: "/wastes.webp", + }); + }); + it("throws when a basic land image is missing", async () => { // Return only 5 basics — Wastes is missing mockQueryRaw.mockResolvedValue(BASIC_ROWS.slice(0, 5) as never); diff --git a/lib/deck/manabase/__tests__/cycles.test.ts b/lib/deck/manabase/__tests__/cycles.test.ts index 19d21ac..79c7819 100644 --- a/lib/deck/manabase/__tests__/cycles.test.ts +++ b/lib/deck/manabase/__tests__/cycles.test.ts @@ -161,6 +161,12 @@ describe("classifyLandCycle", () => { ), ).toBeNull(); }); + + it("returns null when oracle text is absent (predicates see empty text)", () => { + expect( + classifyLandCycle(card({ name: "Textless Land", typeLine: "Land", oracleText: null })), + ).toBeNull(); + }); }); describe("isNonbasicLand", () => { diff --git a/lib/deck/mutation/__tests__/apply.test.ts b/lib/deck/mutation/__tests__/apply.test.ts index 5991550..b7c10a8 100644 --- a/lib/deck/mutation/__tests__/apply.test.ts +++ b/lib/deck/mutation/__tests__/apply.test.ts @@ -310,6 +310,28 @@ describe("applyChanges — basic ops", () => { expect(mockDeckCardDelete).not.toHaveBeenCalled(); }); + it("move that only changes the category writes data.category", async () => { + commanderDeck([ + { + id: "dc-1", + cardId: 1, + name: "Sol Ring", + quantity: 1, + zone: Zone.MAINBOARD, + typeLine: "Artifact", + }, + ]); + + await applyChanges(DECK, USER, [ + { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: "Ramp" }, + ]); + + expect(mockDeckCardUpdate).toHaveBeenCalledWith({ + where: { id: "dc-1" }, + data: { category: "Ramp" }, + }); + }); + it("move that lands on an existing target merges quantity and deletes the source", async () => { commanderDeck([ { diff --git a/lib/deck/mutation/__tests__/invariants.test.ts b/lib/deck/mutation/__tests__/invariants.test.ts index 73daffc..50c314f 100644 --- a/lib/deck/mutation/__tests__/invariants.test.ts +++ b/lib/deck/mutation/__tests__/invariants.test.ts @@ -200,11 +200,6 @@ describe("fullLegality — singleton", () => { describe("checkStructural — structural", () => { it("rejects category != null for non-MAINBOARD add", () => { - const before = snapshotFromCards({ - format: Format.COMMANDER, - cards: [], - extraMeta: [{ cardId: 1, name: "Counterspell", typeLine: "Instant" }], - }); const changes: PlannedChange[] = [ { op: "add", @@ -219,10 +214,6 @@ describe("checkStructural — structural", () => { }); it("rejects category != null on move to non-MAINBOARD", () => { - const before = snapshotFromCards({ - format: Format.COMMANDER, - cards: [dc("dc-1", 1, "Sol Ring", 1)], - }); const changes: PlannedChange[] = [ { op: "move", diff --git a/lib/deck/mutation/plan.ts b/lib/deck/mutation/plan.ts index 18f538c..3d55a25 100644 --- a/lib/deck/mutation/plan.ts +++ b/lib/deck/mutation/plan.ts @@ -49,7 +49,7 @@ function computeDeltas( * `next/cache`, or `server-only` dependency, so the opts matrix and op kinds can * be exercised as pure data — `apply.ts` owns turning this into DB writes. */ -export type MutationPlan = { +type MutationPlan = { ops: DbOp[]; deltas: RevisionDelta[]; structural: LegalityIssue[]; diff --git a/lib/deck/partner-keywords.ts b/lib/deck/partner-keywords.ts index c3b0326..f750090 100644 --- a/lib/deck/partner-keywords.ts +++ b/lib/deck/partner-keywords.ts @@ -1,7 +1,10 @@ const MULTI_COMMANDER_KEYWORDS = [ - "Partner", // also matches "Partner with [Name]" and "Partner—[X]" via startsWith + "Partner", // generic Partner only — see canHavePartner for "Partner with" exclusion "Doctor's companion", - "Choose a background", // Scryfall stores lowercase 'b' + // Scryfall stores "Choose a background" with a lowercase 'b' — verified against card data. + "Choose a background", + // Rowan, Scholar of Sparks // Will, Scholar of Frost — exact string from Scryfall keywords array. + "Friends forever", ] as const; export function canHavePartner( @@ -10,8 +13,14 @@ export function canHavePartner( ): boolean { if (typeLine?.split("—")[1]?.includes("Background")) return true; return keywords.some((k) => - MULTI_COMMANDER_KEYWORDS.some((p) => - p === "Partner" ? k.startsWith("Partner") : k === p, - ), + MULTI_COMMANDER_KEYWORDS.some((p) => { + if (p === "Partner") { + // "Partner with [Name]" is a named-partner pairing — the two cards can + // only pair with each other, not freely with any other commander. + // Exclude it here; named-pairing validation is tracked as a follow-up. + return k.startsWith("Partner") && !k.startsWith("Partner with"); + } + return k === p; + }), ); } diff --git a/lib/deck/queries.ts b/lib/deck/queries.ts index 990f1df..6acf04c 100644 --- a/lib/deck/queries.ts +++ b/lib/deck/queries.ts @@ -247,7 +247,7 @@ export function selectDeckPreviewImages( * the COMMANDER zone, name-tiebroken. Null for non-Commander decks or when the * deck has no commander. */ -export function selectCommanderName( +function selectCommanderName( format: import("@/lib/generated/prisma/enums").Format, cards: DeckPreviewCard[], ): string | null { diff --git a/lib/inventory/__tests__/state.test.ts b/lib/inventory/__tests__/state.test.ts index 16d6776..ceed60a 100644 --- a/lib/inventory/__tests__/state.test.ts +++ b/lib/inventory/__tests__/state.test.ts @@ -115,6 +115,15 @@ describe("computeOwnershipState", () => { }); }); + it("unpinned DeckCard: skips holdings for a different card", () => { + const holdings: ViewerHolding[] = [ + { cardId: 999, printingId: PRINTING, isFoil: false, state: "OWNED" }, + ]; + expect(computeOwnershipState(dc({ printingId: null }), holdings)).toEqual({ + state: "NOT_OWNED", + }); + }); + it("unpinned DeckCard: owned beats wishlist regardless of order", () => { const holdings: ViewerHolding[] = [ { cardId: CARD, printingId: PRINTING, isFoil: false, state: "WISHLIST" }, diff --git a/lib/inventory/state.ts b/lib/inventory/state.ts index 936647b..a581158 100644 --- a/lib/inventory/state.ts +++ b/lib/inventory/state.ts @@ -50,7 +50,8 @@ export function computeOwnershipState( for (const h of holdings) { if (h.cardId !== dc.cardId) continue; if (h.state === "OWNED") return { state: "OWNED" }; - if (h.state === "WISHLIST") wishlisted = true; + // Only WISHLIST remains (HoldingState is OWNED | WISHLIST). + wishlisted = true; } return wishlisted ? { state: "WISHLIST" } : { state: "NOT_OWNED" }; } diff --git a/lib/scryfall/__tests__/map.test.ts b/lib/scryfall/__tests__/map.test.ts index 9c9c33a..2b0f989 100644 --- a/lib/scryfall/__tests__/map.test.ts +++ b/lib/scryfall/__tests__/map.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as telemetry from "@/lib/telemetry"; import { hashObject, toCardCreate, toPrintingCreate } from "../map"; import type { Legalities } from "@/lib/card/types-meta"; import type { ScryfallCard } from "../types"; @@ -236,7 +237,7 @@ describe("toPrintingCreate", () => { expect(p.finishes).toEqual([]); }); - it("maps prices: undefined / '' / null → null; real string passthrough", () => { + it("maps prices: undefined / '' / null → null; real string normalized", () => { const p = toPrintingCreate( 1, makeCard({ @@ -255,6 +256,40 @@ describe("toPrintingCreate", () => { expect(p.priceEurFoil).toBeNull(); }); + describe("parsePrice", () => { + it("normalizes '3.5' to '3.50'", () => { + const p = toPrintingCreate(1, makeCard({ prices: { usd: "3.5" } })); + expect(p.priceUsd).toBe("3.50"); + }); + + it("returns null for 'N/A' and emits a logWarn", () => { + const warnSpy = vi.spyOn(telemetry, "logWarn").mockImplementation(() => {}); + const p = toPrintingCreate(1, makeCard({ prices: { usd: "N/A" } })); + expect(p.priceUsd).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("parsePrice"), + ); + warnSpy.mockRestore(); + }); + + it("returns null for '1,234.56' (comma-formatted) and emits a logWarn", () => { + const warnSpy = vi.spyOn(telemetry, "logWarn").mockImplementation(() => {}); + const p = toPrintingCreate(1, makeCard({ prices: { usd: "1,234.56" } })); + expect(p.priceUsd).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("parsePrice"), + ); + warnSpy.mockRestore(); + }); + + it("returns null for null", () => { + const p = toPrintingCreate(1, makeCard({ prices: { usd: null } })); + expect(p.priceUsd).toBeNull(); + }); + }); + it("maps priceEurEtched from card.prices.eur_etched when present", () => { const p = toPrintingCreate( 1, diff --git a/lib/scryfall/map.ts b/lib/scryfall/map.ts index d5dd852..c093af5 100644 --- a/lib/scryfall/map.ts +++ b/lib/scryfall/map.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { Prisma } from "@/lib/generated/prisma/client"; import type { Rarity } from "@/lib/generated/prisma/enums"; import { toNameSlug } from "@/lib/utils"; +import { logWarn } from "@/lib/telemetry"; import { normalizeLegalities } from "./formats"; import type { ScryfallCard } from "./types"; import { getMainType } from "./types-card"; @@ -54,8 +55,14 @@ function normalizeFinishes(finishes: string[] | undefined): string[] { } function parsePrice(value: string | null | undefined): string | null { - if (value === null || value === undefined || value === "") return null; - return value; + if (value == null || value === "") return null; + const n = Number(value); + if (!Number.isFinite(n)) { + // A non-empty, non-numeric value likely means Scryfall changed its format. + logWarn({ source: "scryfall.map" }, `parsePrice: unexpected value "${value}"`); + return null; + } + return n.toFixed(2); } function getImageUris( diff --git a/lib/stats/__tests__/compute.test.ts b/lib/stats/__tests__/compute.test.ts index e3af415..2271ccf 100644 --- a/lib/stats/__tests__/compute.test.ts +++ b/lib/stats/__tests__/compute.test.ts @@ -150,6 +150,38 @@ describe("computeColorPips", () => { expect(pips.W + pips.U + pips.B + pips.R).toBe(0); }); + it("counts Phyrexian pip {W/P} at full weight (1.0)", () => { + // {W/P} is castable with W mana or 2 life; manabase still needs white sources + const card = makeCard({ mainType: "Instant", manaCost: "{W/P}" }); + const pips = computeColorPips([makeDeckCard(card, { quantity: 2 })]); + expect(pips.W).toBe(2); + expect(pips.U + pips.B + pips.R + pips.G + pips.C).toBe(0); + }); + + it("counts twobrid pip {2/G} at 0.5 weight", () => { + // {2/G} can be paid with 2 generic or 1 green; soft color dependency + const card = makeCard({ mainType: "Creature", manaCost: "{2/G}{2/G}" }); + const pips = computeColorPips([makeDeckCard(card, { quantity: 1 })]); + expect(pips.G).toBe(1); // 2 × 0.5 + expect(pips.W + pips.U + pips.B + pips.R + pips.C).toBe(0); + }); + + it("pure generic {2} contributes nothing to any color", () => { + const card = makeCard({ mainType: "Creature", manaCost: "{2}{2}" }); + const pips = computeColorPips([makeDeckCard(card, { quantity: 3 })]); + expect(pips).toEqual({ W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }); + }); + + it("handles mixed Phyrexian + mono + twobrid in one cost", () => { + // e.g. {U/P}{2/R}{B} — U full, R 0.5, B full + const card = makeCard({ mainType: "Instant", manaCost: "{U/P}{2/R}{B}" }); + const pips = computeColorPips([makeDeckCard(card, { quantity: 1 })]); + expect(pips.U).toBe(1); + expect(pips.R).toBeCloseTo(0.5); + expect(pips.B).toBe(1); + expect(pips.W + pips.G + pips.C).toBe(0); + }); + it("excludes SIDEBOARD and CONSIDERING zones", () => { const bolt = makeCard({ mainType: "Instant", manaCost: "{R}" }); const cards = [ diff --git a/lib/stats/compute.ts b/lib/stats/compute.ts index 693083e..47ee8f8 100644 --- a/lib/stats/compute.ts +++ b/lib/stats/compute.ts @@ -78,12 +78,32 @@ export function computeColorPipsRaw( const monoRegex = /\{([WUBRGC])\}/g; const hybridRegex = /\{([WUBRG])\/([WUBRG])\}/g; + // Phyrexian mana {C/P} is castable with that color's mana or 2 life. + // We count it at full weight (1.0) because the color commitment is real — + // building around Phyrexian cards still demands that color in the manabase. + const phyrexianRegex = /\{([WUBRG])\/P\}/g; + // Twobrid mana {2/C} can be paid with 2 generic or 1 of that color. + // Weight 0.5 signals a soft color dependency — like hybrid, the card is + // playable without the color but benefits from it. + const twobrideRegex = /\{2\/([WUBRG])\}/g; for (const dc of cards) { const manaCost = dc.card.manaCost; if (!manaCost) continue; - const stripped = manaCost.replace(hybridRegex, (_, a: string, b: string) => { + // Strip Phyrexian and twobrid symbols before monoRegex runs so that + // bare {2} generic mana (from the stripped twobrid slot) contributes nothing. + const afterPhyrexian = manaCost.replace(phyrexianRegex, (_, c: string) => { + pips[c as keyof typeof pips] += 1.0 * dc.quantity; + return ""; + }); + + const afterTwobrid = afterPhyrexian.replace(twobrideRegex, (_, c: string) => { + pips[c as keyof typeof pips] += 0.5 * dc.quantity; + return ""; + }); + + const stripped = afterTwobrid.replace(hybridRegex, (_, a: string, b: string) => { pips[a as keyof typeof pips] += 0.5 * dc.quantity; pips[b as keyof typeof pips] += 0.5 * dc.quantity; return ""; diff --git a/next.config.ts b/next.config.ts index 8780f5e..f74bf6a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -50,6 +50,38 @@ const nextConfig: NextConfig = { }, ]; }, + + async headers() { + // script-src includes the two Vercel Insights origins that are proxied via + // rewrites() — cdn.vercel-insights.com (speed insights + events script) + // and vitals.vercel-insights.com (telemetry endpoint). + // Shipped as Report-Only so we can observe violations before enforcing. + const csp = [ + "script-src 'self' https://cdn.vercel-insights.com https://vitals.vercel-insights.com", + "frame-ancestors 'none'", + ].join("; "); + + const securityHeaders = [ + { + key: "Content-Security-Policy-Report-Only", + value: csp, + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + ]; + + return [{ source: "/(.*)", headers: securityHeaders }]; + }, }; export default withWorkflow(bundleAnalyzer(nextConfig)); diff --git a/workflows/_shared/ingest-lock.ts b/workflows/_shared/ingest-lock.ts index 86cf73a..44e2403 100644 --- a/workflows/_shared/ingest-lock.ts +++ b/workflows/_shared/ingest-lock.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@/lib/generated/prisma/client"; import { prisma } from "@/lib/db"; // Ingest can run for many minutes; this gates how long another run will wait @@ -23,7 +24,13 @@ export async function acquireIngestLock( try { await prisma.ingestLock.create({ data: { source, workflowId } }); return true; - } catch { + } catch (err) { + if ( + !(err instanceof Prisma.PrismaClientKnownRequestError) || + err.code !== "P2002" + ) { + throw err; + } const staleBefore = new Date(Date.now() - INGEST_LOCK_STALE_MS); const { count } = await prisma.ingestLock.updateMany({ where: { source, acquiredAt: { lt: staleBefore } }, diff --git a/workflows/precon/steps.ts b/workflows/precon/steps.ts index 0204bcd..929cebe 100644 --- a/workflows/precon/steps.ts +++ b/workflows/precon/steps.ts @@ -23,6 +23,7 @@ import { } from "@/lib/precon/mtgjson"; import { getBatchStorage } from "@/lib/staging"; import { logInfo, logWarn } from "@/lib/telemetry"; +import { runWithConcurrency } from "@/lib/concurrency"; import { SCRYFALL_SOURCE } from "@/workflows/scryfall/steps"; export const PRECON_SOURCE = "mtgjson:decks"; @@ -97,27 +98,6 @@ export async function scryfallCheckpointFreshEnough(): Promise { return Date.now() - row.ranAt.getTime() < SCRYFALL_FRESHNESS_MAX_MS; } -async function runWithConcurrency( - items: T[], - concurrency: number, - fn: (item: T) => Promise, -): Promise { - const results: R[] = new Array(items.length); - let cursor = 0; - const worker = async (): Promise => { - while (true) { - const i = cursor++; - if (i >= items.length) return; - results[i] = await fn(items[i] as T); - } - }; - const workers = Array.from( - { length: Math.min(concurrency, items.length) }, - worker, - ); - await Promise.all(workers); - return results; -} export async function downloadAndStagePrecons( deckIndex: MtgjsonDeckIndexEntry[], diff --git a/workflows/scryfall/__tests__/steps.test.ts b/workflows/scryfall/__tests__/steps.test.ts index 9a1c79b..980fdd5 100644 --- a/workflows/scryfall/__tests__/steps.test.ts +++ b/workflows/scryfall/__tests__/steps.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { revalidateTag } from "next/cache"; import { getWritable } from "workflow"; +import { Prisma } from "@/lib/generated/prisma/client"; import { prisma } from "@/lib/db"; import type { ScryfallCard } from "@/lib/scryfall/types"; import { getBatchStorage } from "@/lib/staging"; @@ -768,7 +769,10 @@ describe("acquireIngestLock", () => { it("returns false when an active lock is held by another run", async () => { mockedPrisma.ingestLock.create.mockRejectedValueOnce( - new Error("unique violation"), + new Prisma.PrismaClientKnownRequestError("unique violation", { + code: "P2002", + clientVersion: "test", + }), ); mockedPrisma.ingestLock.updateMany.mockResolvedValueOnce({ count: 0, @@ -781,7 +785,10 @@ describe("acquireIngestLock", () => { it("steals a stale lock", async () => { mockedPrisma.ingestLock.create.mockRejectedValueOnce( - new Error("unique violation"), + new Prisma.PrismaClientKnownRequestError("unique violation", { + code: "P2002", + clientVersion: "test", + }), ); mockedPrisma.ingestLock.updateMany.mockResolvedValueOnce({ count: 1, @@ -798,6 +805,29 @@ describe("acquireIngestLock", () => { expect(args.where.acquiredAt.lt).toBeInstanceOf(Date); expect(args.data.workflowId).toBe("run-3"); }); + + it("rethrows non-P2002 Prisma errors from the lock create", async () => { + const connectionErr = new Prisma.PrismaClientKnownRequestError( + "connection failed", + { code: "P1001", clientVersion: "test" }, + ); + mockedPrisma.ingestLock.create.mockRejectedValueOnce(connectionErr); + + await expect( + acquireIngestLock(SCRYFALL_SOURCE, "run-err"), + ).rejects.toThrow(connectionErr); + expect(mockedPrisma.ingestLock.updateMany).not.toHaveBeenCalled(); + }); + + it("rethrows plain (non-Prisma) errors from the lock create", async () => { + const dbErr = new Error("unexpected DB error"); + mockedPrisma.ingestLock.create.mockRejectedValueOnce(dbErr); + + await expect( + acquireIngestLock(SCRYFALL_SOURCE, "run-plain"), + ).rejects.toThrow(dbErr); + expect(mockedPrisma.ingestLock.updateMany).not.toHaveBeenCalled(); + }); }); describe("releaseIngestLock", () => { @@ -811,7 +841,7 @@ describe("releaseIngestLock", () => { }); describe("upsertBatch — token enrichment", () => { - it("creates CardToken rows for all_parts entries with component=token", async () => { + it("bulk upserts CardToken rows via $executeRaw for all_parts entries with component=token", async () => { const card = makeCard({ id: "s-1", name: "Goblin Rabblemaster", @@ -834,28 +864,14 @@ describe("upsertBatch — token enrichment", () => { mockedPrisma.card.createMany.mockResolvedValue({ count: 1 } as never); mockedPrisma.printing.createMany.mockResolvedValue({ count: 1 } as never); + const executeRawCallsBefore = mockedPrisma.$executeRaw.mock.calls.length; await upsertBatch("run", 0); - expect(mockedPrisma.$transaction).toHaveBeenCalled(); - const txCalls = mockedPrisma.$transaction.mock.calls; - // Last $transaction call should include the token upsert - const lastTxCall = txCalls[txCalls.length - 1]![0]; - expect(lastTxCall).toHaveLength(1); - expect(mockedPrisma.cardToken.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - cardId_tokenScryfallId: { - cardId: 1, - tokenScryfallId: "token-goblin", - }, - }, - create: expect.objectContaining({ - cardId: 1, - tokenName: "Goblin Token", - tokenScryfallId: "token-goblin", - }), - }), + // Token bulk upsert now uses $executeRaw, not $transaction + cardToken.upsert. + expect(mockedPrisma.$executeRaw.mock.calls.length).toBeGreaterThan( + executeRawCallsBefore, ); + expect(mockedPrisma.cardToken.upsert).not.toHaveBeenCalled(); }); it("does not create CardToken rows for meld_part, meld_result, or combo_piece parts", async () => { @@ -895,8 +911,11 @@ describe("upsertBatch — token enrichment", () => { mockedPrisma.card.createMany.mockResolvedValue({ count: 1 } as never); mockedPrisma.printing.createMany.mockResolvedValue({ count: 1 } as never); + const executeRawCallsBefore = mockedPrisma.$executeRaw.mock.calls.length; await upsertBatch("run", 0); + // No token rows → $executeRaw should not have gained a new call. + expect(mockedPrisma.$executeRaw.mock.calls.length).toBe(executeRawCallsBefore); expect(mockedPrisma.cardToken.upsert).not.toHaveBeenCalled(); }); @@ -909,8 +928,10 @@ describe("upsertBatch — token enrichment", () => { mockedPrisma.card.createMany.mockResolvedValue({ count: 1 } as never); mockedPrisma.printing.createMany.mockResolvedValue({ count: 1 } as never); + const executeRawCallsBefore = mockedPrisma.$executeRaw.mock.calls.length; await upsertBatch("run", 0); + expect(mockedPrisma.$executeRaw.mock.calls.length).toBe(executeRawCallsBefore); expect(mockedPrisma.cardToken.upsert).not.toHaveBeenCalled(); }); @@ -950,19 +971,14 @@ describe("upsertBatch — token enrichment", () => { mockedPrisma.card.createMany.mockResolvedValue({ count: 1 } as never); mockedPrisma.printing.createMany.mockResolvedValue({ count: 1 } as never); + const executeRawCallsBefore = mockedPrisma.$executeRaw.mock.calls.length; await upsertBatch("run", 0); - expect(mockedPrisma.cardToken.upsert).toHaveBeenCalledTimes(1); - expect(mockedPrisma.cardToken.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - cardId_tokenScryfallId: { - cardId: 1, - tokenScryfallId: "token-mapped", - }, - }, - }), + // Only the "Mapped" token row is written — one $executeRaw chunk. + expect(mockedPrisma.$executeRaw.mock.calls.length).toBe( + executeRawCallsBefore + 1, ); + expect(mockedPrisma.cardToken.upsert).not.toHaveBeenCalled(); }); }); diff --git a/workflows/scryfall/steps.ts b/workflows/scryfall/steps.ts index 4c383f8..e785284 100644 --- a/workflows/scryfall/steps.ts +++ b/workflows/scryfall/steps.ts @@ -457,19 +457,31 @@ async function upsertTokens( if (tokenRows.length === 0) return; - await prisma.$transaction( - (tx) => - Promise.all( - tokenRows.map((row) => - tx.cardToken.upsert({ - where: { cardId_tokenScryfallId: { cardId: row.cardId, tokenScryfallId: row.tokenScryfallId } }, - create: row, - update: { tokenName: row.tokenName }, - }), - ), - ), - { timeout: 60_000 }, - ); + // A card can list the same token id more than once across its all_parts + // (or two cards in the batch can share one); duplicate (card_id, + // token_scryfall_id) pairs in a single INSERT make Postgres reject the + // statement with "ON CONFLICT DO UPDATE command cannot affect row a second + // time". Dedupe on the conflict key, keeping the last-seen token_name. + const dedupedRows = [ + ...new Map( + tokenRows.map((r) => [`${r.cardId}${r.tokenScryfallId}`, r]), + ).values(), + ]; + + const UPSERT_CHUNK = 500; + for (let i = 0; i < dedupedRows.length; i += UPSERT_CHUNK) { + const chunk = dedupedRows.slice(i, i + UPSERT_CHUNK); + const rows = chunk.map( + (r) => + Prisma.sql`(${r.cardId}, ${r.tokenName}, ${r.tokenScryfallId})`, + ); + await prisma.$executeRaw` + INSERT INTO card_token (card_id, token_name, token_scryfall_id) + VALUES ${Prisma.join(rows)} + ON CONFLICT (card_id, token_scryfall_id) DO UPDATE SET + token_name = EXCLUDED.token_name + `; + } } // Diff existing Card rows in this Batch against incoming Scryfall data, then