From 81443fd0803daec8b14cd74891b62e29367c2765 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 22 Jul 2026 18:55:57 +0700 Subject: [PATCH] fix(stock): retire dividend command and migrate stats --- cmd/server/command_menu_test.go | 6 +- cmd/server/main.go | 16 ++- cmd/server/main_test.go | 22 ++++ internal/modules/stats/startup.go | 112 ++++++++++++++++++- internal/modules/stats/startup_mongo_test.go | 66 ++++++++++- internal/modules/stats/startup_test.go | 98 ++++++++++++++++ internal/modules/stats/usage_store.go | 30 ++++- internal/modules/stock/handlers.go | 72 ------------ internal/modules/stock/handlers_test.go | 94 ---------------- internal/modules/stock/stock.go | 9 +- 10 files changed, 331 insertions(+), 194 deletions(-) create mode 100644 internal/modules/stats/startup_test.go diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index dd94cb6..ef4e6ce 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -75,7 +75,6 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "stock_sell": " ", "stock_cash_dividend": " ", "stock_share_dividend": " ", - "stock_dividend": " ", "trongtruonghop": "[target...]", "tth": "[target...]", "wheelofnames": "", @@ -125,11 +124,14 @@ func TestBotCommandMenu_StockDividendContracts(t *testing.T) { t.Fatalf("description for %s exceeds Telegram limit", command.Command) } } - for _, name := range []string{"stock_cash_dividend", "stock_share_dividend", "stock_dividend"} { + for _, name := range []string{"stock_cash_dividend", "stock_share_dividend"} { if commands[name] == "" { t.Fatalf("stock menu missing %s: %v", name, commands) } } + if _, exists := commands["stock_dividend"]; exists { + t.Fatalf("retired stock_dividend remains in public menu: %v", commands) + } if _, exists := commands["stock_bonus"]; exists { t.Fatalf("stock_bonus remains in public menu: %v", commands) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 79b0564..5842ef4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -118,7 +118,7 @@ func main() { } defer closeProvider() - if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil { + if err := initStatsStore(rootCtx, provider); err != nil { log.Fatal("stats storage init failed", "err", err) } if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil { @@ -224,6 +224,20 @@ func initStockStore(ctx context.Context, provider storage.Provider) error { return initStockStoreWith(ctx, provider, stock.InitStore) } +func initStatsStore(ctx context.Context, provider storage.Provider) error { + return initStatsStoreWith(ctx, provider, stats.InitStore) +} + +type statsStoreInitializer func(context.Context, storage.Collection, storage.Collection) error + +func initStatsStoreWith(ctx context.Context, provider storage.Provider, init statsStoreInitializer) error { + return init( + ctx, + provider.Collection("stats"), + provider.Collection(systemstate.CollectionName), + ) +} + type stockStoreInitializer func(context.Context, storage.Collection, storage.Collection) error func initStockStoreWith(ctx context.Context, provider storage.Provider, init stockStoreInitializer) error { diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index a038dc9..5365f8e 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -51,6 +51,18 @@ func TestInitStockStoreRunsStartupMigration(t *testing.T) { } } +func TestInitStatsStoreRunsStartupMigration(t *testing.T) { + ctx := context.Background() + provider := storage.NewMemoryProvider() + if err := initStatsStore(ctx, provider); err != nil { + t.Fatalf("initStatsStore: %v", err) + } + marker, exists, err := systemstate.New(provider.Collection(systemstate.CollectionName)).Get(ctx, "migration:stats-delete-stock-dividend-v1") + if err != nil || !exists || marker.Status != "completed" { + t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err) + } +} + func TestInitStockStorePropagatesMigrationError(t *testing.T) { want := errors.New("migration failed") err := initStockStoreWith(context.Background(), storage.NewMemoryProvider(), func(context.Context, storage.Collection, storage.Collection) error { @@ -61,6 +73,16 @@ func TestInitStockStorePropagatesMigrationError(t *testing.T) { } } +func TestInitStatsStorePropagatesMigrationError(t *testing.T) { + want := errors.New("migration failed") + err := initStatsStoreWith(context.Background(), storage.NewMemoryProvider(), func(context.Context, storage.Collection, storage.Collection) error { + return want + }) + if !errors.Is(err, want) { + t.Fatalf("initStatsStoreWith error=%v, want %v", err, want) + } +} + func TestShortCommitSHA(t *testing.T) { if got := shortCommitSHA(" 0123456789abcdef "); got != "0123456" { t.Errorf("full revision: got %q, want %q", got, "0123456") diff --git a/internal/modules/stats/startup.go b/internal/modules/stats/startup.go index cbf71a9..9fa2046 100644 --- a/internal/modules/stats/startup.go +++ b/internal/modules/stats/startup.go @@ -2,32 +2,132 @@ package stats import ( "context" + "errors" "fmt" + "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" + statsCommandUsersIndexName = "stats_cmd_n_user" + statsUserCommandsIndexName = "stats_uid_n_cmd" + statsUsernameLookupIndexName = "stats_user_uid" + deletedStockDividendMarkerKey = "migration:stats-delete-stock-dividend-v1" + deletedStockDividendCommand = "stock_dividend" + deletedCommandMigrationRetries = 5 ) -// InitStore performs stats collection startup maintenance. It is safe to call -// every boot because MongoDB index creation is idempotent. -func InitStore(ctx context.Context, statsColl storage.Collection) error { +// InitStore performs stats collection startup maintenance. MongoDB index +// creation and the legacy-command migration are both safe to run every boot. +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 } } + return markDeletedCommand(ctx, statsColl, systemColl, deletedStockDividendCommand, deletedStockDividendMarkerKey) +} + +func markDeletedCommand(ctx context.Context, statsColl, systemColl storage.Collection, command, markerKey string) error { + system := systemstate.New(systemColl) + marker, exists, err := system.Get(ctx, markerKey) + if err != nil { + return fmt.Errorf("stats deleted-command migration: read marker: %w", err) + } + var matched int64 + if mongoColl, ok := storage.MongoCollection(statsColl); ok { + matched, err = markMongoUsageEntriesDeleted(ctx, mongoColl, command) + } else { + matched, err = markDocUsageEntriesDeleted(ctx, storage.Typed[usageEntry](statsColl), command) + } + if err != nil { + return err + } + + now := time.Now().UnixMilli() + if !exists { + marker = systemstate.Record{Kind: "migration", Name: "stats delete " + command + " v1"} + } + if marker.CompletedAt == 0 { + marker.CompletedAt = now + } + marker.Status = "completed" + marker.Count = matched + marker.UpdatedAt = now + if err := system.Put(ctx, markerKey, marker); err != nil { + return fmt.Errorf("stats deleted-command migration: write marker: %w", err) + } return nil } +func markMongoUsageEntriesDeleted(ctx context.Context, coll *mongo.Collection, command string) (int64, error) { + filter := bson.M{"cmd": command} + if _, err := coll.UpdateMany(ctx, + bson.M{"cmd": command, "deleted": bson.M{"$ne": true}}, + bson.M{ + "$set": bson.M{"deleted": true}, + "$inc": bson.M{"version": int64(1)}, + "$currentDate": bson.M{"updatedAt": true}, + }, + ); err != nil { + return 0, fmt.Errorf("stats deleted-command migration: update %s: %w", command, err) + } + count, err := coll.CountDocuments(ctx, filter) + if err != nil { + return 0, fmt.Errorf("stats deleted-command migration: count %s: %w", command, err) + } + return count, nil +} + +func markDocUsageEntriesDeleted(ctx context.Context, docs storage.DocStore[usageEntry], command string) (int64, error) { + keys, err := docs.List(ctx, command) + if err != nil { + return 0, fmt.Errorf("stats deleted-command migration: list %s: %w", command, err) + } + var matched int64 + for _, key := range keys { + isMatch, err := markUsageEntryDeleted(ctx, docs, key, command) + if err != nil { + return 0, err + } + if isMatch { + matched++ + } + } + return matched, nil +} + +func markUsageEntryDeleted(ctx context.Context, docs storage.DocStore[usageEntry], key, command string) (bool, error) { + for attempt := 0; attempt < deletedCommandMigrationRetries; attempt++ { + entry, version, err := docs.Get(ctx, key) + if errors.Is(err, storage.ErrNotFound) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("stats deleted-command migration: read %s: %w", key, err) + } + if entry.Cmd != command { + return false, nil + } + if entry.Deleted { + return true, nil + } + entry.Deleted = true + if err := docs.PutVersioned(ctx, key, version, entry); err == nil { + return true, nil + } else if !errors.Is(err, storage.ErrConflict) { + return false, fmt.Errorf("stats deleted-command migration: write %s: %w", key, err) + } + } + return false, fmt.Errorf("stats deleted-command migration: write %s: %w", key, storage.ErrConflict) +} + func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error { models := []mongo.IndexModel{ { diff --git a/internal/modules/stats/startup_mongo_test.go b/internal/modules/stats/startup_mongo_test.go index 07d65cf..a9fabcc 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" "github.com/tiennm99/miti99bot/internal/testutil/mongotest" ) @@ -18,19 +19,74 @@ func TestMain(m *testing.M) { } func TestInitStore_MongoCreatesIndexes(t *testing.T) { - ctx, statsColl := setupMongoStatsTest(t) + ctx, statsColl, systemColl := setupMongoStatsTest(t) rawStatsColl, ok := storage.MongoCollection(statsColl) if !ok { t.Fatal("stats collection is not Mongo-backed") } - if err := InitStore(ctx, statsColl); err != nil { + docs := storage.Typed[usageEntry](statsColl) + if err := docs.Put(ctx, "stock_dividend", usageEntry{Cmd: "stock_dividend", N: 9}); err != nil { + t.Fatalf("seed anonymous stats: %v", err) + } + if err := docs.Put(ctx, "stock_dividend:7", usageEntry{Cmd: "stock_dividend", UserID: 7, Username: "alice", N: 4}); err != nil { + t.Fatalf("seed user stats: %v", err) + } + if err := docs.Put(ctx, "stock_dividend_extra", usageEntry{Cmd: "stock_dividend_extra", N: 3}); err != nil { + t.Fatalf("seed prefix stats: %v", err) + } + + 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) } + for _, key := range []string{"stock_dividend", "stock_dividend:7"} { + entry, _, err := docs.Get(ctx, key) + if err != nil || !entry.Deleted { + t.Fatalf("retained %s = %+v, err=%v", key, entry, err) + } + } + prefixEntry, _, err := docs.Get(ctx, "stock_dividend_extra") + if err != nil || prefixEntry.Deleted { + t.Fatalf("prefix entry = %+v, err=%v", prefixEntry, err) + } + marker, exists, err := systemstate.New(systemColl).Get(ctx, deletedStockDividendMarkerKey) + if err != nil || !exists || marker.Status != "completed" || marker.Count != 2 { + t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err) + } + + legacy, _, err := docs.Get(ctx, "stock_dividend:7") + if err != nil { + t.Fatal(err) + } + legacy.Deleted = false + legacy.N++ + if err := docs.Put(ctx, "stock_dividend:7", legacy); err != nil { + t.Fatal(err) + } + store := newUsageStore(statsColl) + if err := store.Increment(ctx, "stock_dividend", usageUser{ID: 7, Username: "alice"}, true); err != nil { + t.Fatalf("retired Increment: %v", err) + } + if rows, err := store.TopCommands(ctx, 10); err != nil || len(rows) != 1 || rows[0].display != "/stock_dividend_extra" || rows[0].n != 3 { + t.Fatalf("top commands after legacy write = %+v, err=%v", rows, err) + } + if rows, err := store.TopUsers(ctx, 10); err != nil || len(rows) != 0 { + t.Fatalf("retired top users = %+v, err=%v", rows, err) + } + if rows, err := store.UsersByCommand(ctx, "stock_dividend", 10); err != nil || len(rows) != 0 { + t.Fatalf("retired users = %+v, err=%v", rows, err) + } + if err := InitStore(ctx, statsColl, systemColl); err != nil { + t.Fatalf("InitStore reconciliation: %v", err) + } + legacy, _, err = docs.Get(ctx, "stock_dividend:7") + if err != nil || !legacy.Deleted || legacy.N != 5 { + t.Fatalf("reconciled legacy entry = %+v, err=%v", legacy, err) + } cur, err := rawStatsColl.Indexes().List(ctx) if err != nil { @@ -58,7 +114,7 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) { } } -func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) { +func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection, storage.Collection) { t.Helper() uri := mongoTests.URI(t) @@ -80,5 +136,5 @@ func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) { }) provider := storage.NewMongoProvider(db) - return ctx, provider.Collection("stats") + return ctx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName) } diff --git a/internal/modules/stats/startup_test.go b/internal/modules/stats/startup_test.go new file mode 100644 index 0000000..da26894 --- /dev/null +++ b/internal/modules/stats/startup_test.go @@ -0,0 +1,98 @@ +package stats + +import ( + "context" + "testing" + + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" +) + +func TestInitStoreMarksStockDividendStatsDeletedAndRetainsHistory(t *testing.T) { + ctx := context.Background() + provider := storage.NewMemoryProvider() + statsColl := provider.Collection("stats") + systemColl := provider.Collection(systemstate.CollectionName) + docs := storage.Typed[usageEntry](statsColl) + + seed := map[string]usageEntry{ + "stock_dividend": {Cmd: "stock_dividend", N: 12}, + "stock_dividend:7": {Cmd: "stock_dividend", UserID: 7, Username: "alice", N: 5, Deleted: true}, + "stock_dividend_extra": {Cmd: "stock_dividend_extra", N: 3}, + "stock_cash_dividend:7": {Cmd: "stock_cash_dividend", UserID: 7, Username: "alice", N: 2}, + } + for key, entry := range seed { + 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) + } + + for _, key := range []string{"stock_dividend", "stock_dividend:7"} { + entry, _, err := docs.Get(ctx, key) + if err != nil { + t.Fatalf("get retained %s: %v", key, err) + } + if !entry.Deleted || entry.N != seed[key].N || entry.Username != seed[key].Username { + t.Fatalf("retained %s = %+v, want deleted history %+v", key, entry, seed[key]) + } + } + for _, key := range []string{"stock_dividend_extra", "stock_cash_dividend:7"} { + entry, _, err := docs.Get(ctx, key) + if err != nil || entry.Deleted { + t.Fatalf("unrelated %s = %+v, err=%v", key, entry, err) + } + } + + marker, exists, err := systemstate.New(systemColl).Get(ctx, deletedStockDividendMarkerKey) + if err != nil || !exists || marker.Status != "completed" || marker.Count != 2 { + t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err) + } + + rows, err := newUsageStore(statsColl).TopCommands(ctx, 10) + if err != nil { + t.Fatalf("TopCommands: %v", err) + } + for _, row := range rows { + if row.display == "/stock_dividend" { + t.Fatalf("retired command remains visible: %+v", rows) + } + } + + // Simulate a legacy instance writing after the first startup completed. + legacy, _, err := docs.Get(ctx, "stock_dividend:7") + if err != nil { + t.Fatal(err) + } + legacy.Deleted = false + legacy.N++ + if err := docs.Put(ctx, "stock_dividend:7", legacy); err != nil { + t.Fatal(err) + } + store := newUsageStore(statsColl) + if err := store.Increment(ctx, "stock_dividend", usageUser{ID: 7, Username: "alice"}, true); err != nil { + t.Fatalf("retired Increment: %v", err) + } + if rows, err := store.UsersByCommand(ctx, "stock_dividend", 10); err != nil || len(rows) != 0 { + t.Fatalf("retired users = %+v, err=%v", rows, err) + } + if rows, err := store.TopUsers(ctx, 10); err != nil || len(rows) != 1 || rows[0].display != "@alice" || rows[0].n != 2 { + t.Fatalf("top users after legacy write = %+v, err=%v", rows, err) + } + if rows, found, err := store.CommandsByUser(ctx, "alice", 10); err != nil || !found || len(rows) != 1 || rows[0].display != "/stock_cash_dividend" { + t.Fatalf("commands after legacy write = %+v, found=%v err=%v", rows, found, err) + } + if err := InitStore(ctx, statsColl, systemColl); err != nil { + t.Fatalf("InitStore reconciliation: %v", err) + } + legacy, _, err = docs.Get(ctx, "stock_dividend:7") + if err != nil || !legacy.Deleted || legacy.N != 6 { + t.Fatalf("reconciled legacy entry = %+v, err=%v", legacy, err) + } +} diff --git a/internal/modules/stats/usage_store.go b/internal/modules/stats/usage_store.go index 9d81f4d..84c2d21 100644 --- a/internal/modules/stats/usage_store.go +++ b/internal/modules/stats/usage_store.go @@ -61,6 +61,9 @@ type docUsageStore struct { } func (s *docUsageStore) Increment(ctx context.Context, cmd string, user usageUser, hasUser bool) error { + if isRetiredCommand(cmd) { + return nil + } s.mu.Lock() defer s.mu.Unlock() @@ -123,7 +126,7 @@ func (s *docUsageStore) TopCommands(ctx context.Context, limit int) ([]row, erro } totals := make(map[string]int64) for _, e := range entries { - if e.Deleted { + if e.Deleted || isRetiredCommand(e.Cmd) { continue } totals[e.Cmd] += e.N @@ -150,7 +153,7 @@ func (s *docUsageStore) TopUsers(ctx context.Context, limit int) ([]row, error) } totals := make(map[int64]total) for _, e := range entries { - if e.Deleted || e.UserID == 0 || e.Username == "" { + if e.Deleted || isRetiredCommand(e.Cmd) || e.UserID == 0 || e.Username == "" { continue } t := totals[e.UserID] @@ -179,7 +182,7 @@ func (s *docUsageStore) CommandsByUser(ctx context.Context, username string, lim found bool ) for _, e := range entries { - if !e.Deleted && e.UserID != 0 && e.Username == username { + if !e.Deleted && !isRetiredCommand(e.Cmd) && e.UserID != 0 && e.Username == username { userID = e.UserID found = true break @@ -191,7 +194,7 @@ func (s *docUsageStore) CommandsByUser(ctx context.Context, username string, lim rows := make([]row, 0) for _, e := range entries { - if !e.Deleted && e.UserID == userID { + if !e.Deleted && !isRetiredCommand(e.Cmd) && e.UserID == userID { rows = append(rows, row{display: "/" + e.Cmd, n: e.N}) } } @@ -200,6 +203,9 @@ func (s *docUsageStore) CommandsByUser(ctx context.Context, username string, lim } func (s *docUsageStore) UsersByCommand(ctx context.Context, cmd string, limit int) ([]row, error) { + if isRetiredCommand(cmd) { + return nil, nil + } s.mu.Lock() defer s.mu.Unlock() @@ -241,6 +247,9 @@ type mongoUsageStore struct { } func (s *mongoUsageStore) Increment(ctx context.Context, cmd string, user usageUser, hasUser bool) error { + if isRetiredCommand(cmd) { + return nil + } userID := int64(0) if hasUser { userID = user.ID @@ -289,7 +298,7 @@ func (s *mongoUsageStore) Increment(ctx context.Context, cmd string, user usageU func (s *mongoUsageStore) TopCommands(ctx context.Context, limit int) ([]row, error) { pipeline := mongo.Pipeline{ bson.D{bsonField("$match", bson.D{ - bsonField("cmd", bson.D{bsonField("$type", "string")}), + bsonField("cmd", bson.D{bsonField("$type", "string"), bsonField("$ne", deletedStockDividendCommand)}), bsonField("deleted", bson.D{bsonField("$ne", true)}), })}, bson.D{bsonField("$group", bson.D{bsonField("_id", "$cmd"), bsonField("n", bson.D{bsonField("$sum", "$n")})})}, @@ -323,6 +332,7 @@ func (s *mongoUsageStore) TopCommands(ctx context.Context, limit int) ([]row, er func (s *mongoUsageStore) TopUsers(ctx context.Context, limit int) ([]row, error) { pipeline := mongo.Pipeline{ bson.D{bsonField("$match", bson.D{ + bsonField("cmd", bson.D{bsonField("$ne", deletedStockDividendCommand)}), bsonField("uid", bson.D{bsonField("$gt", int64(0))}), bsonField("user", bson.D{bsonField("$type", "string"), bsonField("$ne", "")}), bsonField("deleted", bson.D{bsonField("$ne", true)}), @@ -374,7 +384,7 @@ func (s *mongoUsageStore) CommandsByUser(ctx context.Context, username string, l } cur, err := s.coll.Find(ctx, bson.M{ "uid": userID, - "cmd": bson.M{"$type": "string"}, + "cmd": bson.M{"$type": "string", "$ne": deletedStockDividendCommand}, "deleted": bson.M{"$ne": true}, }, opts) if err != nil { @@ -397,6 +407,9 @@ func (s *mongoUsageStore) CommandsByUser(ctx context.Context, username string, l } func (s *mongoUsageStore) UsersByCommand(ctx context.Context, cmd string, limit int) ([]row, error) { + if isRetiredCommand(cmd) { + return nil, nil + } opts := options.Find(). SetProjection(bson.M{"user": 1, "n": 1}). SetSort(bson.D{bsonField("n", -1), bsonField("user", 1)}) @@ -434,6 +447,7 @@ func (s *mongoUsageStore) userIDByUsername(ctx context.Context, username string) } err := s.coll.FindOne(ctx, bson.M{ + "cmd": bson.M{"$ne": deletedStockDividendCommand}, "uid": bson.M{"$gt": int64(0)}, "user": username, "deleted": bson.M{"$ne": true}, @@ -450,6 +464,10 @@ func (s *mongoUsageStore) userIDByUsername(ctx context.Context, username string) return doc.UserID, true, nil } +func isRetiredCommand(cmd string) bool { + return cmd == deletedStockDividendCommand +} + func withLimit(pipeline mongo.Pipeline, limit int) mongo.Pipeline { if limit <= 0 { return pipeline diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index 7a7a154..26d0cc5 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -385,78 +385,6 @@ func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *mod "\nHolding: "+formatShareQuantity(held)+" → "+formatShareQuantity(finalHolding)) } -func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.Update) error { - userID, ok := senderInfo(update) - if !ok { - return chathelper.Reply(ctx, b, update.Message, - "Cannot identify user — stock only works in private/group chats with a sender.") - } - args := argsAfterCommand(update.Message.Text) - if len(args) != 3 { - return chathelper.Reply(ctx, b, update.Message, - "Usage: /stock_dividend \nEg: /stock_dividend 1500 100:10 TCB") - } - vndPerShare, ok := parsePositiveWhole(args[0]) - if !ok { - return chathelper.Reply(ctx, b, update.Message, "VND per share must be a positive whole number.") - } - ratio, ok := parseShareRatio(args[1]) - if !ok { - return chathelper.Reply(ctx, b, update.Message, "Share ratio must use positive whole numbers in owned:new form.") - } - symbol, err := normalizeStockSymbol(args[2]) - if err != nil { - if errors.Is(err, ErrUnknownTicker) { - return chathelper.Reply(ctx, b, update.Message, - "Unknown stock ticker \""+strings.ToUpper(args[2])+"\".") - } - return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.") - } - - defer s.locks.Acquire(strconv.FormatInt(userID, 10))() - - p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli()) - if err != nil { - log.Error("stock_load_portfolio", "user", userID, "err", err) - return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.") - } - held := p.Assets[symbol].Quantity - if held <= 0 { - return chathelper.Reply(ctx, b, update.Message, - "You don't hold any "+symbol+" to receive a dividend.") - } - total, err := cashDividendTotal(held, vndPerShare) - if err != nil { - return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.") - } - newShares, err := shareDividendEntitlement(held, ratio) - if err != nil { - return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.") - } - finalHolding, err := checkedHoldingAfterDividend(held, newShares) - if err != nil { - return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.") - } - balance, err := checkedVNDBalance(p.VND, total) - if err != nil { - return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.") - } - - if err := p.ApplyDividend(symbol, finalHolding, balance, s.now().UnixMilli()); err != nil { - return chathelper.Reply(ctx, b, update.Message, "Could not apply this dividend. Try again later.") - } - if err := SavePortfolio(ctx, s.store, userID, p); err != nil { - log.Error("stock_save_portfolio", "user", userID, "err", err) - return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.") - } - return chathelper.Reply(ctx, b, update.Message, - "Dividend for "+symbol+" ("+ratio.raw+")"+ - "\nCash: "+FormatVND(float64(vndPerShare))+" × "+formatShareQuantity(held)+" = "+FormatVND(float64(total))+ - "\nShares: +"+formatShareQuantity(newShares)+ - "\nHolding: "+formatShareQuantity(held)+" → "+formatShareQuantity(finalHolding)+ - "\nBalance: "+FormatVND(balance)) -} - // handleStats renders the portfolio first, then synchronizes dividend history // for each held ticker. Network calls never hold the user lock, while history // merging and notification state updates reload under it. diff --git a/internal/modules/stock/handlers_test.go b/internal/modules/stock/handlers_test.go index e2cebae..482445c 100644 --- a/internal/modules/stock/handlers_test.go +++ b/internal/modules/stock/handlers_test.go @@ -2,7 +2,6 @@ package stock import ( "context" - "errors" "fmt" "net/http" "net/http/httptest" @@ -30,7 +29,6 @@ func TestModuleRegistersExpectedCommands(t *testing.T) { "stock_sell", "stock_cash_dividend", "stock_share_dividend", - "stock_dividend", "stock_portfolio", } { if !got[name] { @@ -165,14 +163,6 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) { }, want: "Usage: /stock_share_dividend ", }, - { - name: "dividend", - text: "/stock_dividend 1500 100:10 TCB extra", - run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error { - return s.handleDividend(ctx, rb.Bot, upd) - }, - want: "Usage: /stock_dividend ", - }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -218,12 +208,6 @@ func TestDividendHandlersRejectInvalidNumbers(t *testing.T) { t.Fatalf("share dividend: %v", err) } rb.AssertSentText(t, "owned:new") - - rb.Reset() - if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend Inf 4:1 TCB")); err != nil { - t.Fatalf("combined dividend: %v", err) - } - rb.AssertSentText(t, "positive whole number") } type countingPortfolioStore struct { @@ -363,84 +347,6 @@ func TestHandleShareDividendFormatsExactLargeMinimum(t *testing.T) { rb.AssertSentText(t, "Minimum holding: 9.007.199.254.740.993.") } -func TestHandleCombinedDividendUsesPreEventHoldingAndOneSave(t *testing.T) { - ctx := context.Background() - base := newStockStore() - seedStockPortfolio(t, base, 7, 139, 1000) - store := &countingPortfolioStore{Store: base} - s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} - rb := testutil.NewRecordingBot(t) - - if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 1500 100:10 TCB")); err != nil { - t.Fatalf("handleDividend: %v", err) - } - if store.puts != 1 { - t.Fatalf("store writes = %d, want 1", store.puts) - } - p, _ := LoadPortfolio(ctx, base, 7, 999) - if p.Assets["TCB"].Quantity != 152 || p.VND != 209500 || p.Assets["TCB"].Base != 139*30_000 || p.Assets["TCB"].OpenedAt != 100 { - t.Fatalf("portfolio = %+v", p) - } - rb.AssertSentText(t, "Dividend for TCB (100:10)") - rb.AssertSentText(t, "Cash: 1.500 VND × 139 = 208.500 VND") -} - -func TestHandleCombinedDividendRejectsInexactBalanceSum(t *testing.T) { - ctx := context.Background() - base := newStockStore() - seedStockPortfolio(t, base, 7, 1, 1) - store := &countingPortfolioStore{Store: base} - s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} - rb := testutil.NewRecordingBot(t) - - if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 9007199254740992 1:1 TCB")); err != nil { - t.Fatalf("handleDividend: %v", err) - } - if store.puts != 0 { - t.Fatalf("store writes = %d, want 0", store.puts) - } - p, _ := LoadPortfolio(ctx, base, 7, 999) - if p.Assets["TCB"].Quantity != 1 || p.VND != 1 { - t.Fatalf("portfolio changed: %+v", p) - } - rb.AssertSentText(t, "Dividend amount is too large.") -} - -func TestHandleCombinedDividendCreditsCashWhenSharesRoundToZero(t *testing.T) { - ctx := context.Background() - store := newStockStore() - seedStockPortfolio(t, store, 7, 9, 100) - s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} - rb := testutil.NewRecordingBot(t) - - if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 1500 100:10 TCB")); err != nil { - t.Fatalf("handleDividend: %v", err) - } - p, _ := LoadPortfolio(ctx, store, 7, 999) - if p.Assets["TCB"].Quantity != 9 || p.VND != 13600 { - t.Fatalf("portfolio = %+v", p) - } - rb.AssertSentText(t, "Shares: +0") -} - -func TestDividendSaveFailureLeavesStoredPortfolioUnchanged(t *testing.T) { - ctx := context.Background() - base := newStockStore() - seedStockPortfolio(t, base, 7, 139, 1000) - store := &countingPortfolioStore{Store: base, putErr: errors.New("forced write failure")} - s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} - rb := testutil.NewRecordingBot(t) - - if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 1500 100:10 TCB")); err != nil { - t.Fatalf("handleDividend: %v", err) - } - p, _ := LoadPortfolio(ctx, base, 7, 999) - if p.Assets["TCB"].Quantity != 139 || p.VND != 1000 { - t.Fatalf("stored portfolio changed: %+v", p) - } - rb.AssertSentText(t, "Could not save portfolio") -} - func modDepsForTest() modules.Deps { return modules.Deps{Store: storage.NewMemoryProvider().Collection("stock")} } diff --git a/internal/modules/stock/stock.go b/internal/modules/stock/stock.go index 8a69737..d1852a8 100644 --- a/internal/modules/stock/stock.go +++ b/internal/modules/stock/stock.go @@ -5,7 +5,7 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) -// New is the stock module Factory. Eight user-facing commands. +// New is the stock module Factory. Seven user-facing commands. func New(deps modules.Deps) modules.Module { s := newState( storage.Typed[Portfolio](deps.Store), @@ -56,13 +56,6 @@ func New(deps modules.Deps) modules.Module { Parameters: " ", Handler: s.handleShareDividend, }, - { - Name: "stock_dividend", - Visibility: modules.VisibilityPublic, - Description: "Record cash and share dividend", - Parameters: " ", - Handler: s.handleDividend, - }, { Name: "stock_portfolio", Visibility: modules.VisibilityPublic,