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 TestAslanMongoLegacyFreightStockAdjustmentsFeedCoinSellerChannel(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\\.origin = 'DEDUCTION'"). 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"). AddRow("2026-07-05", "SA", int64(2071589123112779778), "-1000.00")) 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) saRow := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap() assertInt64(t, saRow["coin_seller_recharge_usd_minor"], -100000) assertInt64(t, saRow["recharge_usd_minor"], -100000) assertInt64(t, saRow["user_recharge_usd_minor"], 0) assertInt64(t, saRow["paid_users"], 0) 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(3))) 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-deduction-100", "freight_deduction", int64(2069828597070528516), "ae", "-1000.00", createdAt.Add(time.Minute)). 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 != 3 || len(items) != 3 { t.Fatalf("unexpected result total=%d items=%+v", total, items) } if items[0].TransactionID != "aslan-freight-deduction-100" || items[0].Source != "freight_deduction" || items[0].CountryCode != "AE" { t.Fatalf("unexpected first item identity: %+v", items[0]) } if items[0].USDMinorAmount != -100000 || items[0].CreatedAtMS != createdAt.Add(time.Minute).UnixMilli() { t.Fatalf("unexpected first item amount/time: %+v", items[0]) } if items[1].TransactionID != "aslan-freight-purchase-99" || items[1].USDMinorAmount != 132790 { t.Fatalf("unexpected second item: %+v", items[1]) } if items[2].TransactionID != "aslan-gold-coin-7" || items[2].USDMinorAmount != 4200 { t.Fatalf("unexpected third item: %+v", items[2]) } 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 TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(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, 0) if row := grid.metrics["2026-07-05"]["SA"]; row != nil { t.Fatalf("zero legacy recharge should not create metric row: %#v", row) } addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100) row := grid.metrics["2026-07-05"]["SA"] if row == nil { t.Fatalf("negative coin seller deduction should create metric row") } if row.RechargeUSDMinor != -100 || row.CoinSellerRechargeUSDMinor != -100 { t.Fatalf("deduction should reduce recharge metrics: %#v", row) } if len(distinctUsers) != 0 { t.Fatalf("deduction should not create paid users: %#v", distinctUsers) } if len(perUser) != 0 { t.Fatalf("deduction 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) } // applyRetention 的观察日回看归属:区间内每天 D 的 DN 计数 = D-N 注册 cohort 在 D 的活跃, // 归属观察日 D;主 cohorts 与补载的 earlier cohorts 同日并存时必须合并;无 cohort 的天基数保持 0。 func TestAslanMongoApplyRetentionAttributesToObservationDay(t *testing.T) { location, err := time.LoadLocation("Asia/Shanghai") if err != nil { t.Fatalf("load location: %v", err) } startDate := time.Date(2026, 6, 10, 0, 0, 0, 0, location) endDate := time.Date(2026, 6, 12, 0, 0, 0, 0, location) grid := newLegacyMongoGrid(startDate, endDate) // 主 cohorts 覆盖 [start, end);"2026-06-10" 同时出现在 earlier 里,验证合并。 cohorts := map[string]map[string][]int64{ "2026-06-10": {"SA": {1, 2}}, "2026-06-11": {"EG": {3}}, } earlier := map[string]map[string][]int64{ "2026-06-09": {"SA": {10, 11}}, // 观察日 06-10 的 D1 cohort "2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort "2026-05-11": {"EG": {30}}, // 观察日 06-10 的 D30 cohort "2026-06-04": {"EG": {40}}, // 观察日 06-11 的 D7 cohort "2026-06-10": {"SA": {50}}, // 与主 cohorts 同日:合并成 {1,2,50} } activeByDay := map[string]map[int64]struct{}{ "2026-06-10": {10: {}, 20: {}, 30: {}}, "2026-06-11": {1: {}, 50: {}}, } var cohortRangeFrom, cohortRangeTo string requestedUnions := map[string]int{} source := &AslanMongoDashboardSource{ statTimezone: "Asia/Shanghai", retentionCohortsLoader: func(_ context.Context, from time.Time, to time.Time, _ externalDashboardCountryFilter) (map[string]map[string][]int64, error) { cohortRangeFrom, cohortRangeTo = dashboardSQLDate(from), dashboardSQLDate(to) return earlier, nil }, activeUserSetLoader: func(_ context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) { requestedUnions[activeDate] = len(userIDs) return activeByDay[activeDate], nil }, } if err := source.applyRetention(context.Background(), startDate, externalDashboardCountryFilter{}, cohorts, grid); err != nil { t.Fatalf("apply retention: %v", err) } if cohortRangeFrom != "2026-05-11" || cohortRangeTo != "2026-06-10" { t.Fatalf("expected earlier cohort range [start-30, start), got [%s, %s)", cohortRangeFrom, cohortRangeTo) } // 每个观察日只做一次活跃点查,且是三个 offset cohort 的并集。 // 06-10: D1{10,11}∪D7{20,21,22}∪D30{30}=6;06-11: D1 合并 cohort{1,2,50}∪D7{40}=4。 if requestedUnions["2026-06-10"] != 6 || requestedUnions["2026-06-11"] != 4 { t.Fatalf("expected union sizes 6/4, got %#v", requestedUnions) } day1SA := grid.at("2026-06-10", "SA") if day1SA.D1Users != 1 || day1SA.D1Base != 2 || day1SA.D7Users != 1 || day1SA.D7Base != 3 { t.Fatalf("obs 06-10 SA retention mismatch: %+v", day1SA) } day1EG := grid.at("2026-06-10", "EG") if day1EG.D30Users != 1 || day1EG.D30Base != 1 { t.Fatalf("obs 06-10 EG d30 mismatch: %+v", day1EG) } day2SA := grid.at("2026-06-11", "SA") if day2SA.D1Users != 2 || day2SA.D1Base != 3 { t.Fatalf("obs 06-11 SA d1 (merged cohort) mismatch: %+v", day2SA) } day2EG := grid.at("2026-06-11", "EG") if day2EG.D7Users != 0 || day2EG.D7Base != 1 { t.Fatalf("obs 06-11 EG d7 real-zero mismatch: %+v", day2EG) } if day2EG.D30Base != 0 || day2SA.D30Base != 0 { t.Fatalf("obs 06-11 d30 should have no cohort: SA=%+v EG=%+v", day2SA, day2EG) } }