mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-05-12 18:58:17 +00:00
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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,145 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyMatch,
|
||||
formatMatchLine,
|
||||
parseUtc,
|
||||
formatEventLine,
|
||||
formatIctDayLabel,
|
||||
formatIctTime,
|
||||
renderToday,
|
||||
renderWeek,
|
||||
} from "../../../src/modules/lolschedule/format.js";
|
||||
|
||||
const scheduled = {
|
||||
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",
|
||||
};
|
||||
/** @typedef {import("../../../src/modules/lolschedule/api-client.js").ScheduleEvent} ScheduleEvent */
|
||||
|
||||
const live = {
|
||||
...scheduled,
|
||||
DateTime: "2026-04-21 08:00:00",
|
||||
S1: "1",
|
||||
S2: "0",
|
||||
};
|
||||
const completed = /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-20T08:00:00Z",
|
||||
state: "completed",
|
||||
blockName: "Week 3",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "1",
|
||||
teams: [
|
||||
{ name: "T1", code: "T1", result: { outcome: "win", gameWins: 2 } },
|
||||
{ name: "Gen.G", code: "GEN", result: { outcome: "loss", gameWins: 1 } },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
const played = {
|
||||
...scheduled,
|
||||
DateTime: "2026-04-20 10:00:00",
|
||||
S1: "2",
|
||||
S2: "1",
|
||||
Winner: "1",
|
||||
};
|
||||
const live = /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-21T08:00:00Z",
|
||||
state: "inProgress",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "2",
|
||||
teams: [
|
||||
{ name: "Hanwha Life", code: "HLE", result: { gameWins: 1 } },
|
||||
{ name: "DRX", code: "DRX", result: { gameWins: 0 } },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
// Fixed reference — 2026-04-21 08:30:00 UTC (i.e. 15:30 ICT)
|
||||
const nowMs = Date.parse("2026-04-21T08:30:00Z");
|
||||
const scheduled = /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-21T09:00:00Z", // 16:00 ICT
|
||||
state: "unstarted",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "3",
|
||||
teams: [
|
||||
{ name: "KT Rolster", code: "KT" },
|
||||
{ name: "Dplus KIA", code: "DK" },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
describe("parseUtc", () => {
|
||||
it("parses Leaguepedia UTC literal", () => {
|
||||
expect(parseUtc("2026-04-21 09:00:00").toISOString()).toBe("2026-04-21T09:00:00.000Z");
|
||||
describe("formatIctTime / formatIctDayLabel", () => {
|
||||
it("formats UTC datetime in ICT", () => {
|
||||
expect(formatIctTime(new Date("2026-04-21T09:00:00Z"))).toBe("16:00");
|
||||
expect(formatIctDayLabel(new Date("2026-04-21T00:00:00Z"))).toBe("Tue Apr 21");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyMatch", () => {
|
||||
it("returns played when Winner is 1 or 2", () => {
|
||||
expect(classifyMatch(played, nowMs)).toBe("played");
|
||||
expect(classifyMatch({ ...played, Winner: "2" }, nowMs)).toBe("played");
|
||||
});
|
||||
|
||||
it("returns live when started, has partial score, no winner", () => {
|
||||
expect(classifyMatch(live, nowMs)).toBe("live");
|
||||
});
|
||||
|
||||
it("returns scheduled when start time is in the future", () => {
|
||||
expect(classifyMatch(scheduled, nowMs)).toBe("scheduled");
|
||||
});
|
||||
|
||||
it("returns scheduled when start is past but no score", () => {
|
||||
const row = { ...scheduled, DateTime: "2026-04-21 07:00:00" };
|
||||
expect(classifyMatch(row, nowMs)).toBe("scheduled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMatchLine", () => {
|
||||
it("renders scheduled with ICT time and vs", () => {
|
||||
const line = formatMatchLine(scheduled, nowMs);
|
||||
// 09:00 UTC → 16:00 ICT
|
||||
expect(line).toContain("16:00");
|
||||
expect(line).toContain("vs");
|
||||
expect(line).toContain("Bo3");
|
||||
expect(line).toContain("LCK 2026 Spring");
|
||||
expect(line.startsWith("🕒")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders played with score and winner bolded", () => {
|
||||
const line = formatMatchLine(played, nowMs);
|
||||
expect(line).toContain("2–1");
|
||||
expect(line).toContain("<b>T1</b>");
|
||||
describe("formatEventLine", () => {
|
||||
it("renders completed with bolded winner + score", () => {
|
||||
const line = formatEventLine(completed);
|
||||
expect(line.startsWith("✅")).toBe(true);
|
||||
expect(line).toContain("<b>T1</b>");
|
||||
expect(line).toContain("2–1");
|
||||
expect(line).toContain("GEN");
|
||||
expect(line).toContain("Bo3");
|
||||
expect(line).toContain("LCK");
|
||||
expect(line).toContain("Week 3");
|
||||
});
|
||||
|
||||
it("renders live prefix", () => {
|
||||
const line = formatMatchLine(live, nowMs);
|
||||
it("renders live with LIVE prefix + current score", () => {
|
||||
const line = formatEventLine(live);
|
||||
expect(line.startsWith("🔴 LIVE")).toBe(true);
|
||||
expect(line).toContain("1–0");
|
||||
});
|
||||
|
||||
it("escapes HTML in team and tournament names", () => {
|
||||
const row = { ...scheduled, T1: "<script>", Tournament: "A&B" };
|
||||
const line = formatMatchLine(row, nowMs);
|
||||
it("renders unstarted with ICT time + vs", () => {
|
||||
const line = formatEventLine(scheduled);
|
||||
expect(line.startsWith("🕒 16:00")).toBe(true);
|
||||
expect(line).toContain("KT vs DK");
|
||||
});
|
||||
|
||||
it("escapes HTML in league, block, team names", () => {
|
||||
const event = /** @type {ScheduleEvent} */ ({
|
||||
...scheduled,
|
||||
league: { name: "A&B", slug: "ab" },
|
||||
blockName: "<script>",
|
||||
match: {
|
||||
...scheduled.match,
|
||||
teams: [{ code: "<bad>" }, { code: "GEN" }],
|
||||
},
|
||||
});
|
||||
const line = formatEventLine(event);
|
||||
expect(line).not.toContain("<script>");
|
||||
expect(line).toContain("<script>");
|
||||
expect(line).toContain("<bad>");
|
||||
expect(line).toContain("A&B");
|
||||
});
|
||||
|
||||
it("shows TBD when team field is empty", () => {
|
||||
const row = { ...scheduled, T1: "" };
|
||||
expect(formatMatchLine(row, nowMs)).toContain("TBD");
|
||||
it("shows TBD when team is missing", () => {
|
||||
const event = /** @type {ScheduleEvent} */ ({
|
||||
...scheduled,
|
||||
match: { ...scheduled.match, teams: [null, { code: "GEN" }] },
|
||||
});
|
||||
expect(formatEventLine(event)).toContain("TBD vs GEN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderToday", () => {
|
||||
it("returns empty-state message when no rows", () => {
|
||||
const out = renderToday([], new Date("2026-04-21T00:00:00Z"), nowMs);
|
||||
expect(out).toContain("No matches today.");
|
||||
it("empty state when no events", () => {
|
||||
expect(renderToday([], new Date("2026-04-21T00:00:00Z"))).toContain("No matches today.");
|
||||
});
|
||||
|
||||
it("renders header + one line per match", () => {
|
||||
const out = renderToday([scheduled, played], new Date("2026-04-21T00:00:00Z"), nowMs);
|
||||
expect(out).toContain("<b>LoL —");
|
||||
expect(out.split("\n").length).toBeGreaterThanOrEqual(3);
|
||||
it("renders header + one line per event", () => {
|
||||
const out = renderToday([scheduled, live], new Date("2026-04-21T00:00:00Z"));
|
||||
expect(out).toContain("<b>LoL — Tue Apr 21</b>");
|
||||
expect(out.split("\n")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderWeek", () => {
|
||||
it("groups rows by ICT day", () => {
|
||||
const rows = [
|
||||
{ ...scheduled, DateTime: "2026-04-21 09:00:00" }, // Apr 21 ICT
|
||||
{ ...scheduled, DateTime: "2026-04-22 10:00:00" }, // Apr 22 ICT
|
||||
{ ...scheduled, DateTime: "2026-04-22 12:00:00" }, // Apr 22 ICT
|
||||
it("groups events by ICT day in order", () => {
|
||||
const events = [
|
||||
{ ...scheduled, startTime: "2026-04-21T09:00:00Z" },
|
||||
{ ...scheduled, startTime: "2026-04-22T10:00:00Z" },
|
||||
{ ...scheduled, startTime: "2026-04-22T12:00:00Z" },
|
||||
];
|
||||
const out = renderWeek(
|
||||
rows,
|
||||
events,
|
||||
new Date("2026-04-21T00:00:00Z"),
|
||||
new Date("2026-04-28T00:00:00Z"),
|
||||
nowMs,
|
||||
);
|
||||
// Day labels appear once each, in order
|
||||
expect(out.indexOf("Apr 21")).toBeLessThan(out.indexOf("Apr 22"));
|
||||
const apr22Matches = out.split("Apr 22")[1] || "";
|
||||
expect((apr22Matches.match(/🕒/g) || []).length).toBe(2);
|
||||
const apr22Section = out.split("Apr 22")[1] || "";
|
||||
expect((apr22Section.match(/🕒/g) || []).length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty-state when no matches", () => {
|
||||
const out = renderWeek(
|
||||
[],
|
||||
new Date("2026-04-21T00:00:00Z"),
|
||||
new Date("2026-04-28T00:00:00Z"),
|
||||
nowMs,
|
||||
);
|
||||
expect(out).toContain("No matches this week.");
|
||||
it("empty state when no events", () => {
|
||||
expect(
|
||||
renderWeek([], new Date("2026-04-21T00:00:00Z"), new Date("2026-04-28T00:00:00Z")),
|
||||
).toContain("No matches this week.");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user