From d50dd3f2f53ddb96049b05eb753e383f00f9d915 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 5 Aug 2026 16:29:25 +0700 Subject: [PATCH] fix(lol): switch schedule fetch to lolesports.com gql persisted queries Riot retired esports-api.lolesports.com and revoked its public x-api-key (403 for everyone), so fetch schedules from the lolesports.com /api/gql gateway instead using the web client's registered homeEvents operation. The persisted-query ID comes from the frontend's operations manifest; the package doc records how to refresh it when Riot rotates it, and a rotated ID logs lol_persisted_query_rotated instead of caching an empty schedule. Date windows are padded a day each side and filtered to exact instants locally because the gateway's date params have unspecified timezone semantics. --- internal/modules/lol/api_client.go | 259 ++++++++++++++---------- internal/modules/lol/api_client_test.go | 127 ++++++++---- internal/modules/lol/handlers_test.go | 16 +- 3 files changed, 251 insertions(+), 151 deletions(-) diff --git a/internal/modules/lol/api_client.go b/internal/modules/lol/api_client.go index 44379ea..6c4c9e0 100644 --- a/internal/modules/lol/api_client.go +++ b/internal/modules/lol/api_client.go @@ -1,23 +1,32 @@ // Package lol serves LoL esports match schedules via lolesports.com's -// persisted API plus a daily push to subscribers. +// GraphQL gateway plus a daily push to subscribers. // -// Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule -// Auth: x-api-key header — the public key embedded in lolesports.com's web -// client (no registration). If Riot ever rotates it, lift the new value -// from their public JS bundle. +// Endpoint: https://lolesports.com/api/gql — the same-origin proxy the +// lolesports.com web client uses. It accepts only persisted operations: +// the request carries no query text, just the operation name plus a +// pre-registered ID in the persistedQuery extension. The legacy +// esports-api.lolesports.com REST API and its public x-api-key were +// retired by Riot in 2026 (403 for everyone), so there is no key to ship. // -// Cache strategy: live-first fetches with a KV-backed 60-minute stale fallback -// for current schedule windows. +// If Riot redeploys the frontend with a changed homeEvents operation, the +// registered ID rotates and the gateway answers PERSISTED_QUERY_NOT_IN_LIST. +// To refresh: load lolesports.com, find the webpack runtime's chunk-hash map +// for chunk 29 (the persisted-operations manifest, `loadManifest` in the +// bundle), download /_next/static/chunks/29..js, and copy the `id` +// next to `"name":"homeEvents"`. +// +// Cache strategy: live-first fetches with a KV-backed 60-minute stale +// fallback for current schedule windows. package lol import ( + "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" - "net/url" "time" "unicode/utf8" @@ -26,13 +35,21 @@ import ( ) const ( - apiURL = "https://esports-api.lolesports.com/persisted/gw/getSchedule" - // apiKey is the public lolesports.com web client key (not a secret). - // gosec flags it as a hardcoded credential; the value is shipped in - // Riot's own public web bundle and serves the live site too. - // #nosec G101 - apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z" - userAgent = "miti99bot/0.1 (https://t.me/miti99bot)" + apiURL = "https://lolesports.com/api/gql" + // homeEventsID is the gateway-registered persisted-query ID for the web + // client's homeEvents operation (schedule events by date window). Not a + // hash we compute — the value comes from the frontend's operations + // manifest and must be re-extracted if Riot rotates it (see package doc). + homeEventsID = "7246add6f577cf30b304e651bf9e25fc6a41fe49aeafb0754c16b5778060fc0a" + // The gateway rejects requests without Apollo client-awareness headers + // ("No client headers set"), so we mirror the web client's name and send + // our own version string. + clientName = "Esports Web" + clientVersion = "miti99bot/0.1" + userAgent = "miti99bot/0.1 (https://t.me/miti99bot)" + // pageSize is the events-per-page the gateway accepts; 100 covers a full + // day in one page and a dense week in a handful. + pageSize = 100 // staleMaxAge: how long to fall back to a cached payload when the // upstream call fails outright. staleMaxAge = 60 * 60 * time.Second @@ -88,7 +105,9 @@ type Match struct { // ScheduleEvent is one upcoming or past match. State is "unstarted", // "inProgress", or "completed". Type is set to "show" for pre/post-show -// segments which we filter out. +// segments which we filter out. This struct is the module's stable shape: +// formatters and the bson cache read it, so the GraphQL response is mapped +// into it rather than leaking the transport shape. type ScheduleEvent struct { StartTime string `json:"startTime" bson:"startTime"` State string `json:"state,omitempty" bson:"state,omitempty"` @@ -98,17 +117,49 @@ type ScheduleEvent struct { Match Match `json:"match,omitempty" bson:"match,omitempty"` } -// schedulePage is the inner shape of an upstream response. -type schedulePage struct { +// gqlEvent is one event as the gateway returns it. Teams live in matchTeams +// (not match.teams), so it converts into the stable ScheduleEvent shape. +type gqlEvent struct { + StartTime string `json:"startTime"` + State string `json:"state"` + Type string `json:"type"` + BlockName string `json:"blockName"` + League League `json:"league"` + Match Match `json:"match"` + MatchTeams []Team `json:"matchTeams"` +} + +func (e gqlEvent) toScheduleEvent() ScheduleEvent { + m := e.Match + m.Teams = e.MatchTeams + return ScheduleEvent{ + StartTime: e.StartTime, + State: e.State, + Type: e.Type, + BlockName: e.BlockName, + League: e.League, + Match: m, + } +} + +// gqlResponse is the outer shape of a gateway response. GraphQL transports +// errors in-band on HTTP 200, so both branches must be checked. +type gqlResponse struct { Data struct { - Schedule struct { - Events []ScheduleEvent `json:"events"` + Esports struct { + Events []gqlEvent `json:"events"` Pages struct { - Newer string `json:"newer,omitempty"` Older string `json:"older,omitempty"` - } `json:"pages,omitempty"` - } `json:"schedule"` + Newer string `json:"newer,omitempty"` + } `json:"pages"` + } `json:"esports"` } `json:"data"` + Errors []struct { + Message string `json:"message"` + Extensions struct { + Code string `json:"code"` + } `json:"extensions"` + } `json:"errors"` } // cacheRecord is the store value: fetch timestamp + events. The Mongo store @@ -144,125 +195,113 @@ func (c *Client) baseURL() string { return apiURL } -// fetchSchedulePage retrieves one page of events. pageToken can be either a -// forward (`pages.newer`) or backward (`pages.older`) cursor — the upstream -// uses the same query param for both directions. Returns the page's events -// (sorted ascending by startTime) plus both cursor tokens for further -// navigation in either direction. -func (c *Client) fetchSchedulePage(ctx context.Context, pageToken string) ([]ScheduleEvent, string, string, error) { - u, err := url.Parse(c.baseURL()) - if err != nil { - return nil, "", "", fmt.Errorf("lol parse url: %w", err) - } - q := u.Query() - q.Set("hl", "en-US") - if pageToken != "" { - q.Set("pageToken", pageToken) - } - u.RawQuery = q.Encode() +// gqlRequest is the persisted-operation request body: no query text, just +// the operation name, variables, and the registered ID. +type gqlRequest struct { + OperationName string `json:"operationName"` + Variables map[string]any `json:"variables"` + Extensions struct { + PersistedQuery struct { + Version int `json:"version"` + Sha256Hash string `json:"sha256Hash"` + } `json:"persistedQuery"` + } `json:"extensions"` +} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, "", "", fmt.Errorf("lol build request: %w", err) +// fetchEventsPage retrieves one page of events for a date window. The +// gateway's eventDateStart/eventDateEnd are calendar dates with unspecified +// timezone semantics, so the window is padded a day on each side and callers +// filter to the exact [from, to) instants afterwards. pageToken continues a +// prior page's `pages.newer` cursor. Events come back ascending by startTime. +func (c *Client) fetchEventsPage(ctx context.Context, from, to time.Time, pageToken string) ([]ScheduleEvent, string, error) { + vars := map[string]any{ + "hl": "en-US", + "sport": []string{"lol"}, + "eventDateStart": from.UTC().AddDate(0, 0, -1).Format("2006-01-02"), + "eventDateEnd": to.UTC().AddDate(0, 0, 1).Format("2006-01-02"), + "eventType": "match", + "pageSize": pageSize, } - req.Header.Set("x-api-key", apiKey) - req.Header.Set("User-Agent", userAgent) + if pageToken != "" { + vars["pageToken"] = pageToken + } + reqBody := gqlRequest{OperationName: "homeEvents", Variables: vars} + reqBody.Extensions.PersistedQuery.Version = 1 + reqBody.Extensions.PersistedQuery.Sha256Hash = homeEventsID + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, "", fmt.Errorf("lol encode request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL(), bytes.NewReader(payload)) + if err != nil { + return nil, "", fmt.Errorf("lol build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") + req.Header.Set("apollographql-client-name", clientName) + req.Header.Set("apollographql-client-version", clientVersion) + req.Header.Set("User-Agent", userAgent) resp, err := c.httpClient().Do(req) if err != nil { - return nil, "", "", fmt.Errorf("lol do: %w", err) + return nil, "", fmt.Errorf("lol do: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, "", "", fmt.Errorf("lol read: %w", err) + return nil, "", fmt.Errorf("lol read: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Warn("lol_fetch", "status", resp.StatusCode, "body", truncate(string(body), 500)) - return nil, "", "", fmt.Errorf("lol API HTTP %d", resp.StatusCode) + return nil, "", fmt.Errorf("lol API HTTP %d", resp.StatusCode) } - var page schedulePage + var page gqlResponse if err := json.Unmarshal(body, &page); err != nil { - return nil, "", "", fmt.Errorf("lol decode: %w", err) + return nil, "", fmt.Errorf("lol decode: %w", err) + } + if len(page.Errors) > 0 { + first := page.Errors[0] + // A rotated persisted-query ID is an operational event, not a blip: + // every call fails until the constant is refreshed, so make the log + // unmistakable (see package doc for the refresh procedure). + if first.Extensions.Code == "PERSISTED_QUERY_NOT_IN_LIST" { + log.Error("lol_persisted_query_rotated", "msg", first.Message) + } else { + log.Warn("lol_fetch", "gql_code", first.Extensions.Code, "gql_err", truncate(first.Message, 500)) + } + return nil, "", fmt.Errorf("lol API gql error %s: %s", first.Extensions.Code, first.Message) } // Drop pre/post-show segments; they aren't matches. - out := make([]ScheduleEvent, 0, len(page.Data.Schedule.Events)) - for _, e := range page.Data.Schedule.Events { + out := make([]ScheduleEvent, 0, len(page.Data.Esports.Events)) + for _, e := range page.Data.Esports.Events { if e.Type == "show" { continue } - out = append(out, e) + out = append(out, e.toScheduleEvent()) } - return out, page.Data.Schedule.Pages.Newer, page.Data.Schedule.Pages.Older, nil + return out, page.Data.Esports.Pages.Newer, nil } -// earliestStart returns the first parseable startTime in a page that is sorted -// ascending. ok=false when no event has a valid timestamp. -func earliestStart(events []ScheduleEvent) (time.Time, bool) { - for _, e := range events { - if t, err := time.Parse(time.RFC3339, e.StartTime); err == nil { - return t, true - } - } - return time.Time{}, false -} - -// latestStart returns the last parseable startTime in a page that is sorted -// ascending. ok=false when no event has a valid timestamp. -func latestStart(events []ScheduleEvent) (time.Time, bool) { - for i := len(events) - 1; i >= 0; i-- { - if t, err := time.Parse(time.RFC3339, events[i].StartTime); err == nil { - return t, true - } - } - return time.Time{}, false -} - -// fetchEventsInRange covers [from, to) by walking both pagination directions -// from the default page (anchored at "now"). Walks older pages until the -// earliest collected event is ≤ from, then walks newer pages until the -// latest is ≥ to. Page budget caps both directions combined to bound -// upstream calls during dense weeks. +// fetchEventsInRange covers [from, to) with a date-bounded query, following +// `pages.newer` cursors when a window holds more than one page. Page budget +// bounds upstream calls during dense weeks. func (c *Client) fetchEventsInRange(ctx context.Context, from, to time.Time, maxPages int) ([]ScheduleEvent, error) { if maxPages <= 0 { maxPages = 8 } - events, newer, older, err := c.fetchSchedulePage(ctx, "") - if err != nil { - return nil, err - } - collected := events - pages := 1 - - // Walk older while the window extends before what we've collected. - for pages < maxPages && older != "" { - if t, ok := earliestStart(collected); ok && !t.After(from) { - break - } - olderEvents, _, prevOlder, err := c.fetchSchedulePage(ctx, older) + var collected []ScheduleEvent + pageToken := "" + for page := 0; page < maxPages; page++ { + events, newer, err := c.fetchEventsPage(ctx, from, to, pageToken) if err != nil { return nil, err } - // Each page is ascending and disjoint from the next, so prepending - // preserves overall ascending order. - collected = append(olderEvents, collected...) - older = prevOlder - pages++ - } - - // Walk newer while the window extends past what we've collected. - for pages < maxPages && newer != "" { - if t, ok := latestStart(collected); ok && !t.Before(to) { + collected = append(collected, events...) + if newer == "" { break } - newerEvents, nextNewer, _, err := c.fetchSchedulePage(ctx, newer) - if err != nil { - return nil, err - } - collected = append(collected, newerEvents...) - newer = nextNewer - pages++ + pageToken = newer } out := make([]ScheduleEvent, 0, len(collected)) diff --git a/internal/modules/lol/api_client_test.go b/internal/modules/lol/api_client_test.go index ddfcd2e..0d8ddf6 100644 --- a/internal/modules/lol/api_client_test.go +++ b/internal/modules/lol/api_client_test.go @@ -2,6 +2,7 @@ package lol import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -36,20 +37,23 @@ func mkServer(t *testing.T, body string) (*httptest.Server, *int32) { const sampleBody = `{ "data": { - "schedule": { + "esports": { "events": [ { "startTime": "2026-05-09T05:00:00Z", "state": "unstarted", + "type": "match", "league": {"slug": "lck", "name": "LCK"}, - "match": {"teams": [{"code":"T1"},{"code":"GEN"}], "strategy":{"count":3}} + "match": {"strategy":{"count":3}}, + "matchTeams": [{"code":"T1"},{"code":"GEN"}] }, { "startTime": "2026-05-09T08:00:00Z", "state": "unstarted", "type": "show", "league": {"slug": "lck", "name": "LCK"}, - "match": {"teams": [], "strategy":{}} + "match": {"strategy":{}}, + "matchTeams": [] } ], "pages": {"newer": null} @@ -74,6 +78,9 @@ func TestGetEventsWithFallback_FirstHitFetchesUpstreamAndCaches(t *testing.T) { if events[0].League.Slug != "lck" { t.Errorf("event slug = %q, want lck", events[0].League.Slug) } + if got := events[0].Match.Teams; len(got) != 2 || got[0].Code != "T1" { + t.Errorf("teams not mapped from matchTeams: %+v", got) + } if atomic.LoadInt32(count) != 1 { t.Errorf("upstream calls = %d, want 1", *count) } @@ -167,11 +174,12 @@ func TestGetEventsLive_DoesNotWriteCache(t *testing.T) { } } -func TestFetchSchedulePage_DropsShowEvents(t *testing.T) { +func TestFetchEventsPage_DropsShowEvents(t *testing.T) { srv, _ := mkServer(t, sampleBody) c := &Client{HTTP: srv.Client(), URL: srv.URL} - events, _, _, err := c.fetchSchedulePage(context.Background(), "") + from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + events, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), "") if err != nil { t.Fatal(err) } @@ -182,51 +190,74 @@ func TestFetchSchedulePage_DropsShowEvents(t *testing.T) { } } -func TestFetchSchedulePage_NonJSONErrors(t *testing.T) { +func TestFetchEventsPage_NonJSONErrors(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("not json")) })) defer srv.Close() c := &Client{HTTP: srv.Client(), URL: srv.URL} - _, _, _, err := c.fetchSchedulePage(context.Background(), "") + from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + _, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), "") if err == nil || !strings.Contains(err.Error(), "decode") { t.Errorf("non-JSON should produce decode error; got %v", err) } } -// TestFetchEventsInRange_WalksOlderForPastWindow guards the calendar-week -// regression: when `from` precedes the default page's earliest event, the -// fetcher must follow `pages.older` to pick up events earlier in the week. +// TestFetchEventsPage_GraphQLErrorIsFetchError guards the in-band error path: +// the gateway answers HTTP 200 with an `errors` array (e.g. when Riot rotates +// the persisted-query ID), and that must surface as a fetch failure — not be +// cached as an empty schedule. +func TestFetchEventsPage_GraphQLErrorIsFetchError(t *testing.T) { + body := `{"errors":[{"message":"Persisted query 'x' not found in the persisted query list","extensions":{"code":"PERSISTED_QUERY_NOT_IN_LIST"}}]}` + srv, _ := mkServer(t, body) + 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), "") + if err == nil || !strings.Contains(err.Error(), "PERSISTED_QUERY_NOT_IN_LIST") { + t.Errorf("gql error should surface as fetch error with code; got %v", err) + } +} + +// requestVars decodes the persisted-operation POST body a test server +// received and returns its variables map. +func requestVars(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var req struct { + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request body: %v", err) + } + return req.Variables +} + +// TestFetchEventsInRange_WalksNewerPages guards multi-page windows: when a +// date window holds more events than one page, the fetcher must follow +// `pages.newer` cursors until the gateway reports no further page. // -// Server simulates 3 pages: -// - default (no pageToken) → Thu May 21 -// - pageToken=older1 → Wed May 20 (returns older=older2) -// - pageToken=older2 → Mon May 18 + Tue May 19 (no older) -// -// Asking for Mon May 18 → next Mon must return all 4 events. -func TestFetchEventsInRange_WalksOlderForPastWindow(t *testing.T) { - defaultBody := `{"data":{"schedule":{"events":[ - {"startTime":"2026-05-21T05:00:00Z","state":"completed","league":{"slug":"lck","name":"LCK"},"match":{"teams":[{"code":"BFX"},{"code":"HLE"}],"strategy":{"count":3}}} - ],"pages":{"newer":null,"older":"older1"}}}}` - older1Body := `{"data":{"schedule":{"events":[ - {"startTime":"2026-05-20T05:00:00Z","state":"completed","league":{"slug":"lck","name":"LCK"},"match":{"teams":[{"code":"GEN"},{"code":"T1"}],"strategy":{"count":3}}} - ],"pages":{"newer":"newerX","older":"older2"}}}}` - older2Body := `{"data":{"schedule":{"events":[ - {"startTime":"2026-05-18T05:00:00Z","state":"completed","league":{"slug":"lck","name":"LCK"},"match":{"teams":[{"code":"KT"},{"code":"DK"}],"strategy":{"count":3}}}, - {"startTime":"2026-05-19T05:00:00Z","state":"completed","league":{"slug":"lck","name":"LCK"},"match":{"teams":[{"code":"NS"},{"code":"BRO"}],"strategy":{"count":3}}} - ],"pages":{"newer":"newerY","older":null}}}}` +// Server simulates 2 pages: +// - no pageToken → Mon May 18 + Tue May 19 (returns newer=page2) +// - pageToken=page2 → Wed May 20 (no newer) +func TestFetchEventsInRange_WalksNewerPages(t *testing.T) { + page1Body := `{"data":{"esports":{"events":[ + {"startTime":"2026-05-18T05:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"KT"},{"code":"DK"}]}, + {"startTime":"2026-05-19T05:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"NS"},{"code":"BRO"}]} + ],"pages":{"newer":"page2","older":null}}}}` + page2Body := `{"data":{"esports":{"events":[ + {"startTime":"2026-05-20T05:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"GEN"},{"code":"T1"}]} + ],"pages":{"newer":null,"older":"page1"}}}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - switch r.URL.Query().Get("pageToken") { + vars := requestVars(t, r) + token, _ := vars["pageToken"].(string) + switch token { case "": - _, _ = w.Write([]byte(defaultBody)) - case "older1": - _, _ = w.Write([]byte(older1Body)) - case "older2": - _, _ = w.Write([]byte(older2Body)) + _, _ = w.Write([]byte(page1Body)) + case "page2": + _, _ = w.Write([]byte(page2Body)) default: - t.Errorf("unexpected pageToken %q", r.URL.Query().Get("pageToken")) + t.Errorf("unexpected pageToken %q", token) w.WriteHeader(http.StatusBadRequest) } })) @@ -240,8 +271,32 @@ func TestFetchEventsInRange_WalksOlderForPastWindow(t *testing.T) { if err != nil { t.Fatalf("fetch: %v", err) } - if len(events) != 4 { - t.Errorf("events = %d, want 4 (Mon, Tue, Wed, Thu); got days=%v", len(events), eventDays(events)) + if len(events) != 3 { + t.Errorf("events = %d, want 3 (Mon, Tue, Wed); got days=%v", len(events), eventDays(events)) + } +} + +// TestFetchEventsInRange_FiltersToExactWindow guards the client-side [from, to) +// filter: the request pads the date window a day each side (the gateway's date +// params have fuzzy timezone semantics), so events outside the exact instants +// must be dropped locally. +func TestFetchEventsInRange_FiltersToExactWindow(t *testing.T) { + body := `{"data":{"esports":{"events":[ + {"startTime":"2026-05-17T23:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"KT"},{"code":"DK"}]}, + {"startTime":"2026-05-18T05:00:00Z","state":"unstarted","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"GEN"},{"code":"T1"}]}, + {"startTime":"2026-05-19T00:00:00Z","state":"unstarted","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"NS"},{"code":"BRO"}]} + ],"pages":{"newer":null}}}}` + 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 := from.Add(24 * time.Hour) + 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)) } } diff --git a/internal/modules/lol/handlers_test.go b/internal/modules/lol/handlers_test.go index 3258530..e18bc52 100644 --- a/internal/modules/lol/handlers_test.go +++ b/internal/modules/lol/handlers_test.go @@ -63,13 +63,15 @@ const fakeNowMs int64 = 1778328000000 // 2026-05-09T12:00:00Z const todayBody = `{ "data": { - "schedule": { + "esports": { "events": [ { "startTime": "2026-05-09T05:00:00Z", "state": "unstarted", + "type": "match", "league": {"slug": "lck", "name": "LCK"}, - "match": {"teams": [{"code":"T1"},{"code":"GEN"}], "strategy":{"count":3}} + "match": {"strategy":{"count":3}}, + "matchTeams": [{"code":"T1"},{"code":"GEN"}] } ], "pages": {"newer": null} @@ -79,19 +81,23 @@ const todayBody = `{ const futureBody = `{ "data": { - "schedule": { + "esports": { "events": [ { "startTime": "2026-05-10T05:00:00Z", "state": "unstarted", + "type": "match", "league": {"slug": "lck", "name": "LCK"}, - "match": {"teams": [{"code":"DK"},{"code":"KT"}], "strategy":{"count":3}} + "match": {"strategy":{"count":3}}, + "matchTeams": [{"code":"DK"},{"code":"KT"}] }, { "startTime": "2026-05-12T08:00:00Z", "state": "unstarted", + "type": "match", "league": {"slug": "lpl", "name": "LPL"}, - "match": {"teams": [{"code":"JDG"},{"code":"BLG"}], "strategy":{"count":5}} + "match": {"strategy":{"count":5}}, + "matchTeams": [{"code":"JDG"},{"code":"BLG"}] } ], "pages": {"newer": null}