From 275c83b8a674157ec6cb258bd31f033ebea4e694 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 1 Jul 2026 16:41:59 +0700 Subject: [PATCH] feat(lol): make match cache fallback-only --- internal/modules/lol/api_client.go | 40 ++++---- internal/modules/lol/api_client_test.go | 45 +++++---- internal/modules/lol/cron.go | 2 +- internal/modules/lol/cron_test.go | 12 ++- internal/modules/lol/handlers.go | 18 +++- internal/modules/lol/handlers_test.go | 52 +++++++++++ internal/modules/lol/startup.go | 102 ++++++++++++++++++++- internal/modules/lol/startup_mongo_test.go | 66 ++++++++++++- 8 files changed, 287 insertions(+), 50 deletions(-) diff --git a/internal/modules/lol/api_client.go b/internal/modules/lol/api_client.go index 0822d5e..44379ea 100644 --- a/internal/modules/lol/api_client.go +++ b/internal/modules/lol/api_client.go @@ -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 } diff --git a/internal/modules/lol/api_client_test.go b/internal/modules/lol/api_client_test.go index efcc6d9..ddfcd2e 100644 --- a/internal/modules/lol/api_client_test.go +++ b/internal/modules/lol/api_client_test.go @@ -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} diff --git a/internal/modules/lol/cron.go b/internal/modules/lol/cron.go index 970fe28..b3f9530 100644 --- a/internal/modules/lol/cron.go +++ b/internal/modules/lol/cron.go @@ -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) } diff --git a/internal/modules/lol/cron_test.go b/internal/modules/lol/cron_test.go index 07c7ac2..25545f0 100644 --- a/internal/modules/lol/cron_test.go +++ b/internal/modules/lol/cron_test.go @@ -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, } } diff --git a/internal/modules/lol/handlers.go b/internal/modules/lol/handlers.go index 52d134e..ee92636 100644 --- a/internal/modules/lol/handlers.go +++ b/internal/modules/lol/handlers.go @@ -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." diff --git a/internal/modules/lol/handlers_test.go b/internal/modules/lol/handlers_test.go index 49397b6..1c12ece 100644 --- a/internal/modules/lol/handlers_test.go +++ b/internal/modules/lol/handlers_test.go @@ -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) + } +} diff --git a/internal/modules/lol/startup.go b/internal/modules/lol/startup.go index 88d3cf6..0351da4 100644 --- a/internal/modules/lol/startup.go +++ b/internal/modules/lol/startup.go @@ -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 + } +} diff --git a/internal/modules/lol/startup_mongo_test.go b/internal/modules/lol/startup_mongo_test.go index d5a6197..3bde778 100644 --- a/internal/modules/lol/startup_mongo_test.go +++ b/internal/modules/lol/startup_mongo_test.go @@ -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 +}