397 lines
14 KiB
Go
397 lines
14 KiB
Go
package payment
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/config"
|
||
"hyapp-admin-server/internal/modules/dashboard"
|
||
|
||
"go.mongodb.org/mongo-driver/v2/bson"
|
||
)
|
||
|
||
func yumiBillSourceConfig() config.FinanceBillSourceConfig {
|
||
return config.FinanceBillSourceConfig{
|
||
Enabled: true,
|
||
AppCode: "yumi",
|
||
AppName: "Yumi",
|
||
SysOrigin: "LIKEI",
|
||
MongoDatabase: "test",
|
||
MongoCollection: "in_app_purchase_details",
|
||
RequestTimeout: time.Second,
|
||
}
|
||
}
|
||
|
||
func TestLegacyRechargeBillDTOMapsGooglePurchase(t *testing.T) {
|
||
amount, err := bson.ParseDecimal128("164.99")
|
||
if err != nil {
|
||
t.Fatalf("parse amount: %v", err)
|
||
}
|
||
amountUSD, err := bson.ParseDecimal128("2.99")
|
||
if err != nil {
|
||
t.Fatalf("parse amount usd: %v", err)
|
||
}
|
||
createTime := time.UnixMilli(1_751_000_000_000).UTC()
|
||
dto := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{
|
||
ID: "175100000000012345",
|
||
OrderID: "GPA.1234-5678-9012-34567",
|
||
AcceptUserID: 42,
|
||
CountryCode: "TR",
|
||
Currency: "try",
|
||
Amount: amount,
|
||
AmountUSD: amountUSD,
|
||
Status: "SUCCESS",
|
||
Factory: legacyPurchaseFactory{Platform: "Android", FactoryCode: "GOOGLE"},
|
||
Products: []legacyPurchaseProduct{
|
||
{Name: "GOLD", Content: "560000"},
|
||
{Name: "PROPS", Content: "AVATAR_FRAME"},
|
||
},
|
||
CreateTime: createTime,
|
||
})
|
||
|
||
if dto.AppCode != "yumi" || dto.TransactionID != "175100000000012345" {
|
||
t.Fatalf("unexpected identity: %+v", dto)
|
||
}
|
||
if dto.RechargeType != "google_play_recharge" || dto.ProviderCode != "google" {
|
||
t.Fatalf("unexpected channel mapping: %+v", dto)
|
||
}
|
||
if dto.Status != "succeeded" || dto.ExternalRef != "GPA.1234-5678-9012-34567" {
|
||
t.Fatalf("unexpected status/external ref: %+v", dto)
|
||
}
|
||
if dto.CoinAmount != 560000 {
|
||
t.Fatalf("coin amount = %d, want 560000", dto.CoinAmount)
|
||
}
|
||
if dto.USDMinorAmount != 299 {
|
||
t.Fatalf("usd minor = %d, want 299", dto.USDMinorAmount)
|
||
}
|
||
if dto.UserPaidCurrencyCode != "TRY" || dto.UserPaidAmountMicro != 164_990_000 {
|
||
t.Fatalf("unexpected user paid: %+v", dto)
|
||
}
|
||
// 谷歌账单初始必须是未同步(0),等 Orders API 实付缓存回填后才算同步完成。
|
||
if dto.CreatedAtMS != createTime.UnixMilli() || dto.PaidSyncedAtMS != 0 {
|
||
t.Fatalf("unexpected timestamps: %+v", dto)
|
||
}
|
||
|
||
mifapayDTO := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{
|
||
ID: "175100000000054321",
|
||
Factory: legacyPurchaseFactory{Platform: "H5", FactoryCode: "MIFA_PAY"},
|
||
CreateTime: createTime,
|
||
})
|
||
if mifapayDTO.RechargeType != "mifapay" || mifapayDTO.PaidSyncedAtMS != mifapayDTO.CreatedAtMS {
|
||
t.Fatalf("unexpected mifapay mapping: %+v", mifapayDTO)
|
||
}
|
||
}
|
||
|
||
func TestLegacyRechargeTypeMapping(t *testing.T) {
|
||
cases := map[string]string{
|
||
"GOOGLE": "google_play_recharge",
|
||
"APPLE": "apple_recharge",
|
||
"HUAWEI": "huawei_recharge",
|
||
"TELEGRAM": "telegram_recharge",
|
||
"PAYER_MAX": "web_recharge",
|
||
"": "web_recharge",
|
||
}
|
||
for factoryCode, want := range cases {
|
||
if got := legacyRechargeType(factoryCode); got != want {
|
||
t.Fatalf("legacyRechargeType(%q) = %q, want %q", factoryCode, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLegacyDecimalScaledHandlesTypes(t *testing.T) {
|
||
decimal, err := bson.ParseDecimal128("6.99")
|
||
if err != nil {
|
||
t.Fatalf("parse decimal: %v", err)
|
||
}
|
||
if got := legacyDecimalToMinor(decimal); got != 699 {
|
||
t.Fatalf("decimal minor = %d, want 699", got)
|
||
}
|
||
if got := legacyDecimalToMicro("54.99"); got != 54_990_000 {
|
||
t.Fatalf("string micro = %d, want 54990000", got)
|
||
}
|
||
if got := legacyDecimalToMinor(int64(3)); got != 300 {
|
||
t.Fatalf("int64 minor = %d, want 300", got)
|
||
}
|
||
if got := legacyDecimalToMinor(nil); got != 0 {
|
||
t.Fatalf("nil minor = %d, want 0", got)
|
||
}
|
||
if got := legacyDecimalToMinor("not-a-number"); got != 0 {
|
||
t.Fatalf("invalid minor = %d, want 0", got)
|
||
}
|
||
}
|
||
|
||
type staticLegacyRegionResolver struct {
|
||
codes []string
|
||
handled bool
|
||
}
|
||
|
||
func (r staticLegacyRegionResolver) CountryCodesForRegion(context.Context, string, int64) ([]string, bool, error) {
|
||
return r.codes, r.handled, nil
|
||
}
|
||
|
||
func TestLegacyBillFilterBuildsQuery(t *testing.T) {
|
||
source := &MongoRechargeBillSource{
|
||
config: yumiBillSourceConfig(),
|
||
regionResolvers: []legacyRegionCountryResolver{staticLegacyRegionResolver{codes: []string{"tr", "SA"}, handled: true}},
|
||
}
|
||
filter, regionCodes, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{
|
||
Keyword: "GPA.1",
|
||
RegionID: 9_000_000_000_000_000_001,
|
||
StartAtMS: 1_000,
|
||
EndAtMS: 2_000,
|
||
})
|
||
if err != nil || !matchable {
|
||
t.Fatalf("billFilter err=%v matchable=%v", err, matchable)
|
||
}
|
||
if filter["sysOrigin"] != "LIKEI" || filter["status"] != "SUCCESS" {
|
||
t.Fatalf("unexpected base filter: %v", filter)
|
||
}
|
||
timeRange, ok := filter["createTime"].(bson.M)
|
||
if !ok || !timeRange["$gte"].(time.Time).Equal(time.UnixMilli(1_000)) || !timeRange["$lt"].(time.Time).Equal(time.UnixMilli(2_000)) {
|
||
t.Fatalf("unexpected time range: %v", filter["createTime"])
|
||
}
|
||
// likei 账单不带国家码;区域条件必须以国家码清单返回,由聚合阶段按付款用户资料匹配。
|
||
if len(regionCodes) != 2 || regionCodes[0] != "TR" || regionCodes[1] != "SA" {
|
||
t.Fatalf("unexpected region codes: %v", regionCodes)
|
||
}
|
||
if _, ok := filter["countryCode"]; ok {
|
||
t.Fatalf("bill countryCode filter should not be used: %v", filter)
|
||
}
|
||
if _, ok := filter["$or"]; !ok {
|
||
t.Fatalf("keyword filter missing: %v", filter)
|
||
}
|
||
}
|
||
|
||
func TestLegacyBillFilterExcludesFreightGoldRecharge(t *testing.T) {
|
||
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
|
||
filter, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RechargeType: "third_party"})
|
||
if err != nil || !matchable {
|
||
t.Fatalf("billFilter err=%v matchable=%v", err, matchable)
|
||
}
|
||
nor, ok := filter["$nor"].(bson.A)
|
||
if !ok || len(nor) != len(legacyFreightCommodityFields) {
|
||
t.Fatalf("freight exclusion missing: %v", filter)
|
||
}
|
||
for _, condition := range nor {
|
||
item, ok := condition.(bson.M)
|
||
if !ok {
|
||
t.Fatalf("unexpected freight condition: %#v", condition)
|
||
}
|
||
for _, value := range item {
|
||
typed, ok := value.(bson.M)
|
||
if !ok || typed["$in"] == nil {
|
||
t.Fatalf("unexpected freight condition value: %#v", value)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) {
|
||
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
|
||
_, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RegionID: 123})
|
||
if err != nil {
|
||
t.Fatalf("billFilter err=%v", err)
|
||
}
|
||
if matchable {
|
||
t.Fatalf("unresolved region should not be matchable")
|
||
}
|
||
}
|
||
|
||
type fakeDashboardRechargeSource struct {
|
||
appCode string
|
||
overview map[string]any
|
||
query dashboard.StatisticsQuery
|
||
calls int
|
||
}
|
||
|
||
func (s *fakeDashboardRechargeSource) AppCode() string {
|
||
return s.appCode
|
||
}
|
||
|
||
func (s *fakeDashboardRechargeSource) StatisticsOverview(_ context.Context, query dashboard.StatisticsQuery) (map[string]any, error) {
|
||
s.query = query
|
||
s.calls++
|
||
return s.overview, nil
|
||
}
|
||
|
||
func TestLegacyCoinSellerDashboardStatsMergeSummary(t *testing.T) {
|
||
dashboardSource := &fakeDashboardRechargeSource{
|
||
appCode: "Yumi",
|
||
overview: map[string]any{
|
||
"coin_seller_recharge_usd_minor": int64(350_010),
|
||
},
|
||
}
|
||
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
|
||
source.SetDashboardCoinSellerSource(dashboardSource)
|
||
|
||
shanghai, err := time.LoadLocation("Asia/Shanghai")
|
||
if err != nil {
|
||
t.Fatalf("load location: %v", err)
|
||
}
|
||
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
|
||
summary := rechargeBillSummaryDTO{
|
||
GooglePlay: rechargeBillSummaryBucketDTO{BillCount: 2, USDMinorAmount: 10_000},
|
||
ThirdParty: rechargeBillSummaryBucketDTO{BillCount: 3, USDMinorAmount: 20_000},
|
||
}
|
||
if err := source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
|
||
StartAtMS: start.UnixMilli(),
|
||
EndAtMS: start.AddDate(0, 0, 1).UnixMilli(),
|
||
}, 480, &summary); err != nil {
|
||
t.Fatalf("mergeCoinSellerSummary: %v", err)
|
||
}
|
||
legacyRecomputeSummaryTotal(&summary)
|
||
|
||
if summary.CoinSeller.USDMinorAmount != 350_010 {
|
||
t.Fatalf("coin seller usd = %d, want 350010", summary.CoinSeller.USDMinorAmount)
|
||
}
|
||
if summary.Total.USDMinorAmount != 380_010 || summary.Total.BillCount != 5 {
|
||
t.Fatalf("unexpected total after coin seller merge: %+v", summary.Total)
|
||
}
|
||
if dashboardSource.calls != 1 || dashboardSource.query.StatTZ != "Asia/Shanghai" {
|
||
t.Fatalf("dashboard query mismatch: calls=%d query=%+v", dashboardSource.calls, dashboardSource.query)
|
||
}
|
||
}
|
||
|
||
func TestLegacyCoinSellerDashboardStatsRejectsPartialDayRange(t *testing.T) {
|
||
dashboardSource := &fakeDashboardRechargeSource{
|
||
appCode: "yumi",
|
||
overview: map[string]any{"coin_seller_recharge_usd_minor": int64(100)},
|
||
}
|
||
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
|
||
source.SetDashboardCoinSellerSource(dashboardSource)
|
||
|
||
shanghai, err := time.LoadLocation("Asia/Shanghai")
|
||
if err != nil {
|
||
t.Fatalf("load location: %v", err)
|
||
}
|
||
start := time.Date(2026, 7, 5, 10, 0, 0, 0, shanghai)
|
||
summary := rechargeBillSummaryDTO{}
|
||
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
|
||
StartAtMS: start.UnixMilli(),
|
||
EndAtMS: start.Add(2 * time.Hour).UnixMilli(),
|
||
}, 480, &summary)
|
||
if err == nil {
|
||
t.Fatalf("partial day range should fail")
|
||
}
|
||
if dashboardSource.calls != 0 {
|
||
t.Fatalf("dashboard source should not be called for partial day range")
|
||
}
|
||
|
||
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{}, 480, &summary)
|
||
if err == nil {
|
||
t.Fatalf("missing day range should fail")
|
||
}
|
||
}
|
||
|
||
func TestLegacyCoinSellerDashboardStatsRequiresMetricField(t *testing.T) {
|
||
dashboardSource := &fakeDashboardRechargeSource{
|
||
appCode: "yumi",
|
||
overview: map[string]any{"coin_seller_recharge_usd_minor": nil},
|
||
}
|
||
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
|
||
source.SetDashboardCoinSellerSource(dashboardSource)
|
||
|
||
shanghai, err := time.LoadLocation("Asia/Shanghai")
|
||
if err != nil {
|
||
t.Fatalf("load location: %v", err)
|
||
}
|
||
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
|
||
summary := rechargeBillSummaryDTO{}
|
||
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
|
||
StartAtMS: start.UnixMilli(),
|
||
EndAtMS: start.AddDate(0, 0, 1).UnixMilli(),
|
||
}, 480, &summary)
|
||
if err == nil {
|
||
t.Fatalf("missing dashboard coin seller field should fail")
|
||
}
|
||
if dashboardSource.calls != 1 {
|
||
t.Fatalf("dashboard source calls = %d, want 1", dashboardSource.calls)
|
||
}
|
||
}
|
||
|
||
func TestLegacyCoinSellerDashboardStatsMergeOverview(t *testing.T) {
|
||
dashboardSource := &fakeDashboardRechargeSource{
|
||
appCode: "yumi",
|
||
overview: map[string]any{
|
||
"coin_seller_recharge_usd_minor": int64(5_000),
|
||
"daily_series": []map[string]any{
|
||
{"stat_day": "2026-07-05", "coin_seller_recharge_usd_minor": int64(4_200)},
|
||
{"stat_day": "2026-07-06", "coin_seller_recharge_usd_minor": int64(800)},
|
||
},
|
||
"country_breakdown": []map[string]any{
|
||
{"country_code": "SA", "coin_seller_recharge_usd_minor": int64(4_200)},
|
||
{"country_code": "AE", "coin_seller_recharge_usd_minor": int64(800)},
|
||
},
|
||
},
|
||
}
|
||
source := &MongoRechargeBillSource{
|
||
config: yumiBillSourceConfig(),
|
||
regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{
|
||
AppCode: "yumi",
|
||
RegionID: 101,
|
||
Name: "Gulf",
|
||
Countries: []string{"SA", "AE"},
|
||
}}}},
|
||
}
|
||
source.SetDashboardCoinSellerSource(dashboardSource)
|
||
|
||
shanghai, err := time.LoadLocation("Asia/Shanghai")
|
||
if err != nil {
|
||
t.Fatalf("load location: %v", err)
|
||
}
|
||
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
|
||
overview := rechargeBillOverviewDTO{
|
||
Daily: []rechargeBillDailyBucketDTO{{Date: "2026-07-05", ThirdPartyUsdMinor: 2_000}},
|
||
Regions: []rechargeBillRegionBucketDTO{{
|
||
RegionID: 101,
|
||
Name: "Gulf",
|
||
UsdMinorAmount: 2_000,
|
||
}},
|
||
}
|
||
if err := source.mergeCoinSellerOverview(context.Background(), legacyRechargeBillQuery{
|
||
StartAtMS: start.UnixMilli(),
|
||
EndAtMS: start.AddDate(0, 0, 2).UnixMilli(),
|
||
}, 480, &overview); err != nil {
|
||
t.Fatalf("mergeCoinSellerOverview: %v", err)
|
||
}
|
||
if len(overview.Daily) != 2 {
|
||
t.Fatalf("daily buckets = %d, want 2: %+v", len(overview.Daily), overview.Daily)
|
||
}
|
||
if overview.Daily[0].Date != "2026-07-05" || overview.Daily[0].CoinSellerUsdMinor != 4_200 {
|
||
t.Fatalf("existing day not merged: %+v", overview.Daily)
|
||
}
|
||
if overview.Daily[1].Date != "2026-07-06" || overview.Daily[1].CoinSellerUsdMinor != 800 {
|
||
t.Fatalf("new day not appended: %+v", overview.Daily)
|
||
}
|
||
if len(overview.Regions) != 1 || overview.Regions[0].UsdMinorAmount != 7_000 {
|
||
t.Fatalf("region not merged: %+v", overview.Regions)
|
||
}
|
||
}
|
||
|
||
func TestLegacyRegionLookupStages(t *testing.T) {
|
||
if stages := legacyRegionLookupStages(nil); len(stages) != 0 {
|
||
t.Fatalf("empty codes should not add stages: %v", stages)
|
||
}
|
||
stages := legacyRegionLookupStages([]string{"TR", "SA"})
|
||
if len(stages) != 3 {
|
||
t.Fatalf("expected lookup/addFields/match stages, got %d", len(stages))
|
||
}
|
||
lookup, ok := stages[0][0].Value.(bson.M)
|
||
if !ok || lookup["from"] != legacyUserProfileCollection || lookup["localField"] != "acceptUserId" || lookup["foreignField"] != "_id" {
|
||
t.Fatalf("unexpected lookup stage: %v", stages[0])
|
||
}
|
||
match, ok := stages[2][0].Value.(bson.M)
|
||
if !ok {
|
||
t.Fatalf("unexpected match stage: %v", stages[2])
|
||
}
|
||
condition, ok := match["legacyBillCountry"].(bson.M)
|
||
if !ok {
|
||
t.Fatalf("match stage missing country condition: %v", match)
|
||
}
|
||
codes, ok := condition["$in"].([]string)
|
||
if !ok || len(codes) != 2 || codes[0] != "TR" || codes[1] != "SA" {
|
||
t.Fatalf("unexpected region codes in match: %v", condition["$in"])
|
||
}
|
||
}
|