hyapp-server/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go
2026-07-16 18:51:58 +08:00

563 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dashboard
import (
"context"
"database/sql/driver"
"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 TestAslanMongoLegacyGameFlowsUseSelectedDayNumberForRiyadhSnapshots(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
// Social BI 仍按上海时区选择日期wallet 游戏快照虽按 Riyadh 切日,但 group_num 是自然日号,
// 查询必须直接使用所选 yyyyMMdd不能把请求时间点换区后再偏移日期。
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load Shanghai location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, location)
end := start.AddDate(0, 0, 1)
grid := newLegacyMongoGrid(start, end)
// 国家行存在时也不能继承全 App 游戏值;它只用于验证 assembleRange 的维度隔离。
grid.at("2026-07-05", "SA").NewUsers = 1
source := &AslanMongoDashboardSource{
legacyDB: db,
legacyWalletDatabase: "atyou_wallet",
sysOrigin: "ATYOU",
requestTimeout: time.Second,
}
arguments := []driver.Value{"ATYOU", "20260705", "20260706"}
for _, eventType := range aslanLegacyGameEventTypes {
arguments = append(arguments, eventType)
}
mock.ExpectQuery("FROM `atyou_wallet`\\.wallet_gold_count[\\s\\S]*sys_origin = \\?[\\s\\S]*event_type IN").
WithArgs(arguments...).
WillReturnRows(sqlmock.NewRows([]string{"group_num", "type", "amount"}).
AddRow(20260705, 1, int64(12_345)).
AddRow(20260705, 0, int64(2_345)))
if err := source.loadLegacyGameFlows(context.Background(), start, end, grid); err != nil {
t.Fatalf("loadLegacyGameFlows: %v", err)
}
flow := grid.gameFlows["2026-07-05"]
if flow == nil || flow.Turnover != 123 || flow.Payout != 23 {
t.Fatalf("legacy game flow mismatch: %+v", flow)
}
overview := source.assembleRange(grid, externalDashboardCountryFilter{}, false, true, true)
if overview.Total.GameTurnover != 123 || overview.Total.GamePayout != 23 || overview.Total.GameProfit != 100 {
t.Fatalf("legacy game overview mismatch: %+v", overview.Total)
}
assertInt64(t, overview.DailySeries[0]["game_turnover"], 123)
assertInt64(t, overview.DailySeries[0]["game_payout"], 23)
assertInt64(t, overview.DailySeries[0]["game_profit"], 100)
if overview.DailySeries[0]["game_players"] != nil {
t.Fatalf("game players must stay unavailable: %#v", overview.DailySeries[0])
}
if len(overview.CountryBreakdown) != 1 || overview.CountryBreakdown[0]["game_turnover"] != nil {
t.Fatalf("country rows must not contain global game totals: %#v", overview.CountryBreakdown)
}
// 即使 grid 中已有全局快照,区域筛选路径传入 unavailable 后也必须继续输出 nil防止全局值泄漏。
regionOverview := source.assembleRange(grid, externalDashboardCountryFilter{Applied: true, RegionID: 9}, false, true, false)
if regionOverview.DailySeries[0]["game_turnover"] != nil || regionOverview.Total.GameTurnover != 0 {
t.Fatalf("region overview must not expose global game totals: daily=%#v total=%+v", regionOverview.DailySeries, regionOverview.Total)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAslanMongoLegacyGameWhitelistExcludesRewardOrigins(t *testing.T) {
seen := make(map[string]struct{}, len(aslanLegacyGameEventTypes))
for _, eventType := range aslanLegacyGameEventTypes {
if _, duplicated := seen[eventType]; duplicated {
t.Fatalf("duplicate game event type %s", eventType)
}
seen[eventType] = struct{}{}
}
for _, required := range []string{"HOT_GAME_201", "HKYS_GAME_101", "BAISHUN_GAME_1182", "YOMI_GAME_282"} {
if _, ok := seen[required]; !ok {
t.Fatalf("confirmed game event type %s missing from whitelist", required)
}
}
for _, reward := range []string{"HOT_GAME_200", "BAISHUN_GAME_1085"} {
if _, ok := seen[reward]; ok {
t.Fatalf("reward origin %s must not be counted as game flow", reward)
}
}
}
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, true, false)
assertMetricSourceAvailable(t, available, "coin_seller_recharge_usd_minor")
assertMetricSourceAvailable(t, available, "game_turnover")
unavailable := aslanMongoDashboardMetricSources(false, false, false, false)
assertMetricSourceUnavailable(t, unavailable, "coin_seller_recharge_usd_minor")
assertMetricSourceUnavailable(t, unavailable, "game_turnover")
regionFiltered := aslanMongoDashboardMetricSources(false, true, false, true)
assertMetricSourceUnavailable(t, regionFiltered, "game_turnover")
}
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, false)
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}=606-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)
}
}