package lol import ( "context" "errors" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "github.com/tiennm99/miti99bot/internal/storage" ) // 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 } const sampleBody = `{ "data": { "schedule": { "events": [ { "startTime": "2026-05-09T05:00:00Z", "state": "unstarted", "league": {"slug": "lck", "name": "LCK"}, "match": {"teams": [{"code":"T1"},{"code":"GEN"}], "strategy":{"count":3}} }, { "startTime": "2026-05-09T08:00:00Z", "state": "unstarted", "type": "show", "league": {"slug": "lck", "name": "LCK"}, "match": {"teams": [], "strategy":{}} } ], "pages": {"newer": null} } } }` func TestGetEventsWithFallback_FirstHitFetchesUpstreamAndCaches(t *testing.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 (show filtered out)", len(events)) } if events[0].League.Slug != "lck" { t.Errorf("event slug = %q, want lck", events[0].League.Slug) } 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) { 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) { // 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) { 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) { 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 TestFetchSchedulePage_DropsShowEvents(t *testing.T) { srv, _ := mkServer(t, sampleBody) c := &Client{HTTP: srv.Client(), URL: srv.URL} events, _, _, err := c.fetchSchedulePage(context.Background(), "") if err != nil { t.Fatal(err) } for _, e := range events { if e.Type == "show" { t.Errorf("show event leaked: %+v", e) } } } func TestFetchSchedulePage_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(), "") 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. // // 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}}}}` 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") { case "": _, _ = w.Write([]byte(defaultBody)) case "older1": _, _ = w.Write([]byte(older1Body)) case "older2": _, _ = w.Write([]byte(older2Body)) default: t.Errorf("unexpected pageToken %q", r.URL.Query().Get("pageToken")) 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) } if len(events) != 4 { t.Errorf("events = %d, want 4 (Mon, Tue, Wed, Thu); got days=%v", len(events), 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) } } // Smoke: ErrEmptyResult is exported and distinct from generic errors. func TestErrEmptyResult_Identity(t *testing.T) { if errors.Is(ErrEmptyResult, errors.New("other")) { t.Error("ErrEmptyResult should not match arbitrary errors") } }