Files
miti99bot/internal/modules/lolschedule/format_test.go
T
tiennm99 6fa01ba5f1 feat(modules): port lolschedule (5 commands; daily-push cron deferred)
Phase 6e (final sub-phase of port-plan Phase 06). LoL esports match
schedule via lolesports.com's persisted API.

- internal/modules/lolschedule:
  - api_client.go: HTTP client with cache-first lookup (120s fresh
    window, 60-min stale fallback). Cache record shape matches JS so
    cross-runtime KV migration round-trips.
  - parse_date.go: ICT-anchored date parser. Accepts dd-mm-yyyy,
    dd/mm/yyyy, ddmmyyyy; trailing month/year may be omitted (default
    to current ICT month/year). Rejects impossible dates (Apr 31, Feb
    29 in non-leap, etc.).
  - format.go: Today (grouped by league) and Week (grouped by league
    -> day) renderers. Major-league filter (LCK/LPL/LEC/LCS/Worlds/
    MSI/etc.) keeps replies under Telegram's 4096-char limit. All
    user-influenced strings HTML-escaped.
  - subscribers.go: Idempotent add/remove/list keyed by chat id.
  - handlers.go: 5 commands (`/lolschedule [date]`,
    `/lolschedule_today`, `/lolschedule_week`,
    `/lolschedule_subscribe`, `/lolschedule_unsubscribe`).
- 22 tests across api-client (cache hit / miss / stale fallback /
  hard fail / show filter / non-JSON), parse-date (full and
  short formats, defaults, rejections, ICT anchor), format (event
  line states, league ordering, week grouping, HTML escape, major
  filter), subscribers (idempotent add/remove), handlers (HTML
  reply, error path, subscribe/unsubscribe round-trip).

Daily-push cron deferred to Phase 09 (Cloud Scheduler). Subscribers
are still collected so the push lights up the moment the cron infra
lands. Deps doesn't currently expose a *bot.Bot reference; that is
the prerequisite that Phase 09 will solve.

go test -race -count=1 ./... clean (19 packages); golangci-lint clean.
2026-05-09 17:14:01 +07:00

195 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lolschedule
import (
"strings"
"testing"
"time"
)
func mkEvent(state, slug, name, t1Code, t2Code, startISO string) ScheduleEvent {
return ScheduleEvent{
StartTime: startISO,
State: state,
League: League{Slug: slug, Name: name},
Match: Match{
Teams: []Team{{Code: t1Code}, {Code: t2Code}},
Strategy: Strategy{Type: "bestOf", Count: 3},
},
}
}
func TestFormatEventLine_Unstarted(t *testing.T) {
e := mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z")
got := formatEventLine(e)
if !strings.Contains(got, "🕒") {
t.Errorf("missing clock emoji: %q", got)
}
if !strings.Contains(got, "T1 vs GEN") {
t.Errorf("missing team labels: %q", got)
}
if !strings.Contains(got, "Bo3") {
t.Errorf("missing Bo3: %q", got)
}
// 05:00 UTC == 12:00 ICT.
if !strings.Contains(got, "12:00") {
t.Errorf("ICT time wrong; got %q (expected 12:00)", got)
}
}
func TestFormatEventLine_Completed_BoldsWinner(t *testing.T) {
winResult := &struct {
Outcome string `json:"outcome,omitempty"`
GameWins int `json:"gameWins,omitempty"`
}{Outcome: "win", GameWins: 3}
loseResult := &struct {
Outcome string `json:"outcome,omitempty"`
GameWins int `json:"gameWins,omitempty"`
}{Outcome: "loss", GameWins: 1}
e := ScheduleEvent{
StartTime: "2026-05-09T05:00:00Z",
State: "completed",
League: League{Slug: "lck", Name: "LCK"},
Match: Match{
Teams: []Team{
{Code: "T1", Result: winResult},
{Code: "GEN", Result: loseResult},
},
Strategy: Strategy{Count: 5},
},
}
got := formatEventLine(e)
if !strings.Contains(got, "✅") {
t.Errorf("missing completed emoji: %q", got)
}
if !strings.Contains(got, "<b>T1</b>") {
t.Errorf("winner not bolded: %q", got)
}
if !strings.Contains(got, "31") {
t.Errorf("score missing: %q", got)
}
if strings.Contains(got, "<b>GEN</b>") {
t.Errorf("loser should not be bolded: %q", got)
}
}
func TestFormatEventLine_InProgress(t *testing.T) {
w := &struct {
Outcome string `json:"outcome,omitempty"`
GameWins int `json:"gameWins,omitempty"`
}{GameWins: 1}
e := ScheduleEvent{
StartTime: "2026-05-09T05:00:00Z",
State: "inProgress",
League: League{Slug: "lck"},
Match: Match{
Teams: []Team{{Code: "T1", Result: w}, {Code: "GEN", Result: w}},
Strategy: Strategy{Count: 5},
},
}
got := formatEventLine(e)
if !strings.Contains(got, "🔴 LIVE") {
t.Errorf("missing LIVE marker: %q", got)
}
if !strings.Contains(got, "11") {
t.Errorf("score missing: %q", got)
}
}
func TestRenderToday_GroupsByLeagueInOrder(t *testing.T) {
day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation)
events := []ScheduleEvent{
mkEvent("unstarted", "lcs", "LCS", "TL", "C9", "2026-05-09T18:00:00Z"),
mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z"),
mkEvent("unstarted", "lpl", "LPL", "JDG", "BLG", "2026-05-09T08:00:00Z"),
}
got := RenderToday(events, day)
// LEAGUE_ORDER puts LCK before LPL before LCS.
idxLck := strings.Index(got, "<b>LCK</b>")
idxLpl := strings.Index(got, "<b>LPL</b>")
idxLcs := strings.Index(got, "<b>LCS</b>")
if idxLck < 0 || idxLpl < 0 || idxLcs < 0 {
t.Fatalf("missing league section; got:\n%s", got)
}
if idxLck >= idxLpl || idxLpl >= idxLcs {
t.Errorf("league order wrong: lck=%d lpl=%d lcs=%d\n%s", idxLck, idxLpl, idxLcs, got)
}
// Header in ICT.
if !strings.Contains(got, "LoL — Sat May 9</b> (ICT)") {
t.Errorf("header wrong: %q", got)
}
}
func TestRenderToday_EmptyShowsNoMatches(t *testing.T) {
day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation)
got := RenderToday(nil, day)
if !strings.Contains(got, "No matches today.") {
t.Errorf("empty render missing 'No matches today.': %q", got)
}
}
func TestRenderWeek_GroupsByLeagueAndDay(t *testing.T) {
from := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation)
to := from.AddDate(0, 0, 7)
events := []ScheduleEvent{
mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z"),
mkEvent("unstarted", "lck", "LCK", "DK", "KT", "2026-05-10T05:00:00Z"),
}
got := RenderWeek(events, from, to)
if !strings.Contains(got, "<b>LCK</b>") {
t.Errorf("missing LCK section: %q", got)
}
// Both days should appear under LCK.
if !strings.Contains(got, "Sat May 9") {
t.Errorf("missing Sat May 9: %q", got)
}
if !strings.Contains(got, "Sun May 10") {
t.Errorf("missing Sun May 10: %q", got)
}
}
func TestFilterMajor(t *testing.T) {
events := []ScheduleEvent{
{League: League{Slug: "lck"}},
{League: League{Slug: "lpl"}},
{League: League{Slug: "tcl"}}, // Turkish league — not in allowlist
{League: League{Slug: "lja"}}, // Japan academy — not in allowlist
{League: League{Slug: "msi"}},
}
got := FilterMajor(events)
if len(got) != 3 {
t.Errorf("filtered count = %d, want 3", len(got))
}
for _, e := range got {
if e.League.Slug == "tcl" || e.League.Slug == "lja" {
t.Errorf("non-major league leaked: %s", e.League.Slug)
}
}
}
func TestFormatEventLine_EscapesUserStrings(t *testing.T) {
e := ScheduleEvent{
StartTime: "2026-05-09T05:00:00Z",
State: "unstarted",
BlockName: "<script>",
League: League{Slug: "lck"},
Match: Match{
Teams: []Team{
{Name: "Tom & Jerry"},
{Name: `"Quotes"`},
},
Strategy: Strategy{Count: 1},
},
}
got := formatEventLine(e)
if strings.Contains(got, "<script>") {
t.Errorf("raw <script> leaked: %q", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("BlockName not escaped: %q", got)
}
if strings.Contains(got, "Tom & Jerry") {
// & should be escaped to &amp;
t.Errorf("ampersand not escaped: %q", got)
}
}