package dashboard import ( "context" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "go.mongodb.org/mongo-driver/v2/bson" ) // 复合 $group 的 _id 子文档经 driver 解码进 bson.M 后,嵌套值默认是 bson.D 而不是 bson.M; // 这个测试锁死我们对两种形态的兼容,防止聚合行被静默丢弃(表现为"成功但全零")。 func TestAslanMongoDayCountryHandlesDecodedShapes(t *testing.T) { raw, err := bson.Marshal(bson.M{"_id": bson.M{"day": "2026-07-02", "country": "sa"}, "amount": int64(5)}) if err != nil { t.Fatalf("marshal fixture: %v", err) } row := bson.M{} if err := bson.Unmarshal(raw, &row); err != nil { t.Fatalf("unmarshal fixture: %v", err) } if _, isM := row["_id"].(bson.M); isM { t.Log("driver decoded nested document as bson.M") } else if _, isD := row["_id"].(bson.D); isD { t.Log("driver decoded nested document as bson.D (must be supported)") } else { t.Fatalf("unexpected nested _id type %T", row["_id"]) } day, country := aslanMongoDayCountry(row["_id"]) if day != "2026-07-02" || country != "SA" { t.Fatalf("expected day/country from decoded _id, got %q/%q", day, country) } // 直接构造 bson.D 与 bson.M 两种形态都必须可读。 for _, value := range []any{ bson.D{{Key: "day", Value: "2026-07-01"}, {Key: "country", Value: "eg"}}, bson.M{"day": "2026-07-01", "country": "eg"}, } { day, country := aslanMongoDayCountry(value) if day != "2026-07-01" || country != "EG" { t.Fatalf("expected 2026-07-01/EG for %T, got %q/%q", value, day, country) } } } func TestAslanMongoDocumentConversion(t *testing.T) { if doc := aslanMongoDocument(bson.D{{Key: "day", Value: "2026-07-02"}, {Key: "type", Value: int32(1)}}); doc == nil || doc["day"] != "2026-07-02" { t.Fatalf("bson.D conversion failed: %v", doc) } if doc := aslanMongoDocument(bson.M{"user": int64(7)}); doc == nil || doc["user"] != int64(7) { t.Fatalf("bson.M passthrough failed: %v", doc) } if doc := aslanMongoDocument(nil); doc != nil { t.Fatalf("nil should convert to nil, got %v", doc) } if doc := aslanMongoDocument("scalar"); doc != nil { t.Fatalf("scalar should convert to nil, got %v", doc) } } func TestAslanMongoLegacyCoinSellerRechargeFeedsRechargeChannel(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock: %v", err) } defer db.Close() source := &AslanMongoDashboardSource{ legacyDB: db, sysOrigin: "ATYOU", requestTimeout: time.Second, } start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 0, 1) grid := newLegacyMongoGrid(start, end) cohorts := map[string]map[string][]int64{ "2026-07-05": { "SA": {101}, }, } distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{} perUser := map[string]map[int64]int64{} mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2[\\s\\S]*ugcr\\.create_time >= \\?[\\s\\S]*ugcr\\.create_time < \\?"). WithArgs("ATYOU", start, end). WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}). AddRow("2026-07-05", "sa", int64(101), "42.00"). AddRow("2026-07-05", "AE", int64(202), "10.25")) if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil { t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err) } applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser) saMetric := grid.metrics["2026-07-05"]["SA"] if saMetric == nil { t.Fatalf("missing SA metric: %#v", grid.metrics) } saRow := saMetric.toExternal(nil).toMap() assertInt64(t, saRow["coin_seller_recharge_usd_minor"], 4200) assertInt64(t, saRow["recharge_usd_minor"], 4200) assertInt64(t, saRow["total_recharge_usd_minor"], 4200) assertInt64(t, saRow["user_recharge_usd_minor"], 0) assertInt64(t, saRow["mifapay_recharge_usd_minor"], 0) assertInt64(t, saRow["paid_users"], 1) assertInt64(t, saRow["new_user_recharge_usd_minor"], 4200) aeMetric := grid.metrics["2026-07-05"]["AE"] if aeMetric == nil { t.Fatalf("missing AE metric: %#v", grid.metrics) } aeRow := aeMetric.toExternal(nil).toMap() assertInt64(t, aeRow["coin_seller_recharge_usd_minor"], 1025) assertInt64(t, aeRow["recharge_usd_minor"], 1025) assertInt64(t, aeRow["new_user_recharge_usd_minor"], 0) if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestAslanMongoLegacyOtherRechargeFeedsThirdPartyChannel(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock: %v", err) } defer db.Close() source := &AslanMongoDashboardSource{ legacyDB: db, sysOrigin: "ATYOU", requestTimeout: time.Second, } start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 0, 1) grid := newLegacyMongoGrid(start, end) cohorts := map[string]map[string][]int64{ "2026-07-05": { "SA": {101, 202, 303}, }, } distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{} perUser := map[string]map[int64]int64{} mock.ExpectQuery("FROM order_other_recharge oor[\\s\\S]*INNER JOIN user_base_info ubi ON ubi\\.id = oor\\.user_id[\\s\\S]*oor\\.payment_date_number >= \\?[\\s\\S]*oor\\.payment_date_number < \\?"). WithArgs("ATYOU", "20260705", "20260706"). WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}). AddRow("2026-07-05", "SA", int64(101), "12.34"). AddRow("2026-07-05", "SA", int64(202), "0.00"). AddRow("2026-07-05", "SA", int64(303), "-3.00")) if err := source.loadLegacyOtherRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil { t.Fatalf("loadLegacyOtherRechargeStats: %v", err) } applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser) row := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap() assertInt64(t, row["recharge_usd_minor"], 1234) assertInt64(t, row["mifapay_recharge_usd_minor"], 1234) assertInt64(t, row["coin_seller_recharge_usd_minor"], 0) assertInt64(t, row["user_recharge_usd_minor"], 1234) assertInt64(t, row["paid_users"], 1) assertInt64(t, row["new_user_recharge_usd_minor"], 1234) if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock: %v", err) } defer db.Close() source := &AslanMongoDashboardSource{ legacyDB: db, legacyWalletDatabase: "atyou_wallet", sysOrigin: "ATYOU", requestTimeout: time.Second, } start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 0, 1) grid := newLegacyMongoGrid(start, end) cohorts := map[string]map[string][]int64{ "2026-07-05": { "PH": {2069828597070528514}, }, } distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{} perUser := map[string]map[int64]int64{} mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2"). WithArgs("ATYOU", start, end). WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"})) mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.type = 0"). WithArgs("ATYOU", start, end). WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}). AddRow("2026-07-05", "PH", int64(2069828597070528514), "2000.00"). AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47")) if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil { t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err) } applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser) phRow := grid.metrics["2026-07-05"]["PH"].toExternal(nil).toMap() assertInt64(t, phRow["coin_seller_recharge_usd_minor"], 200000) assertInt64(t, phRow["recharge_usd_minor"], 200000) assertInt64(t, phRow["user_recharge_usd_minor"], 0) assertInt64(t, phRow["paid_users"], 1) assertInt64(t, phRow["new_user_recharge_usd_minor"], 200000) pkRow := grid.metrics["2026-07-05"]["PK"].toExternal(nil).toMap() assertInt64(t, pkRow["coin_seller_recharge_usd_minor"], 2547) assertInt64(t, pkRow["recharge_usd_minor"], 2547) if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock: %v", err) } defer db.Close() source := &AslanMongoDashboardSource{ legacyDB: db, legacyWalletDatabase: "atyou_wallet", sysOrigin: "ATYOU", requestTimeout: time.Second, } start := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC) startArg := time.UnixMilli(start.UnixMilli()) endArg := time.UnixMilli(end.UnixMilli()) createdAt := time.Date(2026, 7, 5, 12, 30, 0, 0, time.UTC) queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw" mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern). WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2))) mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern). WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0). WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}). AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt). AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour))) items, total, err := source.ListCoinSellerRechargeBills(context.Background(), CoinSellerRechargeBillQuery{ StartMS: start.UnixMilli(), EndMS: end.UnixMilli(), Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListCoinSellerRechargeBills: %v", err) } if total != 2 || len(items) != 2 { t.Fatalf("unexpected result total=%d items=%+v", total, items) } if items[0].TransactionID != "aslan-freight-purchase-99" || items[0].Source != "freight_purchase" || items[0].CountryCode != "PH" { t.Fatalf("unexpected first item identity: %+v", items[0]) } if items[0].USDMinorAmount != 132790 || items[0].CreatedAtMS != createdAt.UnixMilli() { t.Fatalf("unexpected first item amount/time: %+v", items[0]) } if items[1].TransactionID != "aslan-gold-coin-7" || items[1].USDMinorAmount != 4200 { t.Fatalf("unexpected second item: %+v", items[1]) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) { match := bson.M{} aslanMongoExcludeFreightGoldRecharge(match) for _, field := range []string{"trackCommodityType", "products.code", "products.name", "products.content"} { condition, ok := match[field].(bson.M) if !ok { t.Fatalf("%s condition = %#v", field, match[field]) } values, ok := condition["$nin"].(bson.A) if !ok { t.Fatalf("%s $nin = %#v", field, condition["$nin"]) } got := map[any]bool{} for _, value := range values { got[value] = true } if !got["FREIGHT_GOLD"] || !got["FREIGHT_GOLD_SUPER"] { t.Fatalf("%s should exclude freight commodity types, got %#v", field, values) } } } func TestAslanMongoLegacyRechargeSkipsNonPositiveAmount(t *testing.T) { start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC) grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1)) distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{} perUser := map[string]map[int64]int64{} addLegacyStandardRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 101, 0) addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100) row := grid.metrics["2026-07-05"]["SA"] if row != nil { t.Fatalf("non-positive legacy recharge should not create metric row: %#v", row) } if len(distinctUsers) != 0 { t.Fatalf("non-positive legacy recharge should not create paid users: %#v", distinctUsers) } if len(perUser) != 0 { t.Fatalf("non-positive legacy recharge should not create per-user recharge: %#v", perUser) } } func TestAslanMongoCoinSellerRechargeSourceAvailability(t *testing.T) { available := aslanMongoDashboardMetricSources(false, true) assertMetricSourceAvailable(t, available, "coin_seller_recharge_usd_minor") unavailable := aslanMongoDashboardMetricSources(false, false) assertMetricSourceUnavailable(t, unavailable, "coin_seller_recharge_usd_minor") } func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) { start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC) grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1)) grid.at("2026-07-05", "SA").RechargeUSDMinor = 4200 grid.at("2026-07-05", "AE").RechargeUSDMinor = 1000 distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{ {day: "2026-07-05", country: "SA"}: {"101": {}}, {day: "2026-07-05", country: "AE"}: {"101": {}}, } applyAslanRechargeUserMetrics(nil, grid, distinctUsers, nil) overview := (&AslanMongoDashboardSource{}).assembleRange(grid, externalDashboardCountryFilter{}, false, true) if overview.Total.PaidUsers != 1 { t.Fatalf("total paid users = %d, want 1", overview.Total.PaidUsers) } if len(overview.DailySeries) != 1 { t.Fatalf("daily series mismatch: %#v", overview.DailySeries) } assertInt64(t, overview.DailySeries[0]["paid_users"], 1) countries := map[string]int64{} for _, row := range overview.CountryBreakdown { country, _ := row["country_code"].(string) paid, _ := row["paid_users"].(int64) countries[country] = paid } if countries["SA"] != 1 || countries["AE"] != 1 { t.Fatalf("country paid users should preserve country buckets, got %#v", countries) } } func assertMetricSourceAvailable(t *testing.T, sources []map[string]any, field string) { t.Helper() for _, source := range sources { if source["field"] != field { continue } if source["available"] != true { t.Fatalf("metric source %s should be available, got %#v", field, source) } return } t.Fatalf("metric source %s not found in %#v", field, sources) }