mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-07 22:20:56 +00:00
feat(lol): make match cache fallback-only
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
// client (no registration). If Riot ever rotates it, lift the new value
|
||||
// from their public JS bundle.
|
||||
//
|
||||
// Cache strategy: KV-backed cacheRecord with a 120s fresh window and a
|
||||
// 60-minute stale fallback (stale-while-error).
|
||||
// Cache strategy: live-first fetches with a KV-backed 60-minute stale fallback
|
||||
// for current schedule windows.
|
||||
package lol
|
||||
|
||||
import (
|
||||
@@ -33,8 +33,6 @@ const (
|
||||
// #nosec G101
|
||||
apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"
|
||||
userAgent = "miti99bot/0.1 (https://t.me/miti99bot)"
|
||||
// CacheTTL: schedule data changes minute-by-minute during live events.
|
||||
cacheTTL = 120 * time.Second
|
||||
// staleMaxAge: how long to fall back to a cached payload when the
|
||||
// upstream call fails outright.
|
||||
staleMaxAge = 60 * 60 * time.Second
|
||||
@@ -113,11 +111,11 @@ type schedulePage struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// cacheRecord is the store value: fetch timestamp + events.
|
||||
// cacheRecord is the store value: fetch timestamp + events. The Mongo store
|
||||
// owns updatedAt separately; LoL uses ts for cache freshness in every backend.
|
||||
type cacheRecord struct {
|
||||
Ts int64 `json:"ts" bson:"ts"` // ms-since-epoch when fetched
|
||||
FetchedAt *time.Time `json:"fetchedAt,omitempty" bson:"fetchedAt,omitempty"` // Mongo TTL anchor for matches:* cache docs
|
||||
Events []ScheduleEvent `json:"events" bson:"events"`
|
||||
Ts int64 `json:"ts" bson:"ts"` // ms-since-epoch when fetched
|
||||
Events []ScheduleEvent `json:"events" bson:"events"`
|
||||
}
|
||||
|
||||
// CacheStore is the typed store for schedule cache records.
|
||||
@@ -285,23 +283,25 @@ func cacheKey(from, to time.Time) string {
|
||||
return "matches:" + from.UTC().Format(time.RFC3339) + ":" + to.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// GetEventsCached is the cache-first lookup. Returns fresh cache within
|
||||
// cacheTTL, else fetches upstream and writes back, else falls back to
|
||||
// stale cache (within staleMaxAge), else propagates the error.
|
||||
func (c *Client) GetEventsCached(ctx context.Context, cache CacheStore, from, to time.Time) ([]ScheduleEvent, error) {
|
||||
// GetEventsLive fetches the requested range directly from upstream without
|
||||
// consulting or writing the fallback cache.
|
||||
func (c *Client) GetEventsLive(ctx context.Context, from, to time.Time) ([]ScheduleEvent, error) {
|
||||
return c.fetchEventsInRange(ctx, from, to, 3)
|
||||
}
|
||||
|
||||
// GetEventsWithFallback is live-first. It always tries upstream, writes a
|
||||
// successful response to cache, and uses a recent cached response only when the
|
||||
// upstream call fails.
|
||||
func (c *Client) GetEventsWithFallback(ctx context.Context, cache CacheStore, from, to time.Time) ([]ScheduleEvent, error) {
|
||||
key := cacheKey(from, to)
|
||||
nowTime := time.Now().UTC()
|
||||
now := nowTime.UnixMilli()
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
|
||||
cached, _, cacheErr := cache.Get(ctx, key)
|
||||
hasCached := cacheErr == nil
|
||||
if hasCached && now-cached.Ts < cacheTTL.Milliseconds() {
|
||||
return cached.Events, nil
|
||||
}
|
||||
|
||||
events, fetchErr := c.fetchEventsInRange(ctx, from, to, 3)
|
||||
events, fetchErr := c.GetEventsLive(ctx, from, to)
|
||||
if fetchErr == nil {
|
||||
rec := cacheRecord{Ts: now, FetchedAt: &nowTime, Events: events}
|
||||
rec := cacheRecord{Ts: now, Events: events}
|
||||
if err := cache.Put(ctx, key, rec); err != nil {
|
||||
log.Warn("lol_kv_put_fail", "err", err)
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func (c *Client) GetEventsCached(ctx context.Context, cache CacheStore, from, to
|
||||
}
|
||||
|
||||
// Upstream failed — fall back to stale cache if recent enough.
|
||||
if hasCached && cached.Events != nil && now-cached.Ts < staleMaxAge.Milliseconds() {
|
||||
if hasCached && now-cached.Ts < staleMaxAge.Milliseconds() {
|
||||
log.Warn("lol_stale_fallback", "err", fetchErr)
|
||||
return cached.Events, nil
|
||||
}
|
||||
|
||||
@@ -57,14 +57,14 @@ const sampleBody = `{
|
||||
}
|
||||
}`
|
||||
|
||||
func TestGetEventsCached_FirstHitFetchesUpstream(t *testing.T) {
|
||||
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.GetEventsCached(context.Background(), cache, from, to)
|
||||
events, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
|
||||
if err != nil {
|
||||
t.Fatalf("first fetch: %v", err)
|
||||
}
|
||||
@@ -84,35 +84,27 @@ func TestGetEventsCached_FirstHitFetchesUpstream(t *testing.T) {
|
||||
if cached.Ts <= 0 {
|
||||
t.Fatalf("cached ts = %d, want positive", cached.Ts)
|
||||
}
|
||||
if cached.FetchedAt == nil {
|
||||
t.Fatal("cached fetchedAt is nil")
|
||||
}
|
||||
if cached.FetchedAt.UnixMilli() != cached.Ts {
|
||||
t.Fatalf("cached fetchedAt = %d, want ts %d", cached.FetchedAt.UnixMilli(), cached.Ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEventsCached_SecondHitUsesCache(t *testing.T) {
|
||||
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)
|
||||
|
||||
// First fetch primes the cache.
|
||||
if _, err := c.GetEventsCached(context.Background(), cache, from, to); err != nil {
|
||||
if _, err := c.GetEventsWithFallback(context.Background(), cache, from, to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Second fetch within TTL must NOT hit upstream.
|
||||
if _, err := c.GetEventsCached(context.Background(), cache, from, to); err != nil {
|
||||
if _, err := c.GetEventsWithFallback(context.Background(), cache, from, to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(count); got != 1 {
|
||||
t.Errorf("upstream calls = %d, want 1 (cache should serve second call)", got)
|
||||
if got := atomic.LoadInt32(count); got != 2 {
|
||||
t.Errorf("upstream calls = %d, want 2 (live-first should refetch)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEventsCached_StaleFallback(t *testing.T) {
|
||||
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)
|
||||
@@ -134,7 +126,7 @@ func TestGetEventsCached_StaleFallback(t *testing.T) {
|
||||
defer srv.Close()
|
||||
c := &Client{HTTP: srv.Client(), URL: srv.URL}
|
||||
|
||||
got, err := c.GetEventsCached(context.Background(), cache, from, to)
|
||||
got, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
|
||||
if err != nil {
|
||||
t.Fatalf("stale fallback should succeed: %v", err)
|
||||
}
|
||||
@@ -143,7 +135,7 @@ func TestGetEventsCached_StaleFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEventsCached_HardFailureWhenNoCache(t *testing.T) {
|
||||
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)
|
||||
@@ -154,12 +146,27 @@ func TestGetEventsCached_HardFailureWhenNoCache(t *testing.T) {
|
||||
defer srv.Close()
|
||||
c := &Client{HTTP: srv.Client(), URL: srv.URL}
|
||||
|
||||
_, err := c.GetEventsCached(context.Background(), cache, from, to)
|
||||
_, 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}
|
||||
|
||||
@@ -183,7 +183,7 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
|
||||
|
||||
from := ictDayStartOf(s.now())
|
||||
to := addDays(from, 1)
|
||||
events, err := s.client.GetEventsCached(ctx, s.cache, from, to)
|
||||
events, err := s.client.GetEventsWithFallback(ctx, s.cache, from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lol daily push: fetch matches: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package lol
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -66,8 +68,8 @@ func newTestStore(t *testing.T) (SubscriberStore, PushDateStore, CacheStore) {
|
||||
storage.Typed[cacheRecord](col)
|
||||
}
|
||||
|
||||
// seedFreshCache writes a cacheRecord with `now` as timestamp so the first
|
||||
// GetEventsCached call returns it without hitting the network.
|
||||
// seedFreshCache writes a cacheRecord with `now` as timestamp so
|
||||
// GetEventsWithFallback can serve it when the test upstream fails.
|
||||
func seedFreshCache(t *testing.T, cache CacheStore, events []ScheduleEvent) {
|
||||
t.Helper()
|
||||
from := ictDayStartOf(fixedNow())
|
||||
@@ -84,11 +86,15 @@ func seedFreshCache(t *testing.T, cache CacheStore, events []ScheduleEvent) {
|
||||
func newTestState(t *testing.T) *state {
|
||||
t.Helper()
|
||||
subs, pd, cache := newTestStore(t)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
return &state{
|
||||
subscribers: subs,
|
||||
pushDate: pd,
|
||||
cache: cache,
|
||||
client: &Client{}, // zero value; tests must seed cache to avoid HTTP
|
||||
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
|
||||
nowFn: fixedNow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package lol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +47,8 @@ func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.U
|
||||
if !parsed.OK {
|
||||
return chathelper.Reply(ctx, b, msg, parsed.Error)
|
||||
}
|
||||
return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false)
|
||||
useFallback := strings.TrimSpace(arg) == ""
|
||||
return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false, useFallback)
|
||||
}
|
||||
|
||||
// handleWeek is /lol_this_week — the current ICT calendar week
|
||||
@@ -57,13 +59,21 @@ func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Updat
|
||||
return nil
|
||||
}
|
||||
from := ictWeekStartOf(s.now())
|
||||
return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true)
|
||||
return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true)
|
||||
}
|
||||
|
||||
// replyForRange fetches + filters + renders a date window. week=true uses
|
||||
// RenderWeek; false uses RenderToday.
|
||||
func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week bool) error {
|
||||
events, err := s.client.GetEventsCached(ctx, s.cache, from, to)
|
||||
func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week, useFallback bool) error {
|
||||
var (
|
||||
events []ScheduleEvent
|
||||
err error
|
||||
)
|
||||
if useFallback {
|
||||
events, err = s.client.GetEventsWithFallback(ctx, s.cache, from, to)
|
||||
} else {
|
||||
events, err = s.client.GetEventsLive(ctx, from, to)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("lol_fetch_fail", "err", err, "from", from, "to", to)
|
||||
hint := "Could not fetch matches. Try again later."
|
||||
|
||||
@@ -223,3 +223,55 @@ func TestHandleSchedule_UpstreamFailureGivesFriendlyError(t *testing.T) {
|
||||
t.Errorf("expected friendly fetch-error reply; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSchedule_CustomDateDoesNotUseFallbackCache(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
col := storage.NewMemoryProvider().Collection("lol")
|
||||
cache := storage.Typed[cacheRecord](col)
|
||||
s := &state{
|
||||
subscribers: storage.Typed[subscribersDoc](col),
|
||||
pushDate: storage.Typed[lastPushDoc](col),
|
||||
cache: cache,
|
||||
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
|
||||
nowFn: func() time.Time { return time.UnixMilli(fakeNowMs).UTC() },
|
||||
}
|
||||
from := ictDayStartOf(s.now())
|
||||
to := addDays(from, 1)
|
||||
if err := cache.Put(context.Background(), cacheKey(from, to), cacheRecord{
|
||||
Ts: time.Now().UTC().UnixMilli(),
|
||||
Events: []ScheduleEvent{{
|
||||
StartTime: "2026-05-09T05:00:00Z",
|
||||
State: "unstarted",
|
||||
League: League{Name: "LCK", Slug: "lck"},
|
||||
Match: Match{
|
||||
Teams: []Team{{Code: "T1"}, {Code: "GEN"}},
|
||||
Strategy: Strategy{Count: 3},
|
||||
},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed cache: %v", err)
|
||||
}
|
||||
|
||||
cmd := modules.Command{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "lol", Commands: []modules.Command{cmd}}},
|
||||
AllCommands: map[string]modules.Command{cmd.Name: cmd},
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "T1 vs GEN") {
|
||||
t.Fatalf("default /lol should use stale fallback; got %q", got)
|
||||
}
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol 09-05-2026"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Could not fetch") {
|
||||
t.Fatalf("custom-date /lol should not use stale fallback; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
matchCacheTTLIndexName = "lol_match_cache_ttl"
|
||||
matchCacheTTLField = "updatedAt"
|
||||
matchCacheKeyPrefix = "matches:"
|
||||
matchCacheKeyUpper = "matches;"
|
||||
matchCacheTTL = 30 * 24 * time.Hour
|
||||
@@ -24,6 +25,9 @@ const (
|
||||
// no-op.
|
||||
func InitStore(ctx context.Context, lolColl storage.Collection) error {
|
||||
if mongoColl, ok := storage.MongoCollection(lolColl); ok {
|
||||
if err := deleteMatchCacheDocs(ctx, mongoColl); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureMatchCacheTTLIndex(ctx, mongoColl); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -32,8 +36,11 @@ func InitStore(ctx context.Context, lolColl storage.Collection) error {
|
||||
}
|
||||
|
||||
func ensureMatchCacheTTLIndex(ctx context.Context, coll *mongo.Collection) error {
|
||||
if err := dropConflictingMatchCacheTTLIndex(ctx, coll); err != nil {
|
||||
return err
|
||||
}
|
||||
model := mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "fetchedAt", Value: 1}},
|
||||
Keys: bson.D{{Key: matchCacheTTLField, Value: 1}},
|
||||
Options: options.Index().
|
||||
SetName(matchCacheTTLIndexName).
|
||||
SetExpireAfterSeconds(int32(matchCacheTTL / time.Second)).
|
||||
@@ -45,9 +52,102 @@ func ensureMatchCacheTTLIndex(ctx context.Context, coll *mongo.Collection) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropConflictingMatchCacheTTLIndex(ctx context.Context, coll *mongo.Collection) error {
|
||||
cur, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list lol indexes: %w", err)
|
||||
}
|
||||
defer func() { _ = cur.Close(ctx) }()
|
||||
|
||||
for cur.Next(ctx) {
|
||||
var doc bson.M
|
||||
if err := cur.Decode(&doc); err != nil {
|
||||
return fmt.Errorf("decode lol index: %w", err)
|
||||
}
|
||||
if doc["name"] != matchCacheTTLIndexName {
|
||||
continue
|
||||
}
|
||||
if matchCacheTTLIndexMatches(doc) {
|
||||
return nil
|
||||
}
|
||||
if err := coll.Indexes().DropOne(ctx, matchCacheTTLIndexName); err != nil {
|
||||
return fmt.Errorf("drop old lol match cache ttl index: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return fmt.Errorf("iterate lol indexes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchCacheTTLIndexMatches(doc bson.M) bool {
|
||||
if !singleAscendingKey(doc["key"], matchCacheTTLField) {
|
||||
return false
|
||||
}
|
||||
if got, ok := int64BSON(doc["expireAfterSeconds"]); !ok || got != int64(matchCacheTTL/time.Second) {
|
||||
return false
|
||||
}
|
||||
partial, ok := bsonDocument(doc["partialFilterExpression"])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
idFilter, ok := bsonDocument(partial["_id"])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return idFilter["$gte"] == matchCacheKeyPrefix && idFilter["$lt"] == matchCacheKeyUpper
|
||||
}
|
||||
|
||||
func deleteMatchCacheDocs(ctx context.Context, coll *mongo.Collection) error {
|
||||
if _, err := coll.DeleteMany(ctx, bson.D{{Key: "_id", Value: matchCacheIDRange()}}); err != nil {
|
||||
return fmt.Errorf("delete old lol match cache docs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchCacheIDRange() bson.D {
|
||||
return bson.D{
|
||||
{Key: "$gte", Value: matchCacheKeyPrefix},
|
||||
{Key: "$lt", Value: matchCacheKeyUpper},
|
||||
}
|
||||
}
|
||||
|
||||
func singleAscendingKey(v any, field string) bool {
|
||||
doc, ok := bsonDocument(v)
|
||||
if !ok || len(doc) != 1 {
|
||||
return false
|
||||
}
|
||||
got, ok := int64BSON(doc[field])
|
||||
return ok && got == 1
|
||||
}
|
||||
|
||||
func bsonDocument(v any) (bson.M, bool) {
|
||||
switch d := v.(type) {
|
||||
case bson.M:
|
||||
return d, true
|
||||
case map[string]any:
|
||||
return bson.M(d), true
|
||||
case bson.D:
|
||||
out := bson.M{}
|
||||
for _, e := range d {
|
||||
out[e.Key] = e.Value
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func int64BSON(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n), true
|
||||
case int32:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@ package lol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
|
||||
const legacyFetchedAtField = "fetchedAt"
|
||||
|
||||
uri := os.Getenv("MONGODB_TEST_URL")
|
||||
if uri == "" {
|
||||
t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test")
|
||||
@@ -41,9 +46,47 @@ func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
|
||||
t.Fatal("lol collection is not Mongo-backed")
|
||||
}
|
||||
|
||||
legacyFrom := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
|
||||
legacyTo := legacyFrom.Add(24 * time.Hour)
|
||||
legacyMatchID := cacheKey(legacyFrom, legacyTo)
|
||||
legacyFetchedAt := time.Date(2026, 6, 1, 2, 3, 4, 0, time.UTC)
|
||||
_, err = rawLolColl.Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: legacyFetchedAtField, Value: 1}},
|
||||
Options: options.Index().
|
||||
SetName(matchCacheTTLIndexName).
|
||||
SetExpireAfterSeconds(int32(matchCacheTTL / time.Second)).
|
||||
SetPartialFilterExpression(bson.D{{Key: "_id", Value: matchCacheIDRange()}}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy ttl index: %v", err)
|
||||
}
|
||||
_, err = rawLolColl.InsertMany(ctx, []any{
|
||||
bson.D{
|
||||
{Key: "_id", Value: legacyMatchID},
|
||||
{Key: "version", Value: int64(1)},
|
||||
{Key: "updatedAt", Value: legacyFetchedAt},
|
||||
{Key: "ts", Value: legacyFetchedAt.UnixMilli()},
|
||||
{Key: legacyFetchedAtField, Value: legacyFetchedAt},
|
||||
{Key: "events", Value: bson.A{}},
|
||||
},
|
||||
bson.D{
|
||||
{Key: "_id", Value: "subscribers"},
|
||||
{Key: "version", Value: int64(1)},
|
||||
{Key: "updatedAt", Value: legacyFetchedAt},
|
||||
{Key: legacyFetchedAtField, Value: legacyFetchedAt},
|
||||
{Key: "subscribers", Value: bson.A{}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert legacy docs: %v", err)
|
||||
}
|
||||
|
||||
if err := InitStore(ctx, lolColl); err != nil {
|
||||
t.Fatalf("InitStore: %v", err)
|
||||
}
|
||||
if err := InitStore(ctx, lolColl); err != nil {
|
||||
t.Fatalf("InitStore second run: %v", err)
|
||||
}
|
||||
|
||||
cur, err := rawLolColl.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
@@ -76,8 +119,8 @@ func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("index key is not a document: %#v", found["key"])
|
||||
}
|
||||
if got, _ := int64FromBSON(keyDoc["fetchedAt"]); got != 1 {
|
||||
t.Fatalf("index key fetchedAt = %v, want 1", keyDoc["fetchedAt"])
|
||||
if got, _ := int64FromBSON(keyDoc[matchCacheTTLField]); got != 1 {
|
||||
t.Fatalf("index key %s = %v, want 1", matchCacheTTLField, keyDoc[matchCacheTTLField])
|
||||
}
|
||||
|
||||
partial, ok := bsonDoc(found["partialFilterExpression"])
|
||||
@@ -91,6 +134,16 @@ func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
|
||||
if idFilter["$gte"] != matchCacheKeyPrefix || idFilter["$lt"] != matchCacheKeyUpper {
|
||||
t.Fatalf("partial _id filter = %#v, want [%q, %q)", idFilter, matchCacheKeyPrefix, matchCacheKeyUpper)
|
||||
}
|
||||
|
||||
var matchDoc bson.M
|
||||
err = rawLolColl.FindOne(ctx, bson.M{"_id": legacyMatchID}).Decode(&matchDoc)
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
t.Fatalf("legacy match doc lookup err = %v, want ErrNoDocuments; doc=%#v", err, matchDoc)
|
||||
}
|
||||
subscriberDoc := rawLolDoc(t, ctx, rawLolColl, "subscribers")
|
||||
if _, ok := subscriberDoc[legacyFetchedAtField]; !ok {
|
||||
t.Fatalf("non-match doc was deleted or changed unexpectedly: %#v", subscriberDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func bsonDoc(v any) (bson.M, bool) {
|
||||
@@ -122,3 +175,12 @@ func int64FromBSON(v any) (int64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func rawLolDoc(t *testing.T, ctx context.Context, coll *mongo.Collection, id string) bson.M {
|
||||
t.Helper()
|
||||
var doc bson.M
|
||||
if err := coll.FindOne(ctx, bson.M{"_id": id}).Decode(&doc); err != nil {
|
||||
t.Fatalf("find raw lol doc %s: %v", id, err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user