394 lines
15 KiB
Go
394 lines
15 KiB
Go
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)
|
||
}
|