mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-10-03 07:13:24 +00:00
feat(lolschedule): add LoL esports match schedule module
New module exposing /lol_today and /lol_week commands, backed by the Leaguepedia Cargo API (MatchSchedule table). Renders scores for played/live matches and ICT times for scheduled ones. Caches range queries in KV (60s today, 300s week) with stale-fallback on fetch error.
This commit is contained in:
1 parent
b180ea6660
commit
a7797f16b2
10 files changed
+701
-2
No files matched your search
+1
-1
@@ -12,4 +12,4 @@ WORKER_URL=
|
||||
|
||||
# Same MODULES value as wrangler.toml [vars]. Duplicated here so the register
|
||||
# script can derive the public command list without parsing wrangler.toml.
|
||||
MODULES=util,wordle,loldle,misc
|
||||
MODULES=util,wordle,loldle,misc,trading,lolschedule
|
||||
@@ -14,4 +14,5 @@ export const moduleRegistry = {
|
||||
loldle: () => import("./loldle/index.js"),
|
||||
misc: () => import("./misc/index.js"),
|
||||
trading: () => import("./trading/index.js"),
|
||||
lolschedule: () => import("./lolschedule/index.js"),
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
# lolschedule
|
||||
|
||||
LoL esports match schedule via the **Leaguepedia** MediaWiki/Cargo API.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/lol_today` | Today's matches (ICT). Scores if played / live. Times if scheduled. |
|
||||
| `/lol_week` | Next 7 days, grouped by day. |
|
||||
|
||||
## Data source
|
||||
|
||||
- Endpoint: `https://lol.fandom.com/api.php?action=cargoquery`
|
||||
- Table: `MatchSchedule`
|
||||
- Fields used: `DateTime_UTC`, `Team1`, `Team2`, `Team1Score`, `Team2Score`, `Winner`, `Tournament`, `BestOf`, `OverviewPage`
|
||||
- Auth: none. UA header identifies the bot.
|
||||
|
||||
See `plans/reports/researcher-260421-0845-leaguepedia-api-verification.md` and `plans/reports/researcher-260421-0909-leaguepedia-auth-token.md` for the feasibility verdict + rate-limit strategy.
|
||||
|
||||
## Caching
|
||||
|
||||
Two layers:
|
||||
|
||||
1. **Worker edge cache** — `fetch(url, { cf: { cacheTtl: 30, cacheEverything: true }})` dedupes near-simultaneous calls across requests to the same edge POP.
|
||||
2. **KV cache** (per module) — `matches:{fromIso}:{toIso}` with `ts`+`rows` payload. TTL: 60 s for `/lol_today`, 300 s for `/lol_week`. On fetch failure, stale cache (up to 4× TTL) is returned as a fallback.
|
||||
|
||||
## Time zone
|
||||
|
||||
All rendering is in **ICT (UTC+7)**. `DateTime_UTC` strings from the wiki are parsed as UTC, then shifted for display. Change `TZ_OFFSET_MS` in `format.js` if you need a different zone.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.js` — module contract, registers `/lol_today` and `/lol_week`.
|
||||
- `api-client.js` — cargoquery POST, range fetch, cache-first wrapper.
|
||||
- `format.js` — pure renderers (`classifyMatch`, `formatMatchLine`, `renderToday`, `renderWeek`).
|
||||
- `handlers.js` — grammY command handlers + ICT day-boundary helpers.
|
||||
|
||||
## Known caveat
|
||||
|
||||
The `where=` clause with `>=`/`<` operators hits `MWException` from shared egress IPs during verification (see auth-token report). POST + a proper UA appears to work from CF Worker egress, but confirm on first deploy. If it fails in production, fall back to `HOLDS`-based filters or a client-side filter over a broader fetch.
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @file Leaguepedia cargoquery client for LoL esports match schedule.
|
||||
*
|
||||
* Uses the MediaWiki Cargo extension on lol.fandom.com. No auth. We identify
|
||||
* ourselves with a contact UA (Fandom policy) and cache via both the Worker
|
||||
* edge cache (`cf.cacheTtl`) and the module's KVStore for cross-edge reuse.
|
||||
*
|
||||
* @see plans/reports/researcher-260421-0845-leaguepedia-api-verification.md
|
||||
*/
|
||||
|
||||
const API_URL = "https://lol.fandom.com/api.php";
|
||||
const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot; minhtienit99@gmail.com)";
|
||||
|
||||
/** Default KV cache windows — short enough to catch score updates mid-day. */
|
||||
const CACHE_TTL_TODAY_SEC = 60;
|
||||
const CACHE_TTL_WEEK_SEC = 300;
|
||||
|
||||
/**
|
||||
* @typedef {object} MatchRow
|
||||
* @property {string} DateTime — "YYYY-MM-DD HH:MM:SS" UTC (Leaguepedia convention).
|
||||
* @property {string} T1
|
||||
* @property {string} T2
|
||||
* @property {string|null} S1 — team-1 score, may be empty string before match.
|
||||
* @property {string|null} S2
|
||||
* @property {string|null} Winner — "1" | "2" | "" when unplayed.
|
||||
* @property {string} Tournament
|
||||
* @property {string|null} BO — best-of count as string.
|
||||
* @property {string} OP — OverviewPage (wiki slug) for deep-link.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Low-level cargoquery POST. Uses POST to avoid edge WAF stripping of `>=`/`<`
|
||||
* in the query string and to stay under URL-length limits for long where clauses.
|
||||
*
|
||||
* @param {Record<string, string>} params
|
||||
* @returns {Promise<object[]>} array of row `title` objects
|
||||
*/
|
||||
async function cargoQuery(params) {
|
||||
const body = new URLSearchParams({
|
||||
action: "cargoquery",
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
const res = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body,
|
||||
cf: { cacheTtl: 30, cacheEverything: true },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Leaguepedia API HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json?.error) throw new Error(`Leaguepedia error: ${json.error.info || json.error.code}`);
|
||||
return (json?.cargoquery || []).map((r) => r.title);
|
||||
}
|
||||
|
||||
/** Format a JS Date as Leaguepedia's UTC literal: `YYYY-MM-DD HH:MM:SS`. */
|
||||
function toUtcLiteral(date) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` +
|
||||
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch matches with DateTime_UTC in [from, to).
|
||||
*
|
||||
* @param {Date} from
|
||||
* @param {Date} to
|
||||
* @returns {Promise<MatchRow[]>}
|
||||
*/
|
||||
export async function fetchMatchesInRange(from, to) {
|
||||
const fromLit = toUtcLiteral(from);
|
||||
const toLit = toUtcLiteral(to);
|
||||
const rows = await cargoQuery({
|
||||
tables: "MatchSchedule=MS",
|
||||
fields:
|
||||
"MS.DateTime_UTC=DateTime," +
|
||||
"MS.Team1=T1,MS.Team2=T2," +
|
||||
"MS.Team1Score=S1,MS.Team2Score=S2," +
|
||||
"MS.Winner=Winner,MS.Tournament=Tournament," +
|
||||
"MS.BestOf=BO,MS.OverviewPage=OP",
|
||||
where: `MS.DateTime_UTC >= "${fromLit}" AND MS.DateTime_UTC < "${toLit}"`,
|
||||
order_by: "MS.DateTime_UTC ASC",
|
||||
limit: "100",
|
||||
});
|
||||
return /** @type {MatchRow[]} */ (rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-first match lookup keyed by date range. Returns cached rows without
|
||||
* refetch within TTL; on fetch failure, returns stale cache if available.
|
||||
*
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {Date} from
|
||||
* @param {Date} to
|
||||
* @param {number} ttlSec
|
||||
* @returns {Promise<MatchRow[]>}
|
||||
*/
|
||||
export async function getCachedMatches(db, from, to, ttlSec) {
|
||||
const key = `matches:${from.toISOString()}:${to.toISOString()}`;
|
||||
const cached = await db.getJSON(key);
|
||||
if (cached?.ts && Date.now() - cached.ts < ttlSec * 1000) {
|
||||
return cached.rows;
|
||||
}
|
||||
try {
|
||||
const rows = await fetchMatchesInRange(from, to);
|
||||
try {
|
||||
await db.putJSON(key, { ts: Date.now(), rows }, { expirationTtl: ttlSec * 4 });
|
||||
} catch (err) {
|
||||
console.warn("lolschedule: KV putJSON failed", String(err));
|
||||
}
|
||||
return rows;
|
||||
} catch (err) {
|
||||
if (cached?.rows) return cached.rows; // stale fallback
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC };
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file Pure formatters — Telegram HTML output for today / week match lists.
|
||||
*
|
||||
* All user-influenced substrings are escaped; match rows come from the public
|
||||
* wiki so we treat them as untrusted. Times are rendered in ICT (UTC+7) since
|
||||
* the bot's primary audience is VN; feel free to branch by chat locale later.
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
|
||||
/** @typedef {import("./api-client.js").MatchRow} MatchRow */
|
||||
|
||||
const TZ_OFFSET_MS = 7 * 60 * 60 * 1000; // ICT = UTC+7
|
||||
|
||||
/** Parse Leaguepedia's `YYYY-MM-DD HH:MM:SS` UTC literal into a Date. */
|
||||
export function parseUtc(literal) {
|
||||
return new Date(`${literal.replace(" ", "T")}Z`);
|
||||
}
|
||||
|
||||
/** Shift a UTC date by the ICT offset so getUTC* methods yield ICT components. */
|
||||
function toIct(date) {
|
||||
return new Date(date.getTime() + TZ_OFFSET_MS);
|
||||
}
|
||||
|
||||
/** Format ICT time as `HH:MM`. */
|
||||
function formatIctTime(date) {
|
||||
const d = toIct(date);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/** Format ICT date as `Mon Apr 21` (weekday + month + day). */
|
||||
function formatIctDayLabel(date) {
|
||||
const d = toIct(date);
|
||||
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
return `${weekdays[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${d.getUTCDate()}`;
|
||||
}
|
||||
|
||||
/** ICT calendar-day key `YYYY-MM-DD` (used for grouping). */
|
||||
function ictDayKey(date) {
|
||||
const d = toIct(date);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
/** Coerce Leaguepedia's string score into a number, or null. */
|
||||
function parseScore(s) {
|
||||
if (s == null || s === "") return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a match row's display state.
|
||||
*
|
||||
* @param {MatchRow} row
|
||||
* @param {number} nowMs — injected for testability.
|
||||
* @returns {"played"|"live"|"scheduled"}
|
||||
*/
|
||||
export function classifyMatch(row, nowMs = Date.now()) {
|
||||
const startMs = parseUtc(row.DateTime).getTime();
|
||||
const winner = String(row.Winner ?? "");
|
||||
if (winner === "1" || winner === "2") return "played";
|
||||
const s1 = parseScore(row.S1);
|
||||
const s2 = parseScore(row.S2);
|
||||
const hasScore = (s1 ?? 0) + (s2 ?? 0) > 0;
|
||||
if (startMs <= nowMs && hasScore) return "live";
|
||||
return "scheduled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one match line (no leading newline).
|
||||
*
|
||||
* @param {MatchRow} row
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string} HTML — already escaped
|
||||
*/
|
||||
export function formatMatchLine(row, nowMs = Date.now()) {
|
||||
const t1 = escapeHtml(row.T1 || "TBD");
|
||||
const t2 = escapeHtml(row.T2 || "TBD");
|
||||
const tournament = escapeHtml(row.Tournament || "");
|
||||
const bo = row.BO ? ` · Bo${escapeHtml(row.BO)}` : "";
|
||||
const state = classifyMatch(row, nowMs);
|
||||
|
||||
if (state === "played") {
|
||||
const s1 = parseScore(row.S1) ?? 0;
|
||||
const s2 = parseScore(row.S2) ?? 0;
|
||||
const w1 = String(row.Winner) === "1" ? "<b>" : "";
|
||||
const w1c = w1 ? "</b>" : "";
|
||||
const w2 = String(row.Winner) === "2" ? "<b>" : "";
|
||||
const w2c = w2 ? "</b>" : "";
|
||||
return `✅ ${w1}${t1}${w1c} ${s1}–${s2} ${w2}${t2}${w2c}${bo} · ${tournament}`;
|
||||
}
|
||||
if (state === "live") {
|
||||
const s1 = parseScore(row.S1) ?? 0;
|
||||
const s2 = parseScore(row.S2) ?? 0;
|
||||
return `🔴 LIVE ${t1} ${s1}–${s2} ${t2}${bo} · ${tournament}`;
|
||||
}
|
||||
const time = formatIctTime(parseUtc(row.DateTime));
|
||||
return `🕒 ${time} ${t1} vs ${t2}${bo} · ${tournament}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "today" command reply.
|
||||
*
|
||||
* @param {MatchRow[]} rows
|
||||
* @param {Date} day — any moment on the target ICT day.
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderToday(rows, day, nowMs = Date.now()) {
|
||||
const header = `<b>LoL — ${escapeHtml(formatIctDayLabel(day))}</b> (ICT)`;
|
||||
if (rows.length === 0) return `${header}\nNo matches today.`;
|
||||
return `${header}\n${rows.map((r) => formatMatchLine(r, nowMs)).join("\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "week" command reply — matches grouped by ICT day.
|
||||
*
|
||||
* @param {MatchRow[]} rows
|
||||
* @param {Date} from
|
||||
* @param {Date} to
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderWeek(rows, from, to, nowMs = Date.now()) {
|
||||
const fromLbl = escapeHtml(formatIctDayLabel(from));
|
||||
// `to` is exclusive; subtract 1 ms for a friendlier "through" label.
|
||||
const toLbl = escapeHtml(formatIctDayLabel(new Date(to.getTime() - 1)));
|
||||
const header = `<b>LoL — ${fromLbl} → ${toLbl}</b> (ICT)`;
|
||||
if (rows.length === 0) return `${header}\nNo matches this week.`;
|
||||
|
||||
/** @type {Map<string, { label: string, lines: string[] }>} */
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const d = parseUtc(row.DateTime);
|
||||
const key = ictDayKey(d);
|
||||
let g = groups.get(key);
|
||||
if (!g) {
|
||||
g = { label: formatIctDayLabel(d), lines: [] };
|
||||
groups.set(key, g);
|
||||
}
|
||||
g.lines.push(formatMatchLine(row, nowMs));
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
const sortedKeys = [...groups.keys()].sort();
|
||||
for (const key of sortedKeys) {
|
||||
const g = groups.get(key);
|
||||
sections.push(`<b>${escapeHtml(g.label)}</b>\n${g.lines.join("\n")}`);
|
||||
}
|
||||
return `${header}\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file /lol_today and /lol_week command handlers.
|
||||
*
|
||||
* Day boundaries are defined in ICT (UTC+7), converted to the UTC literals
|
||||
* that Leaguepedia's cargo query expects. Errors are surfaced as a short reply
|
||||
* — we never throw through grammY.
|
||||
*/
|
||||
|
||||
import { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC, getCachedMatches } from "./api-client.js";
|
||||
import { renderToday, renderWeek } from "./format.js";
|
||||
|
||||
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Start of the current ICT calendar day, expressed as a UTC `Date`.
|
||||
* @param {number} [nowMs]
|
||||
*/
|
||||
export function ictDayStart(nowMs = Date.now()) {
|
||||
const shifted = new Date(nowMs + ICT_OFFSET_MS);
|
||||
shifted.setUTCHours(0, 0, 0, 0);
|
||||
return new Date(shifted.getTime() - ICT_OFFSET_MS);
|
||||
}
|
||||
|
||||
/** Add `days` days to a Date, preserving time-of-day. */
|
||||
export function addDays(date, days) {
|
||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("grammy").Context} ctx
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
|
||||
*/
|
||||
export async function handleToday(ctx, db) {
|
||||
if (!db) {
|
||||
await ctx.reply("lolschedule: storage unavailable");
|
||||
return;
|
||||
}
|
||||
const from = ictDayStart();
|
||||
const to = addDays(from, 1);
|
||||
try {
|
||||
const rows = await getCachedMatches(db, from, to, CACHE_TTL_TODAY_SEC);
|
||||
await ctx.reply(renderToday(rows, from), { parse_mode: "HTML" });
|
||||
} catch (err) {
|
||||
console.warn("lolschedule /lol_today failed", String(err));
|
||||
await ctx.reply("Could not fetch today's matches. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("grammy").Context} ctx
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
|
||||
*/
|
||||
export async function handleWeek(ctx, db) {
|
||||
if (!db) {
|
||||
await ctx.reply("lolschedule: storage unavailable");
|
||||
return;
|
||||
}
|
||||
const from = ictDayStart();
|
||||
const to = addDays(from, 7);
|
||||
try {
|
||||
const rows = await getCachedMatches(db, from, to, CACHE_TTL_WEEK_SEC);
|
||||
await ctx.reply(renderWeek(rows, from, to), { parse_mode: "HTML" });
|
||||
} catch (err) {
|
||||
console.warn("lolschedule /lol_week failed", String(err));
|
||||
await ctx.reply("Could not fetch this week's matches. Try again later.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @file lolschedule module — LoL esports match schedule via Leaguepedia API.
|
||||
*
|
||||
* Commands:
|
||||
* /lol_today — matches scheduled for the current ICT day, with live/played scores.
|
||||
* /lol_week — next 7 ICT days, grouped per day.
|
||||
*
|
||||
* Data source: Leaguepedia Cargo `MatchSchedule` table on lol.fandom.com.
|
||||
* See plans/reports/researcher-260421-0845-leaguepedia-api-verification.md.
|
||||
*/
|
||||
|
||||
import { handleToday, handleWeek } from "./handlers.js";
|
||||
|
||||
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
|
||||
let db = null;
|
||||
|
||||
/** @type {import("../registry.js").BotModule} */
|
||||
const lolscheduleModule = {
|
||||
name: "lolschedule",
|
||||
init: async ({ db: store }) => {
|
||||
db = store;
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
name: "lol_today",
|
||||
visibility: "public",
|
||||
description: "Today's LoL esports matches (scores if played)",
|
||||
handler: (ctx) => handleToday(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "lol_week",
|
||||
visibility: "public",
|
||||
description: "LoL esports matches for the next 7 days",
|
||||
handler: (ctx) => handleWeek(ctx, db),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default lolscheduleModule;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
fetchMatchesInRange,
|
||||
getCachedMatches,
|
||||
} from "../../../src/modules/lolschedule/api-client.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
function cargoResponse(rows) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ cargoquery: rows.map((title) => ({ title })) }),
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
describe("fetchMatchesInRange", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("posts cargoquery with correct where clause and parses rows", 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 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");
|
||||
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 () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ error: { info: "Bad query", code: "x" } }),
|
||||
});
|
||||
await expect(
|
||||
fetchMatchesInRange(new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z")),
|
||||
).rejects.toThrow(/Bad query/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedMatches", () => {
|
||||
let db;
|
||||
let kv;
|
||||
|
||||
beforeEach(() => {
|
||||
kv = makeFakeKv();
|
||||
db = createStore("lolschedule", { KV: kv });
|
||||
});
|
||||
|
||||
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.
|
||||
await db.putJSON(`matches:${from.toISOString()}:${to.toISOString()}`, {
|
||||
ts: Date.now() - 10 * 60 * 1000, // 10 min ago — older than the 60s TTL
|
||||
rows: [sampleRow],
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const out = await getCachedMatches(db, from, to, 60);
|
||||
expect(out).toEqual([sampleRow]);
|
||||
});
|
||||
|
||||
it("propagates the error when 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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyMatch,
|
||||
formatMatchLine,
|
||||
parseUtc,
|
||||
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",
|
||||
};
|
||||
|
||||
const live = {
|
||||
...scheduled,
|
||||
DateTime: "2026-04-21 08:00:00",
|
||||
S1: "1",
|
||||
S2: "0",
|
||||
};
|
||||
|
||||
const played = {
|
||||
...scheduled,
|
||||
DateTime: "2026-04-20 10:00:00",
|
||||
S1: "2",
|
||||
S2: "1",
|
||||
Winner: "1",
|
||||
};
|
||||
|
||||
// Fixed reference — 2026-04-21 08:30:00 UTC (i.e. 15:30 ICT)
|
||||
const nowMs = Date.parse("2026-04-21T08:30:00Z");
|
||||
|
||||
describe("parseUtc", () => {
|
||||
it("parses Leaguepedia UTC literal", () => {
|
||||
expect(parseUtc("2026-04-21 09:00:00").toISOString()).toBe("2026-04-21T09:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
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>");
|
||||
expect(line.startsWith("✅")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders live prefix", () => {
|
||||
const line = formatMatchLine(live, nowMs);
|
||||
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);
|
||||
expect(line).not.toContain("<script>");
|
||||
expect(line).toContain("<script>");
|
||||
expect(line).toContain("A&B");
|
||||
});
|
||||
|
||||
it("shows TBD when team field is empty", () => {
|
||||
const row = { ...scheduled, T1: "" };
|
||||
expect(formatMatchLine(row, nowMs)).toContain("TBD");
|
||||
});
|
||||
});
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
];
|
||||
const out = renderWeek(
|
||||
rows,
|
||||
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);
|
||||
});
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -5,7 +5,7 @@ compatibility_date = "2025-10-01"
|
||||
# Enabled modules at runtime. Comma-separated. Must match static-map keys in src/modules/index.js.
|
||||
# Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
|
||||
[vars]
|
||||
MODULES = "util,wordle,loldle,misc,trading"
|
||||
MODULES = "util,wordle,loldle,misc,trading,lolschedule"
|
||||
|
||||
# KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
|
||||
# Create with:
|
||||
|
||||
Reference in new issue
Block a user