From 194caf17eb386a1be93e61b9552696b7dd6ecf8f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 6 Jul 2026 20:46:10 +0700 Subject: [PATCH] fix(lol): rename next week command --- README.md | 2 +- cmd/server/main.go | 3 +- internal/modules/lol/handlers.go | 2 +- internal/modules/lol/handlers_test.go | 6 +- internal/modules/lol/lol.go | 2 +- internal/modules/stats/startup.go | 91 +++++++++++++++++++- internal/modules/stats/startup_mongo_test.go | 6 +- internal/modules/stats/startup_test.go | 76 ++++++++++++++++ telegram-commands.json | 2 +- 9 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 internal/modules/stats/startup_test.go diff --git a/README.md b/README.md index 81a7fa2..6dedf47 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Atlas via long polling and an in-process cron scheduler. | `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/wheelofnamesbeta`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | -| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_nextweek`) + daily push | +| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`) + daily push | | `wc` | World Cup schedule + silent daily push | | `stock` | VN-stocks paper trading | | `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) | diff --git a/cmd/server/main.go b/cmd/server/main.go index a51d123..16bc12b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,6 +29,7 @@ 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,7 +95,7 @@ func main() { } defer closeProvider() - if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil { + if err := stats.InitStore(rootCtx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName)); err != nil { log.Fatal("stats storage init failed", "err", err) } if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil { diff --git a/internal/modules/lol/handlers.go b/internal/modules/lol/handlers.go index 0c3df92..124525f 100644 --- a/internal/modules/lol/handlers.go +++ b/internal/modules/lol/handlers.go @@ -79,7 +79,7 @@ func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Updat "No major LoL matches this week.", "") } -// handleNextWeek is /lol_nextweek — the next ICT calendar week +// handleNextWeek is /lol_next_week — the next ICT calendar week // (Monday 00:00 ICT through the following Monday 00:00 ICT, exclusive). func (s *state) handleNextWeek(ctx context.Context, b *bot.Bot, update *models.Update) error { msg := update.Message diff --git a/internal/modules/lol/handlers_test.go b/internal/modules/lol/handlers_test.go index 6632501..3258530 100644 --- a/internal/modules/lol/handlers_test.go +++ b/internal/modules/lol/handlers_test.go @@ -41,7 +41,7 @@ func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.Reco {Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}, {Name: "lol_tomorrow", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleTomorrow}, {Name: "lol_this_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek}, - {Name: "lol_nextweek", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleNextWeek}, + {Name: "lol_next_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleNextWeek}, {Name: "lol_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe}, {Name: "lol_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe}, }, @@ -160,7 +160,7 @@ func TestHandleTomorrow_RendersTomorrow(t *testing.T) { func TestHandleNextWeek_RendersNextWeek(t *testing.T) { rb, _ := installSchedule(t, futureBody, fakeNowMs) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_nextweek")) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_next_week")) got := rb.LastSent().Text() for _, want := range []string{"Mon May 11", "Sun May 17", "LPL", "JDG vs BLG"} { @@ -179,7 +179,7 @@ func TestNewRegistersScheduleShortcuts(t *testing.T) { for _, cmd := range mod.Commands { got[cmd.Name] = true } - for _, want := range []string{"lol", "lol_tomorrow", "lol_this_week", "lol_nextweek"} { + for _, want := range []string{"lol", "lol_tomorrow", "lol_this_week", "lol_next_week"} { if !got[want] { t.Errorf("New() missing command %s", want) } diff --git a/internal/modules/lol/lol.go b/internal/modules/lol/lol.go index c3fc6bd..9f7ef74 100644 --- a/internal/modules/lol/lol.go +++ b/internal/modules/lol/lol.go @@ -41,7 +41,7 @@ func New(deps modules.Deps) modules.Module { Handler: s.handleWeek, }, { - Name: "lol_nextweek", + Name: "lol_next_week", Visibility: modules.VisibilityPublic, Description: "LoL esports matches for next week (Mon–Sun, ICT)", Handler: s.handleNextWeek, diff --git a/internal/modules/stats/startup.go b/internal/modules/stats/startup.go index 4bc61ce..e076ca3 100644 --- a/internal/modules/stats/startup.go +++ b/internal/modules/stats/startup.go @@ -2,30 +2,115 @@ package stats import ( "context" + "errors" "fmt" + "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 ( statsCommandUsersIndexName = "stats_cmd_n_user" statsUserCommandsIndexName = "stats_uid_n_cmd" statsUsernameLookupIndexName = "stats_user_uid" + + renameLolNextWeekStatsKey = "stats:command-rename:lol_nextweek-to-lol_next_week" + oldLolNextWeekCommand = "lol_nextweek" + newLolNextWeekCommand = "lol_next_week" ) // InitStore performs stats collection startup maintenance. It is safe to call -// every boot: MongoDB indexes are created idempotently and memory storage is a -// no-op. -func InitStore(ctx context.Context, statsColl storage.Collection) error { +// every boot: MongoDB indexes are created idempotently and one-time command +// rename migrations are guarded by the shared system collection. +func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error { if mongoColl, ok := storage.MongoCollection(statsColl); ok { if err := ensureUsageIndexes(ctx, mongoColl); err != nil { return err } } + if err := migrateCommandRename(ctx, statsColl, systemColl, oldLolNextWeekCommand, newLolNextWeekCommand, renameLolNextWeekStatsKey); err != nil { + return err + } + return nil +} + +func migrateCommandRename(ctx context.Context, statsColl, systemColl storage.Collection, oldCmd, newCmd, markerKey string) error { + state := systemstate.New(systemColl) + if rec, ok, err := state.Get(ctx, markerKey); err != nil { + return fmt.Errorf("stats command rename marker %s: %w", markerKey, err) + } else if ok && rec.Status == "complete" { + return nil + } + + docs := storage.Typed[usageEntry](statsColl) + keys, err := docs.List(ctx, oldCmd) + if err != nil { + return fmt.Errorf("stats command rename list %s: %w", oldCmd, err) + } + + var moved int64 + for _, key := range keys { + if key != oldCmd && !strings.HasPrefix(key, oldCmd+":") { + continue + } + entry, _, err := docs.Get(ctx, key) + if err != nil { + return fmt.Errorf("stats command rename get %s: %w", key, err) + } + if entry.Cmd != oldCmd { + continue + } + + targetKey := usageKey(newCmd, entry.UserID) + target, _, err := docs.Get(ctx, targetKey) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("stats command rename get target %s: %w", targetKey, err) + } + missingTarget := errors.Is(err, storage.ErrNotFound) + if missingTarget { + target = usageEntry{} + } + + target.Cmd = newCmd + target.UserID = entry.UserID + if entry.UserID == 0 { + target.Username = "" + } else if entry.Username != "" { + target.Username = entry.Username + } + target.N += entry.N + if missingTarget { + target.Deleted = entry.Deleted + } + if !entry.Deleted { + target.Deleted = false + } + if err := docs.Put(ctx, targetKey, target); err != nil { + return fmt.Errorf("stats command rename put target %s: %w", targetKey, err) + } + if err := docs.Delete(ctx, key); err != nil { + return fmt.Errorf("stats command rename delete old %s: %w", key, err) + } + moved += entry.N + } + + now := time.Now().UTC().UnixMilli() + if err := state.Put(ctx, markerKey, systemstate.Record{ + Kind: "migration", + Name: markerKey, + Status: "complete", + Count: moved, + CompletedAt: now, + UpdatedAt: now, + }); err != nil { + return fmt.Errorf("stats command rename marker put %s: %w", markerKey, err) + } return nil } diff --git a/internal/modules/stats/startup_mongo_test.go b/internal/modules/stats/startup_mongo_test.go index 6fdc296..b9de03a 100644 --- a/internal/modules/stats/startup_mongo_test.go +++ b/internal/modules/stats/startup_mongo_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" ) func TestInitStore_MongoCreatesIndexes(t *testing.T) { @@ -34,16 +35,17 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) { provider := storage.NewMongoProvider(db) statsColl := provider.Collection("stats") + systemColl := provider.Collection(systemstate.CollectionName) rawStatsColl, ok := storage.MongoCollection(statsColl) if !ok { t.Fatal("stats collection is not Mongo-backed") } - if err := InitStore(ctx, statsColl); err != nil { + if err := InitStore(ctx, statsColl, systemColl); err != nil { t.Fatalf("InitStore: %v", err) } - if err := InitStore(ctx, statsColl); err != nil { + if err := InitStore(ctx, statsColl, systemColl); err != nil { t.Fatalf("InitStore second run: %v", err) } diff --git a/internal/modules/stats/startup_test.go b/internal/modules/stats/startup_test.go new file mode 100644 index 0000000..3ff02ea --- /dev/null +++ b/internal/modules/stats/startup_test.go @@ -0,0 +1,76 @@ +package stats + +import ( + "context" + "errors" + "testing" + + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" +) + +func TestInitStore_RenamesLolNextWeekStatsOnce(t *testing.T) { + ctx := context.Background() + provider := storage.NewMemoryProvider() + statsColl := provider.Collection("stats") + systemColl := provider.Collection(systemstate.CollectionName) + docs := storage.Typed[usageEntry](statsColl) + + seeds := map[string]usageEntry{ + usageKey(oldLolNextWeekCommand, 0): {Cmd: oldLolNextWeekCommand, N: 2}, + usageKey(oldLolNextWeekCommand, 7): { + Cmd: oldLolNextWeekCommand, + UserID: 7, + Username: "alice", + N: 3, + }, + usageKey(newLolNextWeekCommand, 7): { + Cmd: newLolNextWeekCommand, + UserID: 7, + Username: "alice", + N: 5, + }, + } + for key, entry := range seeds { + if err := docs.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) + } + if err := InitStore(ctx, statsColl, systemColl); err != nil { + t.Fatalf("InitStore second run: %v", err) + } + + anon, _, err := docs.Get(ctx, usageKey(newLolNextWeekCommand, 0)) + if err != nil { + t.Fatalf("new anonymous stats: %v", err) + } + if anon.Cmd != newLolNextWeekCommand || anon.N != 2 || anon.UserID != 0 { + t.Fatalf("new anonymous stats = %+v, want cmd %q count 2", anon, newLolNextWeekCommand) + } + + user, _, err := docs.Get(ctx, usageKey(newLolNextWeekCommand, 7)) + if err != nil { + t.Fatalf("new user stats: %v", err) + } + if user.Cmd != newLolNextWeekCommand || user.UserID != 7 || user.Username != "alice" || user.N != 8 { + t.Fatalf("new user stats = %+v, want merged count 8", user) + } + + for _, key := range []string{usageKey(oldLolNextWeekCommand, 0), usageKey(oldLolNextWeekCommand, 7)} { + if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { + t.Fatalf("old key %s err = %v, want ErrNotFound", key, err) + } + } + + rec, ok, err := systemstate.New(systemColl).Get(ctx, renameLolNextWeekStatsKey) + if err != nil { + t.Fatalf("migration marker: %v", err) + } + if !ok || rec.Status != "complete" || rec.Count != 5 { + t.Fatalf("migration marker = %+v ok=%v, want complete count 5", rec, ok) + } +} diff --git a/telegram-commands.json b/telegram-commands.json index 5724102..7be1344 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -69,7 +69,7 @@ "description": "LoL matches for this week" }, { - "command": "lol_nextweek", + "command": "lol_next_week", "description": "LoL matches for next week" }, {