Files
tiennm99 277e5e1654 feat(lol): replace schedule source with PandaScore API
Swap the lol module upstream from the lolesports.com gql persisted-query
client to PandaScore REST (/lol/matches, Bearer LOL_PANDASCORE_TOKEN,
free tier 1000 req/h). The gql transport broke whenever Riot redeployed
their frontend; PandaScore is a stable versioned contract.

ScheduleEvent, formatters, cron, and the bson cache shape are unchanged:
only the transport and response mapping moved. PandaScore league slugs
canonicalize to the existing major-league allowlist; results join to
opponents by team_id so reversed arrays cannot swap scores; outcomes are
declared only once upstream commits a winner, preserving the
score-pending rendering. A still-full final page now logs
lol_page_budget_exhausted and the live page budget covers 500 raw
matches per window.

Missing token short-circuits with lol_token_missing before any upstream
call; the 60-minute stale cache still covers outages. Document the new
env var and cancel the superseded Leaguepedia score-enrichment plan.
2026-08-05 17:39:20 +07:00

425 lines
15 KiB
Go

package lol
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/tiennm99/miti99bot/internal/storage"
)
// setTestToken puts a dummy PandaScore token in the environment so client
// calls pass the token presence check. httptest servers ignore it.
func setTestToken(t *testing.T) {
t.Helper()
t.Setenv(tokenEnv, "test-token")
}
// newCacheStore returns a fresh typed cache store backed by an in-memory
// collection — the per-test equivalent of what the factory wires in production.
func newCacheStore() CacheStore {
return storage.Typed[cacheRecord](storage.NewMemoryProvider().Collection("lol"))
}
// mkServer spins an httptest.Server returning the supplied JSON body for
// every page request. callCount counts upstream hits so cache tests can
// assert "1 fetch, then no more".
func mkServer(t *testing.T, body string) (*httptest.Server, *int32) {
t.Helper()
var count int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&count, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv, &count
}
// sampleBody is a PandaScore /lol/matches response: a top-level array.
// Second entry carries a status outside the mapped vocabulary and must be
// dropped.
const sampleBody = `[
{
"id": 1001,
"begin_at": "2026-05-09T05:00:00Z",
"status": "not_started",
"number_of_games": 3,
"winner_id": null,
"league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea", "image_url": ""},
"tournament": {"name": "Regular Season"},
"opponents": [
{"opponent": {"id": 1, "acronym": "T1", "name": "T1", "image_url": ""}},
{"opponent": {"id": 2, "acronym": "GEN", "name": "Gen.G", "image_url": ""}}
],
"results": [{"team_id": 1, "score": 0}, {"team_id": 2, "score": 0}]
},
{
"id": 1002,
"begin_at": "2026-05-09T08:00:00Z",
"status": "canceled",
"number_of_games": 3,
"winner_id": null,
"league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea", "image_url": ""},
"tournament": {"name": "Regular Season"},
"opponents": [],
"results": []
}
]`
func TestGetEventsWithFallback_FirstHitFetchesUpstreamAndCaches(t *testing.T) {
setTestToken(t)
srv, count := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
events, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
if err != nil {
t.Fatalf("first fetch: %v", err)
}
if len(events) != 1 {
t.Errorf("events = %d, want 1 (canceled dropped)", len(events))
}
if events[0].League.Slug != "lck" {
t.Errorf("event slug = %q, want lck (canonicalized)", events[0].League.Slug)
}
if got := events[0].Match.Teams; len(got) != 2 || got[0].Code != "T1" {
t.Errorf("teams not mapped from opponents: %+v", got)
}
if atomic.LoadInt32(count) != 1 {
t.Errorf("upstream calls = %d, want 1", *count)
}
cached, _, err := cache.Get(context.Background(), cacheKey(from, to))
if err != nil {
t.Fatalf("load cached record: %v", err)
}
if cached.Ts <= 0 {
t.Fatalf("cached ts = %d, want positive", cached.Ts)
}
}
func TestGetEventsWithFallback_AlwaysFetchesWhenUpstreamAvailable(t *testing.T) {
setTestToken(t)
srv, count := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
if _, err := c.GetEventsWithFallback(context.Background(), cache, from, to); err != nil {
t.Fatal(err)
}
if _, err := c.GetEventsWithFallback(context.Background(), cache, from, to); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(count); got != 2 {
t.Errorf("upstream calls = %d, want 2 (live-first should refetch)", got)
}
}
func TestGetEventsWithFallback_StaleFallback(t *testing.T) {
setTestToken(t)
// Prime the typed cache store with a stale-but-still-fresh-enough record.
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
staleEvents := []ScheduleEvent{
{StartTime: "2026-05-09T05:00:00Z", League: League{Slug: "lck", Name: "LCK"}},
}
// 10 minutes ago — past the 120s fresh window but well inside 60-min stale.
staleTs := time.Now().UTC().Add(-10 * time.Minute).UnixMilli()
if err := cache.Put(context.Background(), cacheKey(from, to), cacheRecord{Ts: staleTs, Events: staleEvents}); err != nil {
t.Fatal(err)
}
// Upstream errors — server returns 500.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"down"}`))
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
got, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
if err != nil {
t.Fatalf("stale fallback should succeed: %v", err)
}
if len(got) != 1 || got[0].League.Slug != "lck" {
t.Errorf("stale fallback returned wrong events: %+v", got)
}
}
func TestGetEventsWithFallback_HardFailureWhenNoCache(t *testing.T) {
setTestToken(t)
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
_, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
if err == nil {
t.Errorf("expected error when upstream fails AND no cache")
}
}
func TestGetEventsLive_DoesNotWriteCache(t *testing.T) {
setTestToken(t)
srv, _ := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
if _, err := c.GetEventsLive(context.Background(), from, to); err != nil {
t.Fatalf("live fetch: %v", err)
}
if _, _, err := cache.Get(context.Background(), cacheKey(from, to)); !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("live fetch cache entry err = %v, want ErrNotFound", err)
}
}
func TestFetchEventsPage_TokenMissing(t *testing.T) {
t.Setenv(tokenEnv, " ") // whitespace-only counts as missing
srv, count := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
_, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), 1)
if err == nil || !strings.Contains(err.Error(), tokenEnv) {
t.Errorf("missing token should error mentioning %s; got %v", tokenEnv, err)
}
if atomic.LoadInt32(count) != 0 {
t.Errorf("upstream must not be called without a token; calls = %d", *count)
}
}
// TestFetchEventsPage_RequestShape asserts the wire contract: Bearer auth
// header (never token-in-URL) plus range/sort/per_page/page params.
func TestFetchEventsPage_RequestShape(t *testing.T) {
setTestToken(t)
var gotAuth, gotRange, gotSort, gotPerPage, gotPage string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
q := r.URL.Query()
gotRange = q.Get("range[begin_at]")
gotSort = q.Get("sort")
gotPerPage = q.Get("per_page")
gotPage = q.Get("page")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
if _, _, err := c.fetchEventsPage(context.Background(), from, to, 2); err != nil {
t.Fatal(err)
}
if gotAuth != "Bearer test-token" {
t.Errorf("Authorization = %q, want Bearer test-token", gotAuth)
}
if want := "2026-05-09T00:00:00Z,2026-05-10T00:00:00Z"; gotRange != want {
t.Errorf("range[begin_at] = %q, want %q", gotRange, want)
}
if gotSort != "begin_at" || gotPerPage != "100" || gotPage != "2" {
t.Errorf("sort/per_page/page = %q/%q/%q, want begin_at/100/2", gotSort, gotPerPage, gotPage)
}
}
func TestFetchEventsPage_NonJSONErrors(t *testing.T) {
setTestToken(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html>not json</html>"))
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
_, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), 1)
if err == nil || !strings.Contains(err.Error(), "decode") {
t.Errorf("non-JSON should produce decode error; got %v", err)
}
}
// TestToScheduleEvent_ScoreAndWinnerMapping guards the results join: results
// arrive keyed by team_id in arbitrary order relative to opponents, and the
// winner flag must translate into win/loss outcomes only when finished.
func TestToScheduleEvent_ScoreAndWinnerMapping(t *testing.T) {
winner := int64(2)
body := fmt.Sprintf(`{
"id": 42,
"begin_at": "2026-05-09T05:00:00Z",
"status": "finished",
"number_of_games": 5,
"winner_id": %d,
"league": {"name": "LPL", "slug": "league-of-legends-lpl-china", "image_url": ""},
"tournament": {"name": "Playoffs"},
"opponents": [
{"opponent": {"id": 1, "acronym": "JDG", "name": "JD Gaming", "image_url": ""}},
{"opponent": {"id": 2, "acronym": "BLG", "name": "Bilibili Gaming", "image_url": ""}}
],
"results": [{"team_id": 2, "score": 3}, {"team_id": 1, "score": 1}]
}`, winner)
var m psMatch
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatal(err)
}
e, ok := m.toScheduleEvent()
if !ok {
t.Fatal("finished match must map")
}
if e.State != "completed" || e.BlockName != "Playoffs" || e.League.Slug != "lpl" {
t.Errorf("state/block/slug = %q/%q/%q", e.State, e.BlockName, e.League.Slug)
}
if e.Match.Strategy.Count != 5 {
t.Errorf("Bo = %d, want 5", e.Match.Strategy.Count)
}
jdg, blg := e.Match.Teams[0], e.Match.Teams[1]
// Results were listed BLG-first; the join by team_id must still credit
// JDG with 1 and BLG with 3.
if jdg.Result.GameWins != 1 || jdg.Result.Outcome != "loss" {
t.Errorf("JDG result = %+v, want 1/loss", jdg.Result)
}
if blg.Result.GameWins != 3 || blg.Result.Outcome != "win" {
t.Errorf("BLG result = %+v, want 3/win", blg.Result)
}
}
// TestToScheduleEvent_FinishedWithoutWinnerStaysPending: finished but no
// winner_id → no outcome declared → formatters render "score pending".
func TestToScheduleEvent_FinishedWithoutWinnerStaysPending(t *testing.T) {
m := psMatch{Status: "finished", BeginAt: "2026-05-09T05:00:00Z"}
m.Opponents = make([]struct {
Opponent struct {
ID int64 `json:"id"`
Acronym string `json:"acronym"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
} `json:"opponent"`
}, 2)
m.Opponents[0].Opponent.ID = 1
m.Opponents[1].Opponent.ID = 2
e, ok := m.toScheduleEvent()
if !ok {
t.Fatal("finished match must map")
}
if scoreIsPublished(e.Match.Teams[0], e.Match.Teams[1]) {
t.Errorf("no winner_id must leave score unpublished")
}
}
// TestFetchEventsInRange_WalksFullPages guards pagination: a full raw page
// (100 items) must trigger a fetch of the next page, driven by the RAW count
// — status-dropped matches must not end the walk early.
func TestFetchEventsInRange_WalksFullPages(t *testing.T) {
setTestToken(t)
// Page 1: 100 raw matches (2 canceled), page 2: 1 match.
page1 := make([]map[string]any, 0, pageSize)
for i := 0; i < pageSize; i++ {
status := "not_started"
if i < 2 {
status = "canceled"
}
page1 = append(page1, map[string]any{
"id": 2000 + i, "begin_at": "2026-05-18T05:00:00Z", "status": status,
"number_of_games": 1,
"league": map[string]any{"name": "LCK", "slug": "league-of-legends-lck-champions-korea"},
"tournament": map[string]any{"name": "Regular Season"},
"opponents": []any{}, "results": []any{},
})
}
page1JSON, _ := json.Marshal(page1)
page2JSON := `[{"id": 3000, "begin_at": "2026-05-19T05:00:00Z", "status": "not_started",
"number_of_games": 1,
"league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"},
"tournament": {"name": "Regular Season"}, "opponents": [], "results": []}]`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Query().Get("page") {
case "1":
_, _ = w.Write(page1JSON)
case "2":
_, _ = w.Write([]byte(page2JSON))
default:
t.Errorf("unexpected page %q", r.URL.Query().Get("page"))
w.WriteHeader(http.StatusBadRequest)
}
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
from := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC)
to := from.Add(7 * 24 * time.Hour)
events, err := c.fetchEventsInRange(context.Background(), from, to, 0)
if err != nil {
t.Fatalf("fetch: %v", err)
}
// 100 raw - 2 canceled + 1 on page 2.
if len(events) != 99 {
t.Errorf("events = %d, want 99", len(events))
}
}
// TestFetchEventsInRange_FiltersToExactWindow guards the client-side
// [from, to) filter: PandaScore's range upper bound is inclusive, so an
// event starting exactly at `to` comes back and must be dropped locally.
func TestFetchEventsInRange_FiltersToExactWindow(t *testing.T) {
setTestToken(t)
body := `[
{"id": 1, "begin_at": "2026-05-18T05:00:00Z", "status": "not_started", "number_of_games": 3,
"league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"},
"tournament": {"name": "Regular Season"},
"opponents": [{"opponent": {"id": 1, "acronym": "GEN", "name": "Gen.G"}}], "results": []},
{"id": 2, "begin_at": "2026-05-19T00:00:00Z", "status": "not_started", "number_of_games": 3,
"league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"},
"tournament": {"name": "Regular Season"},
"opponents": [{"opponent": {"id": 2, "acronym": "T1", "name": "T1"}}], "results": []}
]`
srv, _ := mkServer(t, body)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
from := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC) // second event is exactly `to`
events, err := c.fetchEventsInRange(context.Background(), from, to, 0)
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(events) != 1 || events[0].Match.Teams[0].Code != "GEN" {
t.Errorf("window filter wrong, got %v", eventDays(events))
}
}
func eventDays(events []ScheduleEvent) []string {
out := make([]string, 0, len(events))
for _, e := range events {
out = append(out, e.StartTime)
}
return out
}
// truncate is internal but worth a smoke test — log payloads use it.
func TestTruncate(t *testing.T) {
if got := truncate("short", 10); got != "short" {
t.Errorf("truncate short = %q, want unchanged", got)
}
got := truncate("a long enough string", 5)
if got != "a lon..." {
t.Errorf("truncate = %q, want 'a lon...'", got)
}
}