mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-24 04:20:39 +00:00
273 lines
9.2 KiB
Go
273 lines
9.2 KiB
Go
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("<html>not json</html>"))
|
|
}))
|
|
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")
|
|
}
|
|
}
|