Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions app/(ui)/deck/[id]/play/__tests__/playtest-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
20 changes: 8 additions & 12 deletions app/(ui)/deck/[id]/play/playtest-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
55 changes: 55 additions & 0 deletions app/(ui)/decks/explore/__tests__/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })],
Expand Down Expand Up @@ -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();
});
});
53 changes: 48 additions & 5 deletions app/(ui)/decks/explore/actions.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -61,13 +96,21 @@ export async function loadMorePublicDecks(
page: number,
pageSize: number,
): Promise<LoadMoreResult> {
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,
Expand Down
12 changes: 12 additions & 0 deletions app/_actions/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
70 changes: 70 additions & 0 deletions app/_components/header-search/use-card-search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((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<Response>((_, 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);
Expand Down
28 changes: 26 additions & 2 deletions app/api/ingest/[runId]/progress/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
Expand Down
Loading