mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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.
This commit is contained in:
1 parent
bd0ad6b817
commit
d50dd3f2f5
3 files changed
+251
-151
No files matched your search
+149
-110
@@ -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.<hash>.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))
|
||||
|
||||
@@ -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("<html>not json</html>"))
|
||||
}))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user