mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
refactor: remove completed startup migrations
This commit is contained in:
1 parent
ad153d8e6c
commit
5254ab4c8e
10 files changed
+20
-801
No files matched your search
@@ -54,6 +54,10 @@ Startup migrations should be safe to run every boot:
|
|||||||
- use `internal/systemstate` records in the shared `system` collection for
|
- use `internal/systemstate` records in the shared `system` collection for
|
||||||
one-time migrations
|
one-time migrations
|
||||||
- write tests for migration idempotency and legacy data handling
|
- 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
|
## Git
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ internal/telegram/ Telegram long-polling bot wrapper
|
|||||||
internal/cron/ in-process cron scheduler
|
internal/cron/ in-process cron scheduler
|
||||||
internal/modules/ Module framework, registry, dispatchers, modules
|
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/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)
|
compose.yml Coolify self-host stack (single bot service)
|
||||||
telegram-commands.json Manual Telegram command menu source
|
telegram-commands.json Manual Telegram command menu source
|
||||||
docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide
|
docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide
|
||||||
|
|||||||
+1
-6
@@ -29,7 +29,6 @@ import (
|
|||||||
"github.com/tiennm99/miti99bot/internal/modules/wordle"
|
"github.com/tiennm99/miti99bot/internal/modules/wordle"
|
||||||
"github.com/tiennm99/miti99bot/internal/server"
|
"github.com/tiennm99/miti99bot/internal/server"
|
||||||
"github.com/tiennm99/miti99bot/internal/storage"
|
"github.com/tiennm99/miti99bot/internal/storage"
|
||||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
|
||||||
"github.com/tiennm99/miti99bot/internal/telegram"
|
"github.com/tiennm99/miti99bot/internal/telegram"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,12 +93,8 @@ func main() {
|
|||||||
log.Fatal("storage init failed", "err", err)
|
log.Fatal("storage init failed", "err", err)
|
||||||
}
|
}
|
||||||
defer closeProvider()
|
defer closeProvider()
|
||||||
systemColl := provider.Collection(systemstate.CollectionName)
|
|
||||||
|
|
||||||
if err := lol.InitStore(rootCtx, provider, systemColl); err != nil {
|
if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil {
|
||||||
log.Fatal("lol storage init failed", "err", err)
|
|
||||||
}
|
|
||||||
if err := stats.InitStore(rootCtx, provider.Collection("stats"), systemColl); err != nil {
|
|
||||||
log.Fatal("stats storage init failed", "err", err)
|
log.Fatal("stats storage init failed", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
4. Copy the `mongodb+srv://…` connection string into `MONGO_URL` and put the
|
||||||
db name in `MONGO_DATABASE`.
|
db name in `MONGO_DATABASE`.
|
||||||
|
|
||||||
> Storage layout: one collection per module plus a shared `system` collection
|
> Storage layout: one collection per module. Each document is a flattened native
|
||||||
> for startup metadata such as one-time migrations. Each document is a flattened native
|
|
||||||
> document — `{ _id: <user key>, ...payload fields, version, updatedAt }` with no
|
> document — `{ _id: <user key>, ...payload fields, version, updatedAt }` with no
|
||||||
> `value` envelope. Payload fields are hoisted to the document root so they
|
> `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
|
> 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);
|
> daily push date under `date`. Concurrency uses the `version` field (optimistic lock);
|
||||||
> `updatedAt` is a BSON Date.
|
> `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
|
> The `stats` collection uses queryable aggregate documents for command/user
|
||||||
> counts and creates indexes on startup. First startup after the schema change
|
> counts and creates indexes on startup. Deleted legacy command rows are retained
|
||||||
> migrates legacy `count:`, `user:`, and `pair:` stats keys into the new shape,
|
> with `deleted: true`; `/stats` queries filter those rows from visible results.
|
||||||
> deletes the legacy keys, and records completion in `system`. Startup also
|
> A historical `system` collection may remain in MongoDB with completed migration
|
||||||
> migrates renamed command stats to the current command names and marks removed
|
> records and can be reused if a future one-time startup migration is needed.
|
||||||
> command rows with `deleted: true`; `/stats` queries filter those retained
|
|
||||||
> legacy rows.
|
|
||||||
|
|
||||||
## 2. Coolify
|
## 2. Coolify
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"github.com/tiennm99/miti99bot/internal/storage"
|
"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
|
// 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
|
// 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
|
// subscribers) are wired here. The cron handler reads deps.Bot at invoke
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,87 +2,25 @@ package stats
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
"github.com/tiennm99/miti99bot/internal/storage"
|
"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
|
// InitStore performs stats collection startup maintenance. It is safe to call
|
||||||
// every boot: indexes are idempotent and legacy migration is guarded by a
|
// every boot: MongoDB indexes are created idempotently and memory storage is a
|
||||||
// system collection marker.
|
// no-op.
|
||||||
func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error {
|
func InitStore(ctx context.Context, statsColl storage.Collection) error {
|
||||||
if mongoColl, ok := storage.MongoCollection(statsColl); ok {
|
if mongoColl, ok := storage.MongoCollection(statsColl); ok {
|
||||||
if err := ensureUsageIndexes(ctx, mongoColl); err != nil {
|
if err := ensureUsageIndexes(ctx, mongoColl); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := migrateLegacyUsage(ctx, statsColl, systemColl); err != nil {
|
return nil
|
||||||
return err
|
|
||||||
}
|
|
||||||
return migrateCommandHistory(ctx, statsColl, systemColl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
|
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
|
||||||
@@ -105,275 +43,3 @@ func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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()
|
|
||||||
}
|
|
||||||
@@ -2,20 +2,15 @@ package stats
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/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")
|
uri := os.Getenv("MONGODB_TEST_URL")
|
||||||
if uri == "" {
|
if uri == "" {
|
||||||
t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test")
|
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)
|
provider := storage.NewMongoProvider(db)
|
||||||
statsColl := provider.Collection("stats")
|
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)
|
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)
|
rawStatsColl, ok := storage.MongoCollection(statsColl)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("stats collection is not Mongo-backed")
|
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)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/tiennm99/miti99bot/internal/modules"
|
"github.com/tiennm99/miti99bot/internal/modules"
|
||||||
"github.com/tiennm99/miti99bot/internal/storage"
|
"github.com/tiennm99/miti99bot/internal/storage"
|
||||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
|
||||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
"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) {
|
func TestStatsViewsFilterDeletedLegacyRows(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
provider := storage.NewMemoryProvider()
|
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
|
// renderStats is the asserted-on surface for the subcommand views, since it
|
||||||
// returns the reply string without touching *bot.Bot. seedFixture below mirrors
|
// returns the reply string without touching *bot.Bot. seedFixture below mirrors
|
||||||
// what Inc would have written across several users + commands.
|
// what Inc would have written across several users + commands.
|
||||||
|
|||||||
Reference in new issue
Block a user