unofficial-ravensburger-playhub-mcp
Version:
MCP server for finding Disney Lorcana TCG events
366 lines (365 loc) • 19.4 kB
JavaScript
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { expandStatusesForApi, loadFilterOptions, updateFilterMaps, resolveFormatIds, resolveFormatIdsStrict, resolveCategoryIds, resolveCategoryIdsStrict, getCategoryName, fetchGameplayFormats, fetchCategories, fetchEventQuickFilters, fetchEvents, fetchEventDetails, fetchEventRegistrations, fetchStoreDetails, fetchTournamentRoundMatches, fetchTournamentRoundStandings, fetchStores, autocompletePlaces, geocodeAddress, geocodePlaceId, searchCardsQuick, fetchGames, } from "../lib/api.js";
const sampleFormats = [
{ id: "fmt-1", name: "Constructed" },
{ id: "fmt-2", name: "Draft" },
];
const sampleCategories = [
{ id: "cat-1", name: "League" },
{ id: "cat-2", name: "Tournament" },
];
describe("expandStatusesForApi", () => {
it('expands "all" to upcoming, inProgress, past', () => {
assert.deepStrictEqual(expandStatusesForApi(["all"]), ["upcoming", "inProgress", "past"]);
});
it("passes through statuses when all is not present", () => {
assert.deepStrictEqual(expandStatusesForApi(["upcoming", "inProgress"]), ["upcoming", "inProgress"]);
assert.deepStrictEqual(expandStatusesForApi(["past"]), ["past"]);
});
it("when all is present with others, returns all three API statuses", () => {
assert.deepStrictEqual(expandStatusesForApi(["all", "past"]), ["upcoming", "inProgress", "past"]);
});
it("when input is empty, returns all three API statuses (avoids sending [] to API)", () => {
assert.deepStrictEqual(expandStatusesForApi([]), ["upcoming", "inProgress", "past"]);
});
});
describe("api – filter maps and resolution", () => {
beforeEach(() => {
updateFilterMaps(sampleFormats, sampleCategories);
});
it("updateFilterMaps sets format and category maps", () => {
const formatIds = resolveFormatIds(["Constructed", "Draft"]);
assert.deepStrictEqual(formatIds, ["fmt-1", "fmt-2"]);
const categoryIds = resolveCategoryIds(["League", "Tournament"]);
assert.deepStrictEqual(categoryIds, ["cat-1", "cat-2"]);
});
it("resolveFormatIds returns only known names and skips unknown", () => {
const stub = () => { };
const orig = console.error;
console.error = stub;
try {
const ids = resolveFormatIds(["Constructed", "UnknownFormat", "Draft"]);
assert.deepStrictEqual(ids, ["fmt-1", "fmt-2"]);
}
finally {
console.error = orig;
}
});
it("resolveFormatIds returns empty for empty input", () => {
assert.deepStrictEqual(resolveFormatIds([]), []);
});
it("resolveCategoryIds returns only known names and skips unknown", () => {
const stub = () => { };
const orig = console.error;
console.error = stub;
try {
const ids = resolveCategoryIds(["League", "UnknownCat", "Tournament"]);
assert.deepStrictEqual(ids, ["cat-1", "cat-2"]);
}
finally {
console.error = orig;
}
});
it("resolveCategoryIds returns empty for empty input", () => {
assert.deepStrictEqual(resolveCategoryIds([]), []);
});
it("getCategoryName returns name for known template id", () => {
assert.strictEqual(getCategoryName("cat-1"), "League");
assert.strictEqual(getCategoryName("cat-2"), "Tournament");
});
it("getCategoryName returns templateId when not in map", () => {
assert.strictEqual(getCategoryName("unknown-id"), "unknown-id");
});
it("resolveFormatIdsStrict returns ids for all known names", () => {
const ids = resolveFormatIdsStrict(["Constructed", "Draft"]);
assert.deepStrictEqual(ids, ["fmt-1", "fmt-2"]);
});
it("resolveFormatIdsStrict throws for unknown format names", () => {
assert.throws(() => resolveFormatIdsStrict(["Constructed", "UnknownFormat"]), /Unknown format.*Use list_filters.*UnknownFormat/);
});
it("resolveCategoryIdsStrict returns ids for all known names", () => {
const ids = resolveCategoryIdsStrict(["League", "Tournament"]);
assert.deepStrictEqual(ids, ["cat-1", "cat-2"]);
});
it("resolveCategoryIdsStrict throws for unknown category names", () => {
assert.throws(() => resolveCategoryIdsStrict(["League", "Set Championship"]), /Unknown category.*Use list_filters.*Set Championship/);
});
});
describe("api – fetch with mocked global fetch", () => {
let originalFetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetchGameplayFormats returns json on ok response", async () => {
const data = [{ id: "1", name: "Constructed" }];
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await fetchGameplayFormats();
assert.deepStrictEqual(result, data);
});
it("fetchGameplayFormats throws on non-ok response", async () => {
globalThis.fetch = async (_input) => new Response("error", { status: 500 });
await assert.rejects(fetchGameplayFormats, /Failed to fetch formats/);
});
it("fetchCategories returns json on ok response", async () => {
const data = [{ id: "1", name: "League" }];
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await fetchCategories();
assert.deepStrictEqual(result, data);
});
it("fetchCategories throws on non-ok response", async () => {
globalThis.fetch = async (_input) => new Response("error", { status: 500 });
await assert.rejects(fetchCategories, /Failed to fetch categories/);
});
it("fetchEventQuickFilters returns json on ok response", async () => {
const data = [{ id: 1, name: "Locals this week", filter_config: { displayStatuses: ["upcoming"] } }];
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await fetchEventQuickFilters();
assert.deepStrictEqual(result, data);
});
it("fetchGames returns json on ok response", async () => {
const data = [{ id: 1, slug: "disney-lorcana", name: "Disney Lorcana" }];
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await fetchGames();
assert.deepStrictEqual(result, data);
});
it("geocodeAddress returns parsed data payload", async () => {
const data = {
data: {
address: {
formattedAddress: "Seattle, WA, USA",
lat: 47.6061,
lng: -122.3328,
},
},
error: null,
};
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await geocodeAddress("Seattle, WA");
assert.ok(result);
assert.strictEqual(result?.address.formattedAddress, "Seattle, WA, USA");
assert.strictEqual(result?.address.lat, 47.6061);
});
it("geocodePlaceId returns parsed data payload", async () => {
const data = {
data: {
address: {
formattedAddress: "Detroit, MI, USA",
lat: 42.3314,
lng: -83.0458,
},
placeId: "ChIJdR3LEAhO4okR0dr0K8z3aM0",
},
error: null,
};
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await geocodePlaceId("ChIJdR3LEAhO4okR0dr0K8z3aM0");
assert.ok(result);
assert.strictEqual(result?.placeId, "ChIJdR3LEAhO4okR0dr0K8z3aM0");
assert.strictEqual(result?.address.lat, 42.3314);
});
it("autocompletePlaces returns suggestions payload", async () => {
const data = {
suggestions: [
{
placePrediction: {
placeId: "ChIJdR3LEAhO4okR0dr0K8z3aM0",
structuredFormat: {
mainText: { text: "Detroit" },
secondaryText: { text: "MI, USA" },
},
},
},
],
};
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await autocompletePlaces("detroit", "session-token");
assert.strictEqual(result.suggestions.length, 1);
assert.strictEqual(result.suggestions[0]?.placePrediction?.placeId, "ChIJdR3LEAhO4okR0dr0K8z3aM0");
});
it("searchCardsQuick sends POST body and returns json", async () => {
const data = { count: 1, results: [{ id: "card-1", name: "Elsa" }] };
globalThis.fetch = async (input, init) => {
const u = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
assert.ok(u.includes("deckbuilder/cards/quick-search"));
assert.strictEqual(init?.method, "POST");
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
assert.strictEqual(body.query, "elsa");
assert.strictEqual(body.game_id, 1);
return new Response(JSON.stringify(data), { status: 200 });
};
const result = await searchCardsQuick("elsa", 1);
assert.strictEqual(result.count, 1);
assert.strictEqual(result.results[0].id, "card-1");
});
it("fetchEvents builds url with params and returns json", async () => {
const data = { count: 0, total: 0, results: [], page_size: 25, current_page_number: 1, next_page_number: null, previous_page_number: null };
globalThis.fetch = async (input) => {
const u = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
assert.ok(u.includes("game_slug"));
return new Response(JSON.stringify(data), { status: 200 });
};
const result = await fetchEvents({ game_slug: "disney-lorcana", latitude: "42", longitude: "-83" });
assert.strictEqual(result.count, 0);
assert.deepStrictEqual(result.results, []);
});
it("fetchEvents appends array params as multiple query params", async () => {
const data = { count: 0, total: 0, results: [], page_size: 25, current_page_number: 1, next_page_number: null, previous_page_number: null };
let capturedUrl = "";
globalThis.fetch = async (input) => {
capturedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify(data), { status: 200 });
};
await fetchEvents({ display_statuses: ["upcoming", "inProgress"] });
assert.ok(capturedUrl.includes("display_statuses=upcoming"));
assert.ok(capturedUrl.includes("display_statuses=inProgress"));
});
it("fetchEvents throws with response text on non-ok", async () => {
globalThis.fetch = async (_input) => new Response("API error body", { status: 400 });
await assert.rejects(() => fetchEvents({ game_slug: "x" }), /API request failed.*API error body/);
});
it("fetchEventDetails returns event on ok", async () => {
const event = { id: 1, name: "Test Event", start_datetime: "2025-01-01T12:00:00Z" };
globalThis.fetch = async (_input) => new Response(JSON.stringify(event), { status: 200 });
const result = await fetchEventDetails(1);
assert.strictEqual(result.id, 1);
assert.strictEqual(result.name, "Test Event");
});
it("fetchEventDetails throws on non-ok", async () => {
globalThis.fetch = async (_input) => new Response("Not found", { status: 404 });
await assert.rejects(() => fetchEventDetails(999), /API request failed/);
});
it("fetchEventRegistrations builds url with page and page_size", async () => {
const data = { count: 0, total: 0, results: [], page_size: 10, current_page_number: 2, next_page_number: null, previous_page_number: null };
let capturedUrl = "";
globalThis.fetch = async (input) => {
capturedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify(data), { status: 200 });
};
await fetchEventRegistrations(1, 2, 10);
assert.ok(capturedUrl.includes("page=2"));
assert.ok(capturedUrl.includes("page_size=10"));
});
it("fetchEventRegistrations throws on non-ok", async () => {
globalThis.fetch = async (_input) => new Response("error", { status: 500 });
await assert.rejects(() => fetchEventRegistrations(1), /API request failed/);
});
it("fetchTournamentRoundStandings returns json on ok", async () => {
const data = { count: 0, total: 0, results: [], page_size: 25, current_page_number: 1, next_page_number: null, previous_page_number: null };
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await fetchTournamentRoundStandings(100, 1, 25);
assert.strictEqual(result.count, 0);
});
it("fetchTournamentRoundStandings throws on non-ok", async () => {
globalThis.fetch = async (_input) => new Response("error", { status: 500 });
await assert.rejects(() => fetchTournamentRoundStandings(101), /API request failed/);
});
it("fetchTournamentRoundStandings falls back to non-paginated standings when paginated is empty", async () => {
globalThis.fetch = async (input) => {
const u = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (u.includes("/standings/paginated/")) {
return new Response(JSON.stringify({
count: 0,
total: 0,
results: [],
page_size: 25,
current_page_number: 1,
next_page_number: null,
previous_page_number: null,
}), { status: 200 });
}
if (u.includes("/standings/")) {
return new Response(JSON.stringify({
standings: [
{ rank: 1, player_name: "Alice", wins: 3, losses: 0 },
{ rank: 2, player_name: "Bob", wins: 2, losses: 1 },
],
}), { status: 200 });
}
return new Response("error", { status: 404 });
};
const result = await fetchTournamentRoundStandings(102, 1, 25);
assert.strictEqual(result.total, 2);
assert.strictEqual(result.results.length, 2);
assert.strictEqual(result.results[0].player_name, "Alice");
});
it("fetchTournamentRoundMatches returns json on ok", async () => {
const data = { count: 0, total: 0, results: [], page_size: 25, current_page_number: 1, next_page_number: null, previous_page_number: null };
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await fetchTournamentRoundMatches(100, 1, 25);
assert.strictEqual(result.count, 0);
});
it("fetchTournamentRoundMatches throws on non-ok", async () => {
globalThis.fetch = async (_input) => new Response("error", { status: 500 });
await assert.rejects(() => fetchTournamentRoundMatches(100), /API request failed/);
});
it("fetchStores builds url with game_id and params", async () => {
const data = { count: 0, total: 0, results: [], page_size: 25, current_page_number: 1, next_page_number: null, previous_page_number: null };
let capturedUrl = "";
globalThis.fetch = async (input) => {
capturedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify(data), { status: 200 });
};
await fetchStores({ search: "game" });
assert.ok(capturedUrl.includes("game_id=1"));
assert.ok(capturedUrl.includes("search=game"));
});
it("fetchStores skips empty string params", async () => {
const data = { count: 0, total: 0, results: [], page_size: 25, current_page_number: 1, next_page_number: null, previous_page_number: null };
let capturedUrl = "";
globalThis.fetch = async (input) => {
capturedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify(data), { status: 200 });
};
await fetchStores({ search: "", other: "value" });
assert.ok(!capturedUrl.includes("search="));
assert.ok(capturedUrl.includes("other=value"));
});
it("fetchStores throws on non-ok", async () => {
globalThis.fetch = async (_input) => new Response("error", { status: 500 });
await assert.rejects(() => fetchStores({}), /API request failed/);
});
it("fetchStoreDetails returns json on ok response", async () => {
const data = { id: "store-uuid", store: { id: 1, name: "Game Haven" }, store_types: [], store_types_pretty: [] };
globalThis.fetch = async (_input) => new Response(JSON.stringify(data), { status: 200 });
const result = await fetchStoreDetails("store-uuid");
assert.strictEqual(result.id, "store-uuid");
assert.strictEqual(result.store.name, "Game Haven");
});
});
describe("api – loadFilterOptions", () => {
let originalFetch;
let originalConsoleError;
beforeEach(() => {
originalFetch = globalThis.fetch;
originalConsoleError = console.error;
console.error = () => { }; // suppress expected "Loaded..." / "Failed to load..." messages
});
afterEach(() => {
globalThis.fetch = originalFetch;
console.error = originalConsoleError;
});
it("loads formats and categories and updates maps on success", async () => {
const formats = [{ id: "lf1", name: "Constructed" }];
const categories = [{ id: "lc1", name: "League" }];
globalThis.fetch = async (input) => {
const u = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (u.includes("gameplay-formats"))
return new Response(JSON.stringify(formats), { status: 200 });
if (u.includes("event-configuration-templates"))
return new Response(JSON.stringify(categories), { status: 200 });
return new Response("", { status: 404 });
};
await loadFilterOptions();
assert.deepStrictEqual(resolveFormatIds(["Constructed"]), ["lf1"]);
assert.deepStrictEqual(resolveCategoryIds(["League"]), ["lc1"]);
assert.strictEqual(getCategoryName("lc1"), "League");
});
it("does not throw when fetch fails (logs warning)", async () => {
globalThis.fetch = async (_input) => new Response("error", { status: 500 });
await assert.doesNotReject(loadFilterOptions());
});
});