feat(lolschedule): per-chat subscribe/unsubscribe for the daily cron

Replaces the single LOLSCHEDULE_CHAT_ID env var with a KV-backed
subscriber list. New commands /lolschedule_subscribe and
/lolschedule_unsubscribe let each chat opt in/out. The cron now fans
out to every subscriber via Promise.allSettled so one blocked chat
cannot break the others.
This commit is contained in:
2026-04-21 10:23:17 +07:00
committed by Tien Nguyen Minh
parent c69d1749b7
commit 3e4e0e5b6e
7 changed files with 270 additions and 54 deletions
+95 -26
View File
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import { handleDailyPushCron } from "../../../src/modules/lolschedule/handlers.js";
import {
handleDailyPushCron,
handleSubscribe,
handleUnsubscribe,
} from "../../../src/modules/lolschedule/handlers.js";
import { listSubscribers } from "../../../src/modules/lolschedule/subscribers.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
function scheduleResponse(events) {
@@ -26,6 +31,56 @@ function majorEvt(startTime, leagueSlug = "lck", leagueName = "LCK") {
};
}
function fakeCtx(chatId) {
const replied = [];
return {
chat: chatId != null ? { id: chatId } : undefined,
reply: async (text, opts) => replied.push({ text, opts }),
replied,
};
}
describe("handleSubscribe / handleUnsubscribe", () => {
let db;
beforeEach(() => {
db = createStore("lolschedule", { KV: makeFakeKv() });
});
it("subscribes a new chat and persists the id", async () => {
const ctx = fakeCtx(10);
await handleSubscribe(ctx, db);
expect(ctx.replied[0].text).toMatch(/Subscribed/);
expect(await listSubscribers(db)).toEqual([10]);
});
it("tells an already-subscribed chat they're in", async () => {
const ctx = fakeCtx(10);
await handleSubscribe(ctx, db);
await handleSubscribe(ctx, db);
expect(ctx.replied[1].text).toMatch(/Already subscribed/);
});
it("unsubscribes an existing chat", async () => {
const ctx = fakeCtx(10);
await handleSubscribe(ctx, db);
await handleUnsubscribe(ctx, db);
expect(ctx.replied[1].text).toMatch(/Unsubscribed/);
expect(await listSubscribers(db)).toEqual([]);
});
it("tells a non-subscriber they weren't subscribed", async () => {
const ctx = fakeCtx(10);
await handleUnsubscribe(ctx, db);
expect(ctx.replied[0].text).toMatch(/weren't subscribed/);
});
it("warns when chat id is missing", async () => {
const ctx = fakeCtx(null);
await handleSubscribe(ctx, db);
expect(ctx.replied[0].text).toMatch(/Could not read chat id/);
});
});
describe("handleDailyPushCron", () => {
let db;
let telegramSpy;
@@ -38,50 +93,64 @@ describe("handleDailyPushCron", () => {
afterEach(() => vi.restoreAllMocks());
function mockFetch(scheduleEvents) {
global.fetch = vi.fn(async (url) => {
global.fetch = vi.fn(async (url, opts) => {
if (String(url).includes("esports-api.lolesports.com")) {
return scheduleResponse(scheduleEvents);
}
return telegramSpy(url);
return telegramSpy(url, opts);
});
}
it("skips cleanly when LOLSCHEDULE_CHAT_ID is missing", async () => {
it("skips cleanly when the bot token is missing", async () => {
mockFetch([majorEvt("2026-04-21T09:00:00Z")]);
await db.putJSON("subscribers", [123]);
await handleDailyPushCron({}, { db, env: {} });
expect(telegramSpy).not.toHaveBeenCalled();
});
it("skips cleanly when there are no subscribers", async () => {
mockFetch([majorEvt("2026-04-21T09:00:00Z")]);
await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "t" } });
expect(telegramSpy).not.toHaveBeenCalled();
});
it("skips cleanly when TELEGRAM_BOT_TOKEN is missing", async () => {
mockFetch([majorEvt("2026-04-21T09:00:00Z")]);
await handleDailyPushCron({}, { db, env: { LOLSCHEDULE_CHAT_ID: "123" } });
it("does not fan out when no major-league matches today", async () => {
mockFetch([majorEvt("2026-04-21T09:00:00Z", "foo-cup", "Foo Cup")]);
await db.putJSON("subscribers", [1, 2]);
await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "t" } });
expect(telegramSpy).not.toHaveBeenCalled();
});
it("does not send when no major-league matches today", async () => {
mockFetch([majorEvt("2026-04-21T09:00:00Z", "lck-challengers", "LCK CL")]);
await handleDailyPushCron(
{},
{ db, env: { LOLSCHEDULE_CHAT_ID: "123", TELEGRAM_BOT_TOKEN: "t" } },
);
expect(telegramSpy).not.toHaveBeenCalled();
});
it("sends the rendered today message when major-league matches exist", async () => {
// Use a time within the current ICT day window so fetchEventsInRange keeps it.
it("sends once per subscriber when matches exist today", async () => {
const ictOffsetMs = 7 * 60 * 60 * 1000;
const now = Date.now();
const ictDayStart = new Date(new Date(now + ictOffsetMs).setUTCHours(0, 0, 0, 0) - ictOffsetMs);
const pickTime = new Date(ictDayStart.getTime() + 12 * 60 * 60 * 1000).toISOString();
mockFetch([majorEvt(pickTime)]);
await handleDailyPushCron(
{},
{ db, env: { LOLSCHEDULE_CHAT_ID: "42", TELEGRAM_BOT_TOKEN: "tok" } },
);
await db.putJSON("subscribers", [100, 200, 300]);
expect(telegramSpy).toHaveBeenCalledOnce();
const [url] = telegramSpy.mock.calls[0];
expect(String(url)).toContain("/bottok/sendMessage");
await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "tok" } });
expect(telegramSpy).toHaveBeenCalledTimes(3);
const bodies = telegramSpy.mock.calls.map((c) => JSON.parse(c[1].body));
expect(bodies.map((b) => b.chat_id).sort()).toEqual([100, 200, 300]);
});
it("continues fanning out when one subscriber fails", async () => {
const ictOffsetMs = 7 * 60 * 60 * 1000;
const ictDayStart = new Date(
new Date(Date.now() + ictOffsetMs).setUTCHours(0, 0, 0, 0) - ictOffsetMs,
);
const pickTime = new Date(ictDayStart.getTime() + 12 * 60 * 60 * 1000).toISOString();
mockFetch([majorEvt(pickTime)]);
await db.putJSON("subscribers", [1, 2]);
// First Telegram call rejects, second resolves
telegramSpy
.mockImplementationOnce(async () => ({ ok: false, status: 403, text: async () => "blocked" }))
.mockImplementationOnce(async () => ({ ok: true, status: 200, text: async () => "{}" }));
await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "tok" } });
expect(telegramSpy).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import {
addSubscriber,
listSubscribers,
removeSubscriber,
} from "../../../src/modules/lolschedule/subscribers.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
describe("subscribers", () => {
let db;
beforeEach(() => {
db = createStore("lolschedule", { KV: makeFakeKv() });
});
it("starts empty", async () => {
expect(await listSubscribers(db)).toEqual([]);
});
it("adds a new subscriber and returns true", async () => {
expect(await addSubscriber(db, 42)).toBe(true);
expect(await listSubscribers(db)).toEqual([42]);
});
it("add is idempotent for an already-subscribed chat", async () => {
await addSubscriber(db, 42);
expect(await addSubscriber(db, 42)).toBe(false);
expect(await listSubscribers(db)).toEqual([42]);
});
it("removes an existing subscriber and returns true", async () => {
await addSubscriber(db, 1);
await addSubscriber(db, 2);
expect(await removeSubscriber(db, 1)).toBe(true);
expect(await listSubscribers(db)).toEqual([2]);
});
it("remove on a non-subscriber returns false and is a no-op", async () => {
await addSubscriber(db, 1);
expect(await removeSubscriber(db, 99)).toBe(false);
expect(await listSubscribers(db)).toEqual([1]);
});
});