refactor(lolschedule): swap Leaguepedia for lolesports.com esports-api

Leaguepedia's anonymous IP rate limit is too aggressive for a bot even
from CF Worker egress (~1–2 req/min), and authenticated Fandom tokens
don't lift it. Switching to the lolesports.com getSchedule endpoint —
the same data feed powering the official site — removes the limit and
provides richer fields: state (unstarted/inProgress/completed), per-team
result.gameWins and outcome, league metadata, bestOf strategy.

Handlers simplify back to cache-first (120 s fresh / 1 h stale fallback)
with no cron needed. Results are filtered to major leagues (LCK, LPL,
LEC, LCS, worlds, msi, first_stand, LCP, CBLOL, EMEA Masters) to keep
the week view under Telegram's 4096-char message limit.
This commit is contained in:
2026-04-21 10:07:25 +07:00
committed by Tien Nguyen Minh
parent 436664c8a1
commit e10269ca0a
6 changed files with 438 additions and 398 deletions
+105 -83
View File
@@ -1,119 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import {
fetchMatchesInRange,
getCachedMatches,
fetchEventsInRange,
fetchSchedulePage,
getEventsCached,
} from "../../../src/modules/lolschedule/api-client.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
function cargoResponse(rows) {
const payload = { cargoquery: rows.map((title) => ({ title })) };
function scheduleResponse(events, { newer, older } = {}) {
const payload = {
data: { schedule: { events, pages: { newer, older } } },
};
return {
ok: true,
status: 200,
text: async () => JSON.stringify(payload),
json: async () => payload,
};
}
const sampleRow = {
DateTime: "2026-04-21 09:00:00",
T1: "T1",
T2: "Gen.G",
S1: "",
S2: "",
Winner: "",
Tournament: "LCK 2026 Spring",
BO: "3",
OP: "LCK/2026_Season/Spring_Season",
};
function evt(startTime, state = "unstarted") {
return {
startTime,
state,
type: "match",
blockName: "W1",
league: { name: "LCK", slug: "lck" },
match: {
id: `m-${startTime}`,
teams: [{ code: "T1" }, { code: "GEN" }],
strategy: { type: "bestOf", count: 3 },
},
};
}
describe("fetchMatchesInRange", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("fetchSchedulePage", () => {
afterEach(() => vi.restoreAllMocks());
it("posts cargoquery with correct where clause and parses rows", async () => {
const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow]));
global.fetch = fetchSpy;
it("calls the getSchedule endpoint with the public API key", async () => {
const spy = vi.fn().mockResolvedValue(scheduleResponse([evt("2026-04-21T09:00:00Z")]));
global.fetch = spy;
const from = new Date("2026-04-21T00:00:00Z");
const to = new Date("2026-04-22T00:00:00Z");
const rows = await fetchMatchesInRange(from, to);
expect(rows).toEqual([sampleRow]);
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, opts] = fetchSpy.mock.calls[0];
expect(url).toBe("https://lol.fandom.com/api.php");
expect(opts.method).toBe("POST");
const { events } = await fetchSchedulePage();
expect(events).toHaveLength(1);
const [url, opts] = spy.mock.calls[0];
expect(url).toContain("esports-api.lolesports.com/persisted/gw/getSchedule");
expect(url).toContain("hl=en-US");
expect(opts.headers["x-api-key"]).toBeDefined();
expect(opts.headers["User-Agent"]).toMatch(/miti99bot/);
const body = opts.body.toString();
expect(body).toContain("action=cargoquery");
expect(body).toContain("MatchSchedule%3DMS"); // "MatchSchedule=MS" url-encoded
expect(body).toContain("2026-04-21+00%3A00%3A00");
expect(body).toContain("2026-04-22+00%3A00%3A00");
});
it("surfaces Leaguepedia API error field", async () => {
const errPayload = { error: { info: "Bad query", code: "x" } };
it("drops type=show entries (pre/post shows)", async () => {
const show = { ...evt("2026-04-21T07:00:00Z"), type: "show" };
global.fetch = vi.fn().mockResolvedValue(scheduleResponse([show, evt("2026-04-21T09:00:00Z")]));
const { events } = await fetchSchedulePage();
expect(events.map((e) => e.type)).not.toContain("show");
});
it("surfaces non-2xx responses as errors", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify(errPayload),
json: async () => errPayload,
ok: false,
status: 403,
text: async () => "forbidden",
});
await expect(
fetchMatchesInRange(new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z")),
).rejects.toThrow(/Bad query/);
await expect(fetchSchedulePage()).rejects.toThrow(/HTTP 403/);
});
});
describe("getCachedMatches", () => {
describe("fetchEventsInRange", () => {
afterEach(() => vi.restoreAllMocks());
it("filters events by startTime in [from, to)", async () => {
global.fetch = vi.fn().mockResolvedValue(
scheduleResponse(
[
evt("2026-04-20T09:00:00Z"), // yesterday
evt("2026-04-21T09:00:00Z"), // today
evt("2026-04-22T09:00:00Z"), // tomorrow — outside 1-day window
],
{ newer: null },
),
);
const out = await fetchEventsInRange(
new Date("2026-04-21T00:00:00Z"),
new Date("2026-04-22T00:00:00Z"),
);
expect(out.map((e) => e.startTime)).toEqual(["2026-04-21T09:00:00Z"]);
});
it("pages forward when the last event is before window end and a newer token exists", async () => {
const spy = vi
.fn()
.mockResolvedValueOnce(scheduleResponse([evt("2026-04-21T09:00:00Z")], { newer: "tok1" }))
.mockResolvedValueOnce(scheduleResponse([evt("2026-04-25T09:00:00Z")], { newer: null }));
global.fetch = spy;
const out = await fetchEventsInRange(
new Date("2026-04-21T00:00:00Z"),
new Date("2026-04-28T00:00:00Z"),
);
expect(spy).toHaveBeenCalledTimes(2);
expect(out).toHaveLength(2);
});
});
describe("getEventsCached", () => {
let db;
let kv;
beforeEach(() => {
kv = makeFakeKv();
db = createStore("lolschedule", { KV: kv });
db = createStore("lolschedule", { KV: makeFakeKv() });
});
afterEach(() => vi.restoreAllMocks());
const from = new Date("2026-04-21T00:00:00Z");
const to = new Date("2026-04-22T00:00:00Z");
it("caches a successful fetch then serves from cache", async () => {
const spy = vi
.fn()
.mockResolvedValue(scheduleResponse([evt("2026-04-21T09:00:00Z")], { newer: null }));
global.fetch = spy;
const r1 = await getEventsCached(db, from, to);
const r2 = await getEventsCached(db, from, to);
expect(r1).toHaveLength(1);
expect(r2).toHaveLength(1);
expect(spy).toHaveBeenCalledOnce();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caches a successful fetch and returns cached on the second call", async () => {
const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow]));
global.fetch = fetchSpy;
const from = new Date("2026-04-21T00:00:00Z");
const to = new Date("2026-04-22T00:00:00Z");
const r1 = await getCachedMatches(db, from, to, 60);
const r2 = await getCachedMatches(db, from, to, 60);
expect(r1).toEqual([sampleRow]);
expect(r2).toEqual([sampleRow]);
expect(fetchSpy).toHaveBeenCalledOnce();
});
it("falls back to stale cache when the fetch fails", async () => {
const from = new Date("2026-04-21T00:00:00Z");
const to = new Date("2026-04-22T00:00:00Z");
// Seed the cache with stale data directly.
it("falls back to stale cache if the fetch fails and cache is within STALE_MAX_AGE_SEC", async () => {
await db.putJSON(`matches:${from.toISOString()}:${to.toISOString()}`, {
ts: Date.now() - 10 * 60 * 1000, // 10 min ago older than the 60s TTL
rows: [sampleRow],
ts: Date.now() - 10 * 60 * 1000, // 10 min ago, older than 120s TTL, newer than 1h stale max
events: [evt("2026-04-21T09:00:00Z")],
});
global.fetch = vi.fn().mockRejectedValue(new Error("boom"));
const out = await getCachedMatches(db, from, to, 60);
expect(out).toEqual([sampleRow]);
const out = await getEventsCached(db, from, to);
expect(out).toHaveLength(1);
});
it("propagates the error when no cache is available", async () => {
it("throws when fetch fails and no cache is available", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("boom"));
await expect(
getCachedMatches(db, new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z"), 60),
).rejects.toThrow(/boom/);
await expect(getEventsCached(db, from, to)).rejects.toThrow(/boom/);
});
});