mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-08 22:25:35 +00:00
fix(stock): retire dividend command and migrate stats
This commit is contained in:
@@ -75,7 +75,6 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
"stock_sell": "<quantity> <ticker>",
|
||||
"stock_cash_dividend": "<vnd_per_share> <ticker>",
|
||||
"stock_share_dividend": "<ratio(owned:new)> <ticker>",
|
||||
"stock_dividend": "<vnd_per_share> <ratio(owned:new)> <ticker>",
|
||||
"trongtruonghop": "[target...]",
|
||||
"tth": "[target...]",
|
||||
"wheelofnames": "<option,...>",
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+15
-1
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <vnd_per_share> <ratio(owned:new)> <ticker>\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.
|
||||
|
||||
@@ -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 <ratio(owned:new)> <ticker>",
|
||||
},
|
||||
{
|
||||
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 <vnd_per_share> <ratio(owned:new)> <ticker>",
|
||||
},
|
||||
}
|
||||
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")}
|
||||
}
|
||||
|
||||
@@ -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: "<ratio(owned:new)> <ticker>",
|
||||
Handler: s.handleShareDividend,
|
||||
},
|
||||
{
|
||||
Name: "stock_dividend",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Record cash and share dividend",
|
||||
Parameters: "<vnd_per_share> <ratio(owned:new)> <ticker>",
|
||||
Handler: s.handleDividend,
|
||||
},
|
||||
{
|
||||
Name: "stock_portfolio",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
|
||||
Reference in New Issue
Block a user