hyapp-server/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go
2026-07-06 11:46:58 +08:00

376 lines
14 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"
"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)
}