diff --git a/AGENTS.md b/AGENTS.md index 2773f5b..c7eb881 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,10 @@ Startup migrations should be safe to run every boot: - use `internal/systemstate` records in the shared `system` collection for one-time migrations - write tests for migration idempotency and legacy data handling +- after production data is verified migrated and the owner approves cleanup, + remove completed one-time migration runtime code and migration-only tests; + keep historical `system` marker records and legacy data unless the owner + explicitly asks to delete them ## Git diff --git a/README.md b/README.md index 35ef1a0..e4a2e2d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ internal/telegram/ Telegram long-polling bot wrapper internal/cron/ in-process cron scheduler internal/modules/ Module framework, registry, dispatchers, modules internal/storage/ typed DocStore[T] (Provider + Typed); mongodb runtime + memory (tests). Values persist as flattened native BSON root documents -internal/systemstate/ shared `system` collection metadata for startup migrations +internal/systemstate/ shared `system` collection helper for future startup migrations compose.yml Coolify self-host stack (single bot service) telegram-commands.json Manual Telegram command menu source docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide diff --git a/cmd/server/main.go b/cmd/server/main.go index 46105ad..8cce720 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,7 +29,6 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/wordle" "github.com/tiennm99/miti99bot/internal/server" "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/systemstate" "github.com/tiennm99/miti99bot/internal/telegram" ) @@ -94,12 +93,8 @@ func main() { log.Fatal("storage init failed", "err", err) } defer closeProvider() - systemColl := provider.Collection(systemstate.CollectionName) - if err := lol.InitStore(rootCtx, provider, systemColl); err != nil { - log.Fatal("lol storage init failed", "err", err) - } - if err := stats.InitStore(rootCtx, provider.Collection("stats"), systemColl); err != nil { + if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil { log.Fatal("stats storage init failed", "err", err) } diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index e281ae2..ba8ef2b 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -60,8 +60,7 @@ overrides are not supported in runtime env; modules use coded defaults. 4. Copy the `mongodb+srv://…` connection string into `MONGO_URL` and put the db name in `MONGO_DATABASE`. -> Storage layout: one collection per module plus a shared `system` collection -> for startup metadata such as one-time migrations. Each document is a flattened native +> Storage layout: one collection per module. Each document is a flattened native > document — `{ _id: , ...payload fields, version, updatedAt }` with no > `value` envelope. Payload fields are hoisted to the document root so they > expand and are queryable in Compass. The two non-object values are wrapped in a @@ -69,17 +68,11 @@ overrides are not supported in runtime env; modules use coded defaults. > daily push date under `date`. Concurrency uses the `version` field (optimistic lock); > `updatedAt` is a BSON Date. > -> The `lol` module uses the `lol` collection. First startup after the rename -> copies documents from the legacy `lolschedule` collection, drops that legacy -> collection, and records completion in `system`. -> > The `stats` collection uses queryable aggregate documents for command/user -> counts and creates indexes on startup. First startup after the schema change -> migrates legacy `count:`, `user:`, and `pair:` stats keys into the new shape, -> deletes the legacy keys, and records completion in `system`. Startup also -> migrates renamed command stats to the current command names and marks removed -> command rows with `deleted: true`; `/stats` queries filter those retained -> legacy rows. +> counts and creates indexes on startup. Deleted legacy command rows are retained +> with `deleted: true`; `/stats` queries filter those rows from visible results. +> A historical `system` collection may remain in MongoDB with completed migration +> records and can be reused if a future one-time startup migration is needed. ## 2. Coolify diff --git a/internal/modules/lol/lol.go b/internal/modules/lol/lol.go index 2c6de77..bcd28de 100644 --- a/internal/modules/lol/lol.go +++ b/internal/modules/lol/lol.go @@ -5,6 +5,9 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) +// CollectionName is the MongoDB collection/module key used by the registry. +const CollectionName = "lol" + // New is the lol module Factory. The 4 user-facing commands plus the // daily-push cron (lol_daily_push at 08:00 ICT, fan-out to // subscribers) are wired here. The cron handler reads deps.Bot at invoke diff --git a/internal/modules/lol/startup.go b/internal/modules/lol/startup.go deleted file mode 100644 index cb29cef..0000000 --- a/internal/modules/lol/startup.go +++ /dev/null @@ -1,97 +0,0 @@ -package lol - -import ( - "context" - "fmt" - "time" - - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo/options" - - "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/systemstate" -) - -const ( - // CollectionName is the current MongoDB collection/module key. - CollectionName = "lol" - - legacyCollectionName = "lolschedule" - - collectionMigrationName = "lol-collection-v1" - collectionMigrationKey = "migration:" + collectionMigrationName -) - -// InitStore performs lol collection startup maintenance. It is safe to call on -// every boot: the collection rename migration is guarded by the system -// collection and only applies to Mongo-backed storage. -func InitStore(ctx context.Context, provider storage.Provider, systemColl storage.Collection) error { - oldColl, okOld := storage.MongoCollection(provider.Collection(legacyCollectionName)) - newColl, okNew := storage.MongoCollection(provider.Collection(CollectionName)) - if !okOld || !okNew { - return nil - } - - sys := systemstate.New(systemColl) - if rec, ok, err := sys.Get(ctx, collectionMigrationKey); err != nil { - return fmt.Errorf("lol collection migration marker get: %w", err) - } else if ok && rec.Status == "done" { - return nil - } - - count, err := oldColl.CountDocuments(ctx, bson.D{}) - if err != nil { - return fmt.Errorf("lol legacy collection count: %w", err) - } - if count == 0 { - if err := oldColl.Drop(ctx); err != nil { - return fmt.Errorf("lol legacy collection drop: %w", err) - } - return markCollectionMigrationDone(ctx, sys, 0) - } - - cur, err := oldColl.Find(ctx, bson.D{}) - if err != nil { - return fmt.Errorf("lol legacy collection find: %w", err) - } - defer func() { _ = cur.Close(ctx) }() - - copied := int64(0) - for cur.Next(ctx) { - var doc bson.M - if err := cur.Decode(&doc); err != nil { - return fmt.Errorf("lol legacy collection decode: %w", err) - } - id, ok := doc["_id"] - if !ok { - return fmt.Errorf("lol legacy collection document missing _id") - } - if _, err := newColl.ReplaceOne(ctx, bson.M{"_id": id}, doc, options.Replace().SetUpsert(true)); err != nil { - return fmt.Errorf("lol collection copy %v: %w", id, err) - } - copied++ - } - if err := cur.Err(); err != nil { - return fmt.Errorf("lol legacy collection cursor: %w", err) - } - - if err := oldColl.Drop(ctx); err != nil { - return fmt.Errorf("lol legacy collection drop: %w", err) - } - return markCollectionMigrationDone(ctx, sys, copied) -} - -func markCollectionMigrationDone(ctx context.Context, sys systemstate.Store, count int64) error { - now := time.Now().UnixMilli() - if err := sys.Put(ctx, collectionMigrationKey, systemstate.Record{ - Kind: "migration", - Name: collectionMigrationName, - Status: "done", - Count: count, - CompletedAt: now, - UpdatedAt: now, - }); err != nil { - return fmt.Errorf("lol collection migration marker put: %w", err) - } - return nil -} diff --git a/internal/modules/lol/startup_mongo_test.go b/internal/modules/lol/startup_mongo_test.go deleted file mode 100644 index c26242f..0000000 --- a/internal/modules/lol/startup_mongo_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package lol - -import ( - "context" - "fmt" - "os" - "testing" - "time" - - "go.mongodb.org/mongo-driver/v2/bson" - - "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/systemstate" -) - -func TestInitStore_MongoMigratesLegacyCollection(t *testing.T) { - uri := os.Getenv("MONGODB_TEST_URL") - if uri == "" { - t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test") - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - client, err := storage.NewMongoClient(ctx, uri) - if err != nil { - t.Fatalf("NewMongoClient: %v", err) - } - dbName := fmt.Sprintf("miti99bot_lol_test_%d", time.Now().UnixNano()) - db := client.Database(dbName) - defer func() { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = db.Drop(cleanupCtx) - _ = client.Disconnect(cleanupCtx) - }() - - provider := storage.NewMongoProvider(db) - legacyColl := provider.Collection(legacyCollectionName) - systemColl := provider.Collection(systemstate.CollectionName) - legacySubscribers := storage.Typed[subscribersDoc](legacyColl) - legacyPushDate := storage.Typed[lastPushDoc](legacyColl) - legacyCache := storage.Typed[cacheRecord](legacyColl) - - if err := legacySubscribers.Put(ctx, subscribersKey, subscribersDoc{Subscribers: []Subscriber{{ChatID: 7}, {ChatID: 8, ThreadID: 3}}}); err != nil { - t.Fatalf("legacy subscribers: %v", err) - } - if err := legacyPushDate.Put(ctx, lastPushDateKey, lastPushDoc{Date: "2026-07-01"}); err != nil { - t.Fatalf("legacy push date: %v", err) - } - if err := legacyCache.Put(ctx, "matches:from:to", cacheRecord{ - Ts: 123, - Events: []ScheduleEvent{{StartTime: "2026-07-01T01:00:00Z", League: League{Slug: "lck"}}}, - }); err != nil { - t.Fatalf("legacy cache: %v", err) - } - - if err := InitStore(ctx, provider, systemColl); err != nil { - t.Fatalf("InitStore: %v", err) - } - - newColl := provider.Collection(CollectionName) - newSubscribers := storage.Typed[subscribersDoc](newColl) - newPushDate := storage.Typed[lastPushDoc](newColl) - newCache := storage.Typed[cacheRecord](newColl) - - subs, _, err := newSubscribers.Get(ctx, subscribersKey) - if err != nil { - t.Fatalf("new subscribers get: %v", err) - } - if len(subs.Subscribers) != 2 || subs.Subscribers[1] != (Subscriber{ChatID: 8, ThreadID: 3}) { - t.Fatalf("new subscribers = %+v", subs.Subscribers) - } - push, _, err := newPushDate.Get(ctx, lastPushDateKey) - if err != nil { - t.Fatalf("new push date get: %v", err) - } - if push.Date != "2026-07-01" { - t.Fatalf("new push date = %q", push.Date) - } - cached, _, err := newCache.Get(ctx, "matches:from:to") - if err != nil { - t.Fatalf("new cache get: %v", err) - } - if cached.Ts != 123 || len(cached.Events) != 1 || cached.Events[0].League.Slug != "lck" { - t.Fatalf("new cache = %+v", cached) - } - - names, err := db.ListCollectionNames(ctx, bson.M{"name": legacyCollectionName}) - if err != nil { - t.Fatalf("ListCollectionNames: %v", err) - } - if len(names) != 0 { - t.Fatalf("legacy collection still exists: %v", names) - } - - rec, ok, err := systemstate.New(systemColl).Get(ctx, collectionMigrationKey) - if err != nil { - t.Fatalf("migration marker get: %v", err) - } - if !ok || rec.Status != "done" || rec.Name != collectionMigrationName || rec.Count != 3 { - t.Fatalf("migration marker = %+v ok=%v", rec, ok) - } - - if err := InitStore(ctx, provider, systemColl); err != nil { - t.Fatalf("second InitStore: %v", err) - } -} diff --git a/internal/modules/stats/startup.go b/internal/modules/stats/startup.go index 35ea8ec..05049cb 100644 --- a/internal/modules/stats/startup.go +++ b/internal/modules/stats/startup.go @@ -2,87 +2,25 @@ package stats import ( "context" - "errors" "fmt" - "strconv" - "strings" - "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" - "github.com/tiennm99/miti99bot/internal/systemstate" ) -const ( - legacyCountPrefix = "count:" - legacyUserPrefix = "user:" - legacyPairPrefix = "pair:" - - usageMigrationName = "stats-usage-v2" - usageMigrationKey = "migration:" + usageMigrationName - - commandHistoryMigrationName = "stats-command-history-v2" - commandHistoryMigrationKey = "migration:" + commandHistoryMigrationName -) - -type commandRename struct { - Old string - New string -} - -var commandRenames = []commandRename{ - {Old: "trade_topup", New: "stock_topup"}, - {Old: "trade_buy", New: "stock_buy"}, - {Old: "trade_sell", New: "stock_sell"}, - {Old: "trade_income_stock", New: "stock_bonus"}, - {Old: "trade_income_vnd", New: "stock_dividend"}, - {Old: "trade_stats", New: "stock_portfolio"}, - {Old: "lolschedule", New: "lol"}, - {Old: "lolschedule_week", New: "lol_this_week"}, - {Old: "lolschedule_subscribe", New: "lol_subscribe"}, - {Old: "lolschedule_unsubscribe", New: "lol_unsubscribe"}, - {Old: "wc_week", New: "wc_this_week"}, - {Old: "gold_stats", New: "gold_portfolio"}, - {Old: "coin_stats", New: "coin_portfolio"}, - {Old: "stock_stats", New: "stock_portfolio"}, - {Old: "stock_income_stock", New: "stock_bonus"}, - {Old: "stock_income_vnd", New: "stock_dividend"}, -} - -var deletedCommandNames = []string{ - "lolschedule_today", - "trade_income_events", - "trade_convert", - "stock_income_events", - "wc_today", - "stock_convert", -} - -type legacyCountEntry struct { - N int64 `json:"n" bson:"n"` -} - -type legacyUserEntry struct { - Username string `json:"username" bson:"username"` - N int64 `json:"n" bson:"n"` -} - // InitStore performs stats collection startup maintenance. It is safe to call -// every boot: indexes are idempotent and legacy migration is guarded by a -// system collection marker. -func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error { +// every boot: MongoDB indexes are created idempotently and memory storage is a +// no-op. +func InitStore(ctx context.Context, statsColl storage.Collection) error { if mongoColl, ok := storage.MongoCollection(statsColl); ok { if err := ensureUsageIndexes(ctx, mongoColl); err != nil { return err } } - if err := migrateLegacyUsage(ctx, statsColl, systemColl); err != nil { - return err - } - return migrateCommandHistory(ctx, statsColl, systemColl) + return nil } func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error { @@ -105,275 +43,3 @@ func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error { } return nil } - -func migrateLegacyUsage(ctx context.Context, statsColl, systemColl storage.Collection) error { - sys := systemstate.New(systemColl) - if rec, ok, err := sys.Get(ctx, usageMigrationKey); err != nil { - return fmt.Errorf("stats legacy migration marker get: %w", err) - } else if ok && rec.Status == "done" { - return nil - } - - legacyCounts := storage.Typed[legacyCountEntry](statsColl) - legacyUsers := storage.Typed[legacyUserEntry](statsColl) - usageDocs := storage.Typed[usageEntry](statsColl) - - usernames, userKeys, err := loadLegacyUsernames(ctx, legacyUsers) - if err != nil { - return err - } - countTotals, countKeys, err := loadLegacyCounts(ctx, legacyCounts) - if err != nil { - return err - } - pairs, pairKeys, pairSums, err := loadLegacyPairs(ctx, legacyCounts, usernames) - if err != nil { - return err - } - - written := int64(0) - for _, entry := range pairs { - if err := usageDocs.Put(ctx, usageKey(entry.Cmd, entry.UserID), entry); err != nil { - return fmt.Errorf("stats legacy pair put %s:%d: %w", entry.Cmd, entry.UserID, err) - } - written++ - } - - for cmd, total := range countTotals { - anonymous := total - pairSums[cmd] - if anonymous <= 0 { - continue - } - if err := usageDocs.Put(ctx, usageKey(cmd, 0), usageEntry{Cmd: cmd, N: anonymous}); err != nil { - return fmt.Errorf("stats legacy anonymous put %s: %w", cmd, err) - } - written++ - } - - for _, key := range append(append(countKeys, pairKeys...), userKeys...) { - if err := legacyCounts.Delete(ctx, key); err != nil { - return fmt.Errorf("stats legacy delete %s: %w", key, err) - } - } - - now := nowMillis() - if err := sys.Put(ctx, usageMigrationKey, systemstate.Record{ - Kind: "migration", - Name: usageMigrationName, - Status: "done", - Count: written, - CompletedAt: now, - UpdatedAt: now, - }); err != nil { - return fmt.Errorf("stats legacy migration marker put: %w", err) - } - return nil -} - -func migrateCommandHistory(ctx context.Context, statsColl, systemColl storage.Collection) error { - sys := systemstate.New(systemColl) - if rec, ok, err := sys.Get(ctx, commandHistoryMigrationKey); err != nil { - return fmt.Errorf("stats command history migration marker get: %w", err) - } else if ok && rec.Status == "done" { - return nil - } - - docs := storage.Typed[usageEntry](statsColl) - changed := int64(0) - for _, rename := range commandRenames { - n, err := migrateCommandRename(ctx, docs, rename) - if err != nil { - return err - } - changed += n - } - for _, cmd := range deletedCommandNames { - n, err := markCommandDeleted(ctx, docs, cmd) - if err != nil { - return err - } - changed += n - } - - now := nowMillis() - if err := sys.Put(ctx, commandHistoryMigrationKey, systemstate.Record{ - Kind: "migration", - Name: commandHistoryMigrationName, - Status: "done", - Count: changed, - CompletedAt: now, - UpdatedAt: now, - }); err != nil { - return fmt.Errorf("stats command history migration marker put: %w", err) - } - return nil -} - -func migrateCommandRename(ctx context.Context, docs storage.DocStore[usageEntry], rename commandRename) (int64, error) { - keys, err := docs.List(ctx, rename.Old) - if err != nil { - return 0, fmt.Errorf("stats command rename list %s: %w", rename.Old, err) - } - changed := int64(0) - for _, key := range keys { - if !usageKeyBelongsToCommand(key, rename.Old) { - continue - } - entry, _, err := docs.Get(ctx, key) - if err != nil { - return changed, fmt.Errorf("stats command rename get %s: %w", key, err) - } - if entry.Cmd != "" && entry.Cmd != rename.Old { - continue - } - entry.Cmd = rename.Old - targetKey := usageKey(rename.New, entry.UserID) - target, _, err := docs.Get(ctx, targetKey) - switch { - case errors.Is(err, storage.ErrNotFound): - target = usageEntry{ - Cmd: rename.New, - UserID: entry.UserID, - Username: entry.Username, - } - case err != nil: - return changed, fmt.Errorf("stats command rename target get %s: %w", targetKey, err) - } - - target.Cmd = rename.New - target.UserID = entry.UserID - if entry.UserID == 0 { - target.Username = "" - } else if entry.Username != "" { - target.Username = entry.Username - } - target.N += entry.N - target.Deleted = false - if err := docs.Put(ctx, targetKey, target); err != nil { - return changed, fmt.Errorf("stats command rename put %s: %w", targetKey, err) - } - if err := docs.Delete(ctx, key); err != nil { - return changed, fmt.Errorf("stats command rename delete %s: %w", key, err) - } - changed++ - } - return changed, nil -} - -func markCommandDeleted(ctx context.Context, docs storage.DocStore[usageEntry], cmd string) (int64, error) { - keys, err := docs.List(ctx, cmd) - if err != nil { - return 0, fmt.Errorf("stats command delete list %s: %w", cmd, err) - } - changed := int64(0) - for _, key := range keys { - if !usageKeyBelongsToCommand(key, cmd) { - continue - } - entry, _, err := docs.Get(ctx, key) - if err != nil { - return changed, fmt.Errorf("stats command delete get %s: %w", key, err) - } - if entry.Cmd != "" && entry.Cmd != cmd { - continue - } - entry.Cmd = cmd - if entry.Deleted { - continue - } - entry.Deleted = true - if err := docs.Put(ctx, key, entry); err != nil { - return changed, fmt.Errorf("stats command delete put %s: %w", key, err) - } - changed++ - } - return changed, nil -} - -func usageKeyBelongsToCommand(key, cmd string) bool { - return key == cmd || strings.HasPrefix(key, cmd+":") -} - -func loadLegacyUsernames(ctx context.Context, users storage.DocStore[legacyUserEntry]) (map[int64]string, []string, error) { - keys, err := users.List(ctx, legacyUserPrefix) - if err != nil { - return nil, nil, fmt.Errorf("stats legacy users list: %w", err) - } - usernames := make(map[int64]string, len(keys)) - for _, key := range keys { - id, err := strconv.ParseInt(strings.TrimPrefix(key, legacyUserPrefix), 10, 64) - if err != nil { - continue - } - entry, _, err := users.Get(ctx, key) - if err != nil { - return nil, nil, fmt.Errorf("stats legacy user get %s: %w", key, err) - } - usernames[id] = entry.Username - } - return usernames, keys, nil -} - -func loadLegacyCounts(ctx context.Context, counts storage.DocStore[legacyCountEntry]) (map[string]int64, []string, error) { - keys, err := counts.List(ctx, legacyCountPrefix) - if err != nil { - return nil, nil, fmt.Errorf("stats legacy counts list: %w", err) - } - totals := make(map[string]int64, len(keys)) - for _, key := range keys { - cmd := strings.TrimPrefix(key, legacyCountPrefix) - if cmd == "" { - continue - } - entry, _, err := counts.Get(ctx, key) - if err != nil { - return nil, nil, fmt.Errorf("stats legacy count get %s: %w", key, err) - } - totals[cmd] = entry.N - } - return totals, keys, nil -} - -func loadLegacyPairs(ctx context.Context, counts storage.DocStore[legacyCountEntry], usernames map[int64]string) ([]usageEntry, []string, map[string]int64, error) { - keys, err := counts.List(ctx, legacyPairPrefix) - if err != nil { - return nil, nil, nil, fmt.Errorf("stats legacy pairs list: %w", err) - } - pairs := make([]usageEntry, 0, len(keys)) - pairSums := make(map[string]int64) - for _, key := range keys { - cmd, userID, ok := parseLegacyPairKey(key) - if !ok { - continue - } - entry, _, err := counts.Get(ctx, key) - if err != nil { - return nil, nil, nil, fmt.Errorf("stats legacy pair get %s: %w", key, err) - } - pairs = append(pairs, usageEntry{ - Cmd: cmd, - UserID: userID, - Username: usernames[userID], - N: entry.N, - }) - pairSums[cmd] += entry.N - } - return pairs, keys, pairSums, nil -} - -func parseLegacyPairKey(key string) (string, int64, bool) { - rest := strings.TrimPrefix(key, legacyPairPrefix) - idx := strings.LastIndexByte(rest, ':') - if idx <= 0 || idx == len(rest)-1 { - return "", 0, false - } - id, err := strconv.ParseInt(rest[idx+1:], 10, 64) - if err != nil { - return "", 0, false - } - return rest[:idx], id, true -} - -func nowMillis() int64 { - return time.Now().UnixMilli() -} diff --git a/internal/modules/stats/startup_mongo_test.go b/internal/modules/stats/startup_mongo_test.go index 29223ba..d35cf13 100644 --- a/internal/modules/stats/startup_mongo_test.go +++ b/internal/modules/stats/startup_mongo_test.go @@ -2,20 +2,15 @@ package stats import ( "context" - "errors" "fmt" "os" "testing" "time" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/systemstate" ) -func TestInitStore_MongoCreatesIndexesAndMigratesLegacy(t *testing.T) { +func TestInitStore_MongoCreatesIndexes(t *testing.T) { uri := os.Getenv("MONGODB_TEST_URL") if uri == "" { t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test") @@ -39,49 +34,11 @@ func TestInitStore_MongoCreatesIndexesAndMigratesLegacy(t *testing.T) { provider := storage.NewMongoProvider(db) statsColl := provider.Collection("stats") - systemColl := provider.Collection(systemstate.CollectionName) - legacyCounts := storage.Typed[legacyCountEntry](statsColl) - legacyUsers := storage.Typed[legacyUserEntry](statsColl) - sys := systemstate.New(systemColl) - if err := sys.Put(ctx, "migration:stats-command-history-v1", systemstate.Record{ - Kind: "migration", - Name: "stats-command-history-v1", - Status: "done", - Count: 2, - UpdatedAt: 1, - }); err != nil { - t.Fatalf("seed v1 marker: %v", err) - } - if err := legacyCounts.Put(ctx, legacyCountPrefix+"ping", legacyCountEntry{N: 2}); err != nil { - t.Fatalf("legacy count: %v", err) - } - if err := legacyCounts.Put(ctx, legacyCountPrefix+"gold_stats", legacyCountEntry{N: 4}); err != nil { - t.Fatalf("legacy renamed count: %v", err) - } - if err := legacyCounts.Put(ctx, legacyCountPrefix+"trade_stats", legacyCountEntry{N: 6}); err != nil { - t.Fatalf("legacy trade count: %v", err) - } - if err := legacyUsers.Put(ctx, legacyUserPrefix+"7", legacyUserEntry{Username: "alice", N: 1}); err != nil { - t.Fatalf("legacy user: %v", err) - } - if err := legacyCounts.Put(ctx, legacyPairPrefix+"ping:7", legacyCountEntry{N: 1}); err != nil { - t.Fatalf("legacy pair: %v", err) - } - if err := legacyCounts.Put(ctx, legacyPairPrefix+"stock_convert:7", legacyCountEntry{N: 3}); err != nil { - t.Fatalf("legacy deleted pair: %v", err) - } - if err := InitStore(ctx, statsColl, systemColl); err != nil { + if err := InitStore(ctx, statsColl); err != nil { t.Fatalf("InitStore: %v", err) } - usageDocs := storage.Typed[usageEntry](statsColl) - assertUsageEntry(t, usageDocs, usageKey("ping", 7), usageEntry{Cmd: "ping", UserID: 7, Username: "alice", N: 1}) - assertUsageEntry(t, usageDocs, usageKey("ping", 0), usageEntry{Cmd: "ping", N: 1}) - assertUsageEntry(t, usageDocs, usageKey("gold_portfolio", 0), usageEntry{Cmd: "gold_portfolio", N: 4}) - assertUsageEntry(t, usageDocs, usageKey("stock_portfolio", 0), usageEntry{Cmd: "stock_portfolio", N: 6}) - assertUsageEntry(t, usageDocs, usageKey("stock_convert", 7), usageEntry{Cmd: "stock_convert", UserID: 7, Username: "alice", N: 3, Deleted: true}) - rawStatsColl, ok := storage.MongoCollection(statsColl) if !ok { t.Fatal("stats collection is not Mongo-backed") @@ -110,21 +67,4 @@ func TestInitStore_MongoCreatesIndexesAndMigratesLegacy(t *testing.T) { t.Fatalf("missing index %s; indexes=%v", name, found) } } - - rec, ok, err := sys.Get(ctx, commandHistoryMigrationKey) - if err != nil || !ok { - t.Fatalf("command history marker ok=%v err=%v", ok, err) - } - if rec.Status != "done" || rec.Count != 3 { - t.Fatalf("command history marker = %+v, want done count 3", rec) - } - - rawDoc := bson.M{} - err = rawStatsColl.FindOne(ctx, bson.M{"_id": legacyCountPrefix + "ping"}).Decode(&rawDoc) - if err == nil { - t.Fatalf("legacy count key still exists: %+v", rawDoc) - } - if !errors.Is(err, mongo.ErrNoDocuments) { - t.Fatalf("legacy count lookup err = %v, want ErrNoDocuments", err) - } } diff --git a/internal/modules/stats/stats_test.go b/internal/modules/stats/stats_test.go index 134fb41..f578226 100644 --- a/internal/modules/stats/stats_test.go +++ b/internal/modules/stats/stats_test.go @@ -11,7 +11,6 @@ import ( "github.com/tiennm99/miti99bot/internal/modules" "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/systemstate" "github.com/tiennm99/miti99bot/internal/testutil" ) @@ -238,171 +237,6 @@ func TestCommandHook_FiredThroughModulesBuild(t *testing.T) { } } -func TestInitStore_MigratesLegacyStatsOnceAndDeletesOldKeys(t *testing.T) { - ctx := context.Background() - provider := storage.NewMemoryProvider() - statsColl := provider.Collection("stats") - systemColl := provider.Collection(systemstate.CollectionName) - legacyCounts := storage.Typed[legacyCountEntry](statsColl) - legacyUsers := storage.Typed[legacyUserEntry](statsColl) - - if err := legacyCounts.Put(ctx, legacyCountPrefix+"ping", legacyCountEntry{N: 6}); err != nil { - t.Fatalf("legacy count ping: %v", err) - } - if err := legacyCounts.Put(ctx, legacyCountPrefix+"wordle", legacyCountEntry{N: 2}); err != nil { - t.Fatalf("legacy count wordle: %v", err) - } - if err := legacyUsers.Put(ctx, legacyUserPrefix+"1", legacyUserEntry{Username: "alice", N: 4}); err != nil { - t.Fatalf("legacy user alice: %v", err) - } - if err := legacyUsers.Put(ctx, legacyUserPrefix+"2", legacyUserEntry{Username: "bob", N: 3}); err != nil { - t.Fatalf("legacy user bob: %v", err) - } - if err := legacyCounts.Put(ctx, legacyPairPrefix+"ping:1", legacyCountEntry{N: 3}); err != nil { - t.Fatalf("legacy pair ping alice: %v", err) - } - if err := legacyCounts.Put(ctx, legacyPairPrefix+"ping:2", legacyCountEntry{N: 2}); err != nil { - t.Fatalf("legacy pair ping bob: %v", err) - } - if err := legacyCounts.Put(ctx, legacyPairPrefix+"wordle:2", legacyCountEntry{N: 2}); err != nil { - t.Fatalf("legacy pair wordle bob: %v", err) - } - - if err := InitStore(ctx, statsColl, systemColl); err != nil { - t.Fatalf("InitStore: %v", err) - } - - usageDocs := storage.Typed[usageEntry](statsColl) - assertUsageEntry(t, usageDocs, usageKey("ping", 1), usageEntry{Cmd: "ping", UserID: 1, Username: "alice", N: 3}) - assertUsageEntry(t, usageDocs, usageKey("ping", 2), usageEntry{Cmd: "ping", UserID: 2, Username: "bob", N: 2}) - assertUsageEntry(t, usageDocs, usageKey("wordle", 2), usageEntry{Cmd: "wordle", UserID: 2, Username: "bob", N: 2}) - assertUsageEntry(t, usageDocs, usageKey("ping", 0), usageEntry{Cmd: "ping", N: 1}) - - for _, key := range []string{ - legacyCountPrefix + "ping", - legacyCountPrefix + "wordle", - legacyPairPrefix + "ping:1", - legacyPairPrefix + "ping:2", - legacyPairPrefix + "wordle:2", - legacyUserPrefix + "1", - legacyUserPrefix + "2", - } { - if _, _, err := legacyCounts.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { - t.Fatalf("legacy key %s should be deleted, got err=%v", key, err) - } - } - - sys := systemstate.New(systemColl) - rec, ok, err := sys.Get(ctx, usageMigrationKey) - if err != nil || !ok { - t.Fatalf("migration marker ok=%v err=%v", ok, err) - } - if rec.Status != "done" || rec.Count != 4 { - t.Fatalf("migration marker = %+v, want done count 4", rec) - } - - if err := legacyCounts.Put(ctx, legacyCountPrefix+"coin", legacyCountEntry{N: 99}); err != nil { - t.Fatalf("legacy count after marker: %v", err) - } - if err := InitStore(ctx, statsColl, systemColl); err != nil { - t.Fatalf("InitStore second run: %v", err) - } - if _, _, err := usageDocs.Get(ctx, usageKey("coin", 0)); !errors.Is(err, storage.ErrNotFound) { - t.Fatalf("second InitStore should skip migration after marker, got err=%v", err) - } -} - -func TestInitStore_MigratesCommandHistoryAndMarksDeleted(t *testing.T) { - ctx := context.Background() - provider := storage.NewMemoryProvider() - statsColl := provider.Collection("stats") - systemColl := provider.Collection(systemstate.CollectionName) - usageDocs := storage.Typed[usageEntry](statsColl) - sys := systemstate.New(systemColl) - - if err := sys.Put(ctx, "migration:stats-command-history-v1", systemstate.Record{ - Kind: "migration", - Name: "stats-command-history-v1", - Status: "done", - Count: 5, - UpdatedAt: 1, - }); err != nil { - t.Fatalf("seed v1 marker: %v", err) - } - - seed := map[string]usageEntry{ - usageKey("gold_stats", 0): {Cmd: "gold_stats", N: 2}, - usageKey("gold_portfolio", 0): {Cmd: "gold_portfolio", N: 5}, - usageKey("trade_topup", 0): {Cmd: "trade_topup", N: 6}, - usageKey("trade_stats", 0): {Cmd: "trade_stats", N: 9}, - usageKey("stock_portfolio", 0): {Cmd: "stock_portfolio", N: 1}, - usageKey("trade_income_stock", 7): {Cmd: "trade_income_stock", UserID: 7, Username: "alice", N: 5}, - usageKey("stock_income_stock", 7): {Cmd: "stock_income_stock", UserID: 7, Username: "alice", N: 3}, - usageKey("stock_bonus", 7): {Cmd: "stock_bonus", UserID: 7, Username: "alice", N: 4}, - usageKey("lolschedule_week", 8): {Cmd: "lolschedule_week", UserID: 8, Username: "bob", N: 1}, - usageKey("lolschedule_today", 0): {Cmd: "lolschedule_today", N: 9}, - usageKey("trade_income_events", 8): {Cmd: "trade_income_events", UserID: 8, Username: "bob", N: 3}, - usageKey("trade_convert", 7): {Cmd: "trade_convert", UserID: 7, Username: "alice", N: 4}, - usageKey("stock_income_events", 0): {Cmd: "stock_income_events", N: 5}, - usageKey("stock_convert", 7): {Cmd: "stock_convert", UserID: 7, Username: "alice", N: 2}, - usageKey("trade_stats_extra", 7): {Cmd: "trade_stats_extra", UserID: 7, Username: "alice", N: 99}, - usageKey("stock_convert_extra", 7): {Cmd: "stock_convert_extra", UserID: 7, Username: "alice", N: 99}, - usageKey("lolschedule_weekly", 10): {Cmd: "lolschedule_weekly", UserID: 10, Username: "carol", N: 99}, - usageKey("stock_income_stocked", 11): {Cmd: "stock_income_stocked", UserID: 11, Username: "dan", N: 99}, - } - for key, entry := range seed { - if err := usageDocs.Put(ctx, key, entry); err != nil { - t.Fatalf("seed %s: %v", key, err) - } - } - - if err := InitStore(ctx, statsColl, systemColl); err != nil { - t.Fatalf("InitStore: %v", err) - } - - assertUsageEntry(t, usageDocs, usageKey("gold_portfolio", 0), usageEntry{Cmd: "gold_portfolio", N: 7}) - assertUsageEntry(t, usageDocs, usageKey("stock_topup", 0), usageEntry{Cmd: "stock_topup", N: 6}) - assertUsageEntry(t, usageDocs, usageKey("stock_portfolio", 0), usageEntry{Cmd: "stock_portfolio", N: 10}) - assertUsageEntry(t, usageDocs, usageKey("stock_bonus", 7), usageEntry{Cmd: "stock_bonus", UserID: 7, Username: "alice", N: 12}) - assertUsageEntry(t, usageDocs, usageKey("lol_this_week", 8), usageEntry{Cmd: "lol_this_week", UserID: 8, Username: "bob", N: 1}) - assertUsageEntry(t, usageDocs, usageKey("lolschedule_today", 0), usageEntry{Cmd: "lolschedule_today", N: 9, Deleted: true}) - assertUsageEntry(t, usageDocs, usageKey("trade_income_events", 8), usageEntry{Cmd: "trade_income_events", UserID: 8, Username: "bob", N: 3, Deleted: true}) - assertUsageEntry(t, usageDocs, usageKey("trade_convert", 7), usageEntry{Cmd: "trade_convert", UserID: 7, Username: "alice", N: 4, Deleted: true}) - assertUsageEntry(t, usageDocs, usageKey("stock_income_events", 0), usageEntry{Cmd: "stock_income_events", N: 5, Deleted: true}) - assertUsageEntry(t, usageDocs, usageKey("stock_convert", 7), usageEntry{Cmd: "stock_convert", UserID: 7, Username: "alice", N: 2, Deleted: true}) - assertUsageEntry(t, usageDocs, usageKey("trade_stats_extra", 7), usageEntry{Cmd: "trade_stats_extra", UserID: 7, Username: "alice", N: 99}) - assertUsageEntry(t, usageDocs, usageKey("stock_convert_extra", 7), usageEntry{Cmd: "stock_convert_extra", UserID: 7, Username: "alice", N: 99}) - assertUsageEntry(t, usageDocs, usageKey("lolschedule_weekly", 10), usageEntry{Cmd: "lolschedule_weekly", UserID: 10, Username: "carol", N: 99}) - assertUsageEntry(t, usageDocs, usageKey("stock_income_stocked", 11), usageEntry{Cmd: "stock_income_stocked", UserID: 11, Username: "dan", N: 99}) - - for _, key := range []string{ - usageKey("gold_stats", 0), - usageKey("trade_topup", 0), - usageKey("trade_stats", 0), - usageKey("trade_income_stock", 7), - usageKey("stock_income_stock", 7), - usageKey("lolschedule_week", 8), - } { - if _, _, err := usageDocs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { - t.Fatalf("renamed key %s should be deleted, got err=%v", key, err) - } - } - - rec, ok, err := sys.Get(ctx, commandHistoryMigrationKey) - if err != nil || !ok { - t.Fatalf("command history marker ok=%v err=%v", ok, err) - } - if rec.Status != "done" || rec.Count != 11 { - t.Fatalf("command history marker = %+v, want done count 11", rec) - } - - if err := InitStore(ctx, statsColl, systemColl); err != nil { - t.Fatalf("InitStore second run: %v", err) - } - assertUsageEntry(t, usageDocs, usageKey("gold_portfolio", 0), usageEntry{Cmd: "gold_portfolio", N: 7}) - assertUsageEntry(t, usageDocs, usageKey("stock_portfolio", 0), usageEntry{Cmd: "stock_portfolio", N: 10}) -} - func TestStatsViewsFilterDeletedLegacyRows(t *testing.T) { ctx := context.Background() provider := storage.NewMemoryProvider() @@ -444,17 +278,6 @@ func TestStatsViewsFilterDeletedLegacyRows(t *testing.T) { } } -func assertUsageEntry(t *testing.T, docs storage.DocStore[usageEntry], key string, want usageEntry) { - t.Helper() - got, _, err := docs.Get(context.Background(), key) - if err != nil { - t.Fatalf("usage entry %s: %v", key, err) - } - if got != want { - t.Fatalf("usage entry %s = %+v, want %+v", key, got, want) - } -} - // renderStats is the asserted-on surface for the subcommand views, since it // returns the reply string without touching *bot.Bot. seedFixture below mirrors // what Inc would have written across several users + commands.