👁️
1/**
2 * Contract tests for CardDataProvider implementations
3 *
4 * Ensures ClientCardProvider and ServerCardProvider return identical data
5 */
6
7import { beforeAll, describe, expect, it, vi } from "vitest";
8import type { CardDataProvider } from "../card-data-provider";
9import { ClientCardProvider } from "../cards-client-provider";
10import { ServerCardProvider } from "../cards-server-provider";
11import type { ScryfallId } from "../scryfall-types";
12import { asOracleId, asScryfallId } from "../scryfall-types";
13import { getTestCardOracleId } from "./test-card-lookup";
14import { mockFetchFromPublicDir } from "./test-helpers";
15
16// Mock cards-worker-client to use real worker code without Comlink/Worker
17vi.mock("../cards-worker-client", () => {
18 // biome-ignore lint/suspicious/noExplicitAny: mock needs to accept any worker instance
19 let workerInstance: any = null;
20
21 return {
22 initializeWorker: async () => {
23 const { __CardsWorkerForTestingOnly } = await import(
24 "../../workers/cards.worker"
25 );
26 workerInstance = new __CardsWorkerForTestingOnly();
27 await workerInstance.initialize();
28 },
29 getCardsWorker: () =>
30 new Proxy(workerInstance, {
31 get(target, prop) {
32 const value = target[prop];
33 if (typeof value === "function") {
34 // biome-ignore lint/suspicious/noExplicitAny: proxy needs to handle any function signature
35 return async (...args: any[]) => {
36 const result = await value.apply(target, args);
37 return structuredClone(result);
38 };
39 }
40 return value;
41 },
42 }),
43 };
44});
45
46// Test card from fixture
47const TEST_CARD_NAME = "Forest";
48const TEST_CARD_ORACLE = getTestCardOracleId(TEST_CARD_NAME);
49
50// Resolved at runtime in beforeAll (needs provider)
51let TEST_CARD_ID: ScryfallId;
52
53const INVALID_ID = asScryfallId("00000000-0000-0000-0000-000000000000");
54const INVALID_ORACLE = asOracleId("00000000-0000-0000-0000-000000000000");
55
56// Sample of 50 card IDs evenly distributed across dataset for bulk testing
57const SAMPLE_CARD_IDS = [
58 "0000419b-0bba-4488-8f7a-6194544ce91e",
59 "050ee59e-23fc-4476-8d1f-3f29d3ec9e74",
60 "0a05ead0-5e82-4af9-9c7e-78a6de425673",
61 "0eed3360-1634-4e60-b24d-4128c4896994",
62 "142479d8-8956-44a2-8c54-9dd6dc1774c0",
63 "19439420-9e0a-4fd1-bae9-f1c698edee1c",
64 "1e6e80ec-68a4-4cfb-a712-2ea0d26dc6a1",
65 "23a48f4a-96eb-47bd-9384-3bcc959a7c4b",
66 "28a6b23f-a854-469a-9b06-119507dd9d42",
67 "2dde460a-208f-4758-b172-64123ac69d75",
68 "32eb6ab0-831e-4a30-a2fc-5ea1cb40930c",
69 "381097a1-aac8-449b-bed5-ec0d9879a2c8",
70 "3d5529ca-5c20-4dfd-8595-96d6dfa6debe",
71 "427e31b2-2c53-46c8-af51-16a1ad6c66fd",
72 "47a469be-e43a-48cb-b216-bb39ade32acb",
73 "4cc636b1-5fc5-422d-9722-5fa12c754d6c",
74 "520d3b08-bd71-422c-ae6b-d67360a36aca",
75 "570a8272-7ca1-47db-834b-82f603d1417a",
76 "5bf655ce-c841-42b2-9578-56ab401bf4de",
77 "610def80-1303-488e-bfd9-4a27f031d20e",
78 "6620ead7-4499-4a19-b3b8-edd263067c02",
79 "6b3eb24e-bba4-463b-ad7e-e3daebda1e74",
80 "7083fa8a-a841-4c69-9443-af35676a7817",
81 "75a62b31-986c-4722-97da-d984272f0f05",
82 "7ae76409-40d7-4a54-ad58-5c67996b1a0c",
83 "8010cc08-7035-4daf-a4c4-e8d8959e1e82",
84 "853d15cd-a1a2-47a8-89c4-81b7ca663fff",
85 "8a238f08-f7c3-46be-b999-77b1c310cb1a",
86 "8f5427b1-f1c2-4bb3-8736-701667ac2256",
87 "949d42fb-72ff-40f9-8aff-7b0937fcdedf",
88 "998d0cc8-ca2a-41c3-ab65-d05c26ab8278",
89 "9e997f78-22a2-4b66-ac10-1adc9a72ce3b",
90 "a39d6484-6530-4237-84b9-68ab8a056e7c",
91 "a898939c-fb28-40cb-9c48-49763c0771a1",
92 "ada2b522-219a-44be-98c1-83a02efdd709",
93 "b2d51bdf-f118-4a1e-9060-bdf3c78697f2",
94 "b7e92c82-840f-4c75-b617-7b58a07be5b4",
95 "bd139009-fe5d-4189-8cde-a68ede6283fc",
96 "c233f64e-179f-4783-a6a4-d3fe2c718a39",
97 "c7552208-fd7c-4dc0-b7dd-acfcd3f78841",
98 "cc738025-a771-4186-b08c-7b37c0e9713b",
99 "d15adc93-1a71-453b-8277-4a525a9cbc7b",
100 "d691cd3b-afe5-4f28-95a9-125475515126",
101 "dbb0df36-8467-4a41-8e1c-6c3584d4fd10",
102 "e0d4b681-9f20-4bb5-8a6d-552f069e577f",
103 "e62d3bcc-7bb4-42be-90a9-caf3c1caa29d",
104 "eb58d7ba-ba86-433e-8f1e-3f492c380796",
105 "f08383ed-bf90-474a-97fb-9d7f8b3fb70a",
106 "f5a65d3b-83e7-4f32-89b3-d152e66f1868",
107 "fac38053-817d-4e0c-b6cc-81b6b92e652f",
108];
109
110describe("CardDataProvider contract", () => {
111 let clientProvider: ClientCardProvider;
112 let serverProvider: ServerCardProvider;
113
114 beforeAll(async () => {
115 mockFetchFromPublicDir();
116
117 serverProvider = new ServerCardProvider();
118 clientProvider = new ClientCardProvider();
119 await clientProvider.initialize();
120
121 // Resolve test card ID from oracle ID
122 const cardId = await serverProvider.getCanonicalPrinting(TEST_CARD_ORACLE);
123 if (!cardId) {
124 throw new Error(`Failed to resolve test card ID for ${TEST_CARD_NAME}`);
125 }
126 TEST_CARD_ID = cardId;
127 }, 20_000);
128
129 describe.each([
130 ["ServerCardProvider", () => serverProvider],
131 ["ClientCardProvider", () => clientProvider],
132 ])("%s", (_name, getProvider) => {
133 let provider: CardDataProvider;
134
135 beforeAll(() => {
136 provider = getProvider();
137 });
138
139 describe("getCardById", () => {
140 it("returns a valid card for known ID", async () => {
141 const card = await provider.getCardById(TEST_CARD_ID);
142
143 expect(card).toBeDefined();
144 expect(card?.id).toBe(TEST_CARD_ID);
145 expect(card?.name).toBe(TEST_CARD_NAME);
146 expect(card?.oracle_id).toBe(TEST_CARD_ORACLE);
147 });
148
149 it("returns undefined for invalid ID", async () => {
150 const card = await provider.getCardById(INVALID_ID);
151 expect(card).toBeUndefined();
152 });
153
154 it("returns undefined for missing ID", async () => {
155 const missingId = asScryfallId("ffffffff-ffff-ffff-ffff-ffffffffffff");
156 const card = await provider.getCardById(missingId);
157 expect(card).toBeUndefined();
158 });
159 });
160
161 describe("getPrintingsByOracleId", () => {
162 it("returns printing IDs for known oracle ID", async () => {
163 const printings =
164 await provider.getPrintingsByOracleId(TEST_CARD_ORACLE);
165
166 expect(Array.isArray(printings)).toBe(true);
167 expect(printings.length).toBeGreaterThan(0);
168 expect(printings).toContain(TEST_CARD_ID);
169 });
170
171 it("returns empty array for invalid oracle ID", async () => {
172 const printings = await provider.getPrintingsByOracleId(INVALID_ORACLE);
173 expect(printings).toEqual([]);
174 });
175
176 it("returns empty array for missing oracle ID", async () => {
177 const missingOracle = asOracleId(
178 "ffffffff-ffff-ffff-ffff-ffffffffffff",
179 );
180 const printings = await provider.getPrintingsByOracleId(missingOracle);
181 expect(printings).toEqual([]);
182 });
183 });
184
185 describe("getMetadata", () => {
186 it("returns version and card count", async () => {
187 const metadata = await provider.getMetadata();
188
189 expect(metadata).toHaveProperty("version");
190 expect(metadata).toHaveProperty("cardCount");
191 expect(typeof metadata.version).toBe("string");
192 expect(typeof metadata.cardCount).toBe("number");
193 expect(metadata.cardCount).toBeGreaterThan(0);
194 });
195 });
196
197 describe("getCanonicalPrinting", () => {
198 it("returns canonical printing ID for known oracle ID", async () => {
199 const canonicalId =
200 await provider.getCanonicalPrinting(TEST_CARD_ORACLE);
201
202 expect(canonicalId).toBeDefined();
203 expect(typeof canonicalId).toBe("string");
204 });
205
206 it("returns undefined for invalid oracle ID", async () => {
207 const canonicalId = await provider.getCanonicalPrinting(INVALID_ORACLE);
208 expect(canonicalId).toBeUndefined();
209 });
210
211 it("returns undefined for missing oracle ID", async () => {
212 const missingOracle = asOracleId(
213 "ffffffff-ffff-ffff-ffff-ffffffffffff",
214 );
215 const canonicalId = await provider.getCanonicalPrinting(missingOracle);
216 expect(canonicalId).toBeUndefined();
217 });
218 });
219 });
220
221 describe("Cross-provider consistency", () => {
222 it("returns identical card data", async () => {
223 const [clientCard, serverCard] = await Promise.all([
224 clientProvider.getCardById(TEST_CARD_ID),
225 serverProvider.getCardById(TEST_CARD_ID),
226 ]);
227
228 expect(clientCard).toEqual(serverCard);
229 });
230
231 it("returns identical printing lists", async () => {
232 const [clientPrintings, serverPrintings] = await Promise.all([
233 clientProvider.getPrintingsByOracleId(TEST_CARD_ORACLE),
234 serverProvider.getPrintingsByOracleId(TEST_CARD_ORACLE),
235 ]);
236
237 expect(clientPrintings).toEqual(serverPrintings);
238 });
239
240 it("returns identical metadata", async () => {
241 const [clientMetadata, serverMetadata] = await Promise.all([
242 clientProvider.getMetadata(),
243 serverProvider.getMetadata(),
244 ]);
245
246 expect(clientMetadata).toEqual(serverMetadata);
247 });
248
249 it("returns identical canonical printings", async () => {
250 const [clientCanonical, serverCanonical] = await Promise.all([
251 clientProvider.getCanonicalPrinting(TEST_CARD_ORACLE),
252 serverProvider.getCanonicalPrinting(TEST_CARD_ORACLE),
253 ]);
254
255 expect(clientCanonical).toEqual(serverCanonical);
256 });
257
258 it("returns identical results for missing IDs", async () => {
259 const missingId = asScryfallId("ffffffff-ffff-ffff-ffff-ffffffffffff");
260 const [clientCard, serverCard] = await Promise.all([
261 clientProvider.getCardById(missingId),
262 serverProvider.getCardById(missingId),
263 ]);
264
265 expect(clientCard).toBeUndefined();
266 expect(serverCard).toBeUndefined();
267 expect(clientCard).toEqual(serverCard);
268 });
269
270 it.each(SAMPLE_CARD_IDS)(
271 "returns identical data for card %s",
272 async (cardId) => {
273 const id = asScryfallId(cardId);
274 const [clientCard, serverCard] = await Promise.all([
275 clientProvider.getCardById(id),
276 serverProvider.getCardById(id),
277 ]);
278
279 expect(clientCard).toEqual(serverCard);
280 },
281 );
282 });
283
284 describe("ClientCardProvider specific", () => {
285 it("supports searchCards", async () => {
286 expect(clientProvider.searchCards).toBeDefined();
287
288 const results = await clientProvider.searchCards("forest", undefined, 10);
289 expect(Array.isArray(results)).toBe(true);
290 expect(results.length).toBeGreaterThan(0);
291
292 const forest = results.find((c) => c.name === TEST_CARD_NAME);
293 expect(forest).toBeDefined();
294 });
295 });
296
297 describe("ServerCardProvider specific", () => {
298 it("does not support searchCards", () => {
299 expect(serverProvider.searchCards).toBeUndefined();
300 });
301
302 describe("getCardsByIds (batch fetch)", () => {
303 it("returns cards for valid IDs", async () => {
304 const ids = SAMPLE_CARD_IDS.slice(0, 5).map(asScryfallId);
305 const result = await serverProvider.getCardsByIds(ids);
306
307 expect(result.size).toBe(5);
308 for (const id of ids) {
309 expect(result.has(id)).toBe(true);
310 expect(result.get(id)?.id).toBe(id);
311 }
312 });
313
314 it("handles mixed valid and invalid IDs", async () => {
315 const validIds = SAMPLE_CARD_IDS.slice(0, 3).map(asScryfallId);
316 const invalidIds = [INVALID_ID];
317 const result = await serverProvider.getCardsByIds([
318 ...validIds,
319 ...invalidIds,
320 ]);
321
322 expect(result.size).toBe(3);
323 for (const id of validIds) {
324 expect(result.has(id)).toBe(true);
325 }
326 expect(result.has(INVALID_ID)).toBe(false);
327 });
328
329 it("returns same data as individual getCardById calls", async () => {
330 const ids = SAMPLE_CARD_IDS.slice(0, 10).map(asScryfallId);
331 const batchResult = await serverProvider.getCardsByIds(ids);
332 const individualResults = await Promise.all(
333 ids.map((id) => serverProvider.getCardById(id)),
334 );
335
336 for (let i = 0; i < ids.length; i++) {
337 expect(batchResult.get(ids[i])).toEqual(individualResults[i]);
338 }
339 });
340
341 it("handles empty array", async () => {
342 const result = await serverProvider.getCardsByIds([]);
343 expect(result.size).toBe(0);
344 });
345 });
346 });
347
348 describe("Volatile data", () => {
349 describe.each([
350 ["ServerCardProvider", () => serverProvider],
351 ["ClientCardProvider", () => clientProvider],
352 ])("%s", (_name, getProvider) => {
353 it("returns volatile data for known card", async () => {
354 const provider = getProvider();
355 const volatileData = await provider.getVolatileData(TEST_CARD_ID);
356
357 expect(volatileData).not.toBeNull();
358 expect(volatileData).toHaveProperty("edhrecRank");
359 expect(volatileData).toHaveProperty("usd");
360 expect(volatileData).toHaveProperty("usdFoil");
361 expect(volatileData).toHaveProperty("usdEtched");
362 expect(volatileData).toHaveProperty("eur");
363 expect(volatileData).toHaveProperty("eurFoil");
364 expect(volatileData).toHaveProperty("tix");
365 });
366
367 it("returns null for invalid card ID", async () => {
368 const provider = getProvider();
369 const volatileData = await provider.getVolatileData(INVALID_ID);
370 expect(volatileData).toBeNull();
371 });
372 });
373
374 it.each(SAMPLE_CARD_IDS)(
375 "returns identical volatile data for card %s",
376 async (cardId) => {
377 const id = asScryfallId(cardId);
378 const [clientData, serverData] = await Promise.all([
379 clientProvider.getVolatileData(id),
380 serverProvider.getVolatileData(id),
381 ]);
382
383 expect(clientData).toEqual(serverData);
384 },
385 );
386 });
387});