hyapp-server/server/admin/internal/modules/databi/finance_recharge_test.go
2026-07-12 00:47:20 +08:00

218 lines
11 KiB
Go

package databi
import (
"context"
"testing"
"hyapp-admin-server/internal/modules/payment"
)
type fakeFinanceRechargeOverviewSource struct {
calls []payment.FinanceRechargeOverviewQuery
}
func (f *fakeFinanceRechargeOverviewSource) FinanceRechargeOverview(_ context.Context, query payment.FinanceRechargeOverviewQuery) (payment.FinanceRechargeOverview, error) {
f.calls = append(f.calls, query)
if query.EndAtMS == 200 {
return payment.FinanceRechargeOverview{Daily: []payment.FinanceRechargeDailyBucket{{
Date: "1970-01-01", GoogleUSDMinor: 50, ThirdPartyUSDMinor: 100, CoinSellerUSDMinor: 50,
}}}, nil
}
region := payment.FinanceRechargeRegionOverview{RegionID: 7, TotalUSDMinor: 150}
if len(query.ScopeRegionIDs) > 0 {
region.DailyAvailable = true
region.Daily = []payment.FinanceRechargeDailyBucket{{
Date: "2026-07-10", GoogleUSDMinor: 40, ThirdPartyUSDMinor: 50, CoinSellerUSDMinor: 60,
}}
}
return payment.FinanceRechargeOverview{
Daily: []payment.FinanceRechargeDailyBucket{
{Date: "2026-07-10", GoogleUSDMinor: 100, ThirdPartyUSDMinor: 200, CoinSellerUSDMinor: 300},
{Date: "2026-07-11", GoogleUSDMinor: 10, ThirdPartyUSDMinor: 20, CoinSellerUSDMinor: 30},
},
Regions: []payment.FinanceRechargeRegionOverview{region},
}, nil
}
func TestApplyFinanceRechargeOverviewReplacesDashboardRechargeMetrics(t *testing.T) {
finance := &fakeFinanceRechargeOverviewSource{}
service := &Service{financeRecharge: finance}
out := AppOverview{
AppCode: "aslan",
Total: map[string]any{
"active_users": int64(10), "paid_users": int64(2), "recharge_usd_minor": int64(9999),
"coin_seller_recharge_usd_minor": int64(8888), "arpu_delta_rate": 0.9, "arppu_delta_rate": 0.8,
},
DailySeries: []map[string]any{{
"stat_day": "2026-07-10", "active_users": int64(5), "paid_users": int64(1),
"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}},
RegionBreakdown: []map[string]any{{
"region_id": int64(7), "active_users": int64(5), "paid_users": int64(1),
"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}, {
"region_id": int64(8), "active_users": int64(3), "paid_users": int64(1),
"recharge_usd_minor": int64(7777), "coin_seller_recharge_usd_minor": int64(6666),
}},
DailyRegionBreakdown: []map[string]any{{
"region_id": int64(7), "stat_day": "2026-07-10", "active_users": int64(5), "paid_users": int64(1),
"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}, {
"region_id": int64(8), "stat_day": "2026-07-10", "active_users": int64(3), "paid_users": int64(1),
"recharge_usd_minor": int64(7777), "coin_seller_recharge_usd_minor": int64(6666),
}},
CountryBreakdown: []map[string]any{{
"country_code": "SA", "recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}},
DailyCountryBreakdown: []map[string]any{{
"country_code": "SA", "stat_day": "2026-07-10", "recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}},
}
query := OverviewQuery{StartMS: 200, EndMS: 300}
regions := []RegionInfo{
{AppCode: "aslan", RegionID: 7, RegionCode: "SA", RegionName: "沙特"},
{AppCode: "aslan", RegionID: 8, RegionCode: "AE", RegionName: "阿联酋"},
}
if err := service.applyFinanceRechargeOverview(context.Background(), &out, query, regions, false, nil); err != nil {
t.Fatalf("apply Finance recharge overview failed: %v", err)
}
if got := mustMetric(t, out.Total, "recharge_usd_minor"); got != 660 {
t.Fatalf("total recharge should use Finance channels: got %d", got)
}
if got := mustMetric(t, out.Total, "coin_seller_recharge_usd_minor"); got != 330 {
t.Fatalf("coin seller recharge should use Finance verified/H5 amount: got %d", got)
}
if got := mustMetric(t, out.Total, "mifapay_recharge_usd_minor"); got != 220 {
t.Fatalf("third-party recharge should use Finance amount: got %d", got)
}
if delta, ok := out.Total["recharge_delta_rate"].(float64); !ok || delta != 2.3 {
t.Fatalf("recharge delta should compare the previous Finance range: %#v", out.Total["recharge_delta_rate"])
}
if out.Total["arpu_delta_rate"] != nil || out.Total["arppu_delta_rate"] != nil {
t.Fatalf("amount-derived deltas must not retain the old Dashboard numerator: %#v", out.Total)
}
if out.Total["arpu_usd_minor"] != nil || out.Total["arppu_usd_minor"] != nil || out.Total["finance_recharge_aligned"] != true {
t.Fatalf("Finance recharge must not be divided by old Dashboard users: %#v", out.Total)
}
if len(out.DailySeries) != 2 || mustMetric(t, out.DailySeries[0], "recharge_usd_minor") != 600 || mustMetric(t, out.DailySeries[1], "recharge_usd_minor") != 60 {
t.Fatalf("daily series should be replaced and missing Finance days appended: %#v", out.DailySeries)
}
if mustMetric(t, out.RegionBreakdown[0], "recharge_usd_minor") != 150 || out.RegionBreakdown[0]["coin_seller_recharge_usd_minor"] != nil {
t.Fatalf("global region rows should use Finance totals without inventing channel splits: %#v", out.RegionBreakdown)
}
if mustMetric(t, out.RegionBreakdown[1], "recharge_usd_minor") != 0 || out.DailyRegionBreakdown[0]["recharge_usd_minor"] != nil || out.DailyRegionBreakdown[1]["recharge_usd_minor"] != nil {
t.Fatalf("regions/days missing from Finance must not retain Dashboard recharge: region=%#v daily=%#v", out.RegionBreakdown, out.DailyRegionBreakdown)
}
if out.CountryBreakdown[0]["recharge_usd_minor"] != nil || out.CountryBreakdown[0]["coin_seller_recharge_usd_minor"] != nil || out.DailyCountryBreakdown[0]["recharge_usd_minor"] != nil {
t.Fatalf("country recharge must be unavailable because Finance has no country dimension: %#v %#v", out.CountryBreakdown, out.DailyCountryBreakdown)
}
if len(finance.calls) != 2 || !finance.calls[0].IncludeRegions || finance.calls[1].IncludeRegions || finance.calls[0].TZOffsetMinutes != 480 {
t.Fatalf("current/previous Finance queries mismatch: %#v", finance.calls)
}
}
func TestApplyFinanceRechargeOverviewLoadsDailyChannelsOnlyForExplicitRegions(t *testing.T) {
finance := &fakeFinanceRechargeOverviewSource{}
service := &Service{financeRecharge: finance}
out := AppOverview{
AppCode: "aslan",
Total: map[string]any{},
RegionBreakdown: []map[string]any{{"region_id": int64(7)}},
DailyRegionBreakdown: []map[string]any{{"region_id": int64(7), "stat_day": "2026-07-10"}},
}
query := OverviewQuery{StartMS: 200, EndMS: 300, RegionIDs: []int64{7}}
regions := []RegionInfo{{AppCode: "aslan", RegionID: 7, RegionCode: "SA", RegionName: "沙特"}}
if err := service.applyFinanceRechargeOverview(context.Background(), &out, query, regions, false, nil); err != nil {
t.Fatalf("apply scoped Finance recharge overview failed: %v", err)
}
if mustMetric(t, out.RegionBreakdown[0], "recharge_usd_minor") != 150 || mustMetric(t, out.DailyRegionBreakdown[0], "coin_seller_recharge_usd_minor") != 60 {
t.Fatalf("explicit regions should receive Finance daily channels: region=%#v daily=%#v", out.RegionBreakdown, out.DailyRegionBreakdown)
}
if len(finance.calls) != 2 || len(finance.calls[0].ScopeRegionIDs) != 1 || finance.calls[0].ScopeRegionIDs[0] != 7 || finance.calls[1].ScopeRegionIDs[0] != 7 {
t.Fatalf("current and previous windows must keep the explicit region scope: %#v", finance.calls)
}
}
func TestApplyFinanceRechargeKpiWindowUsesFinanceTotalsAndRegions(t *testing.T) {
finance := &fakeFinanceRechargeOverviewSource{}
service := &Service{financeRecharge: finance}
total := map[string]any{"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888)}
regions := map[int64]map[string]any{7: {
"region_id": int64(7), "recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}}
if err := service.applyFinanceRechargeKpiWindow(context.Background(), "aslan", 200, 300, total, regions); err != nil {
t.Fatalf("apply Finance KPI window failed: %v", err)
}
if mustMetric(t, total, "recharge_usd_minor") != 660 || mustMetric(t, regions[7], "recharge_usd_minor") != 150 {
t.Fatalf("KPI window should use Finance total/region values: total=%#v regions=%#v", total, regions)
}
if len(finance.calls) != 1 || !finance.calls[0].IncludeRegions {
t.Fatalf("KPI window should issue one Finance query with regions: %#v", finance.calls)
}
}
func TestOverlayFinanceRechargeRequirementsUsesFinanceAmounts(t *testing.T) {
sections := []map[string]any{{
"key": "revenue",
"total": map[string]any{"recharge_usd_minor": int64(9999), "recharge_users": int64(4), "arppu_usd_minor": int64(123)},
"average": map[string]any{"recharge_usd_minor": float64(9999)},
"daily_series": []any{
map[string]any{"stat_day": "2026-07-10", "recharge_usd_minor": int64(9999), "recharge_users": int64(3)},
map[string]any{"stat_day": "2026-07-11", "recharge_usd_minor": int64(9999), "recharge_users": int64(1)},
},
"columns": []any{map[string]any{"key": "coin_seller_recharge_usd_minor"}},
"metric_definitions": []any{map[string]any{"metric": "coin_seller_recharge_usd_minor", "definition": "old"}},
}}
overlayFinanceRechargeRequirements(sections, payment.FinanceRechargeOverview{Daily: []payment.FinanceRechargeDailyBucket{
{Date: "2026-07-10", GoogleUSDMinor: 100, ThirdPartyUSDMinor: 200, CoinSellerUSDMinor: 300},
{Date: "2026-07-11", GoogleUSDMinor: 10, ThirdPartyUSDMinor: 20, CoinSellerUSDMinor: 30},
}})
total := sections[0]["total"].(map[string]any)
daily := anyRowSlice(sections[0]["daily_series"])
average := sections[0]["average"].(map[string]any)
if mustMetric(t, total, "recharge_usd_minor") != 660 || mustMetric(t, total, "coin_seller_recharge_usd_minor") != 330 {
t.Fatalf("requirements total should use Finance channels: %#v", total)
}
if mustMetric(t, daily[0], "recharge_usd_minor") != 600 || mustMetric(t, daily[1], "recharge_usd_minor") != 60 {
t.Fatalf("requirements daily should use Finance channels: %#v", daily)
}
if average["recharge_usd_minor"] != float64(330) || total["recharge_users"] != int64(4) || total["arppu_usd_minor"] != nil {
t.Fatalf("requirements average/non-amount fields mismatch: total=%#v average=%#v", total, average)
}
if sections[0]["recharge_source"] != "finance" || sections[0]["finance_recharge_available"] != true {
t.Fatalf("requirements must expose Finance source metadata: %#v", sections[0])
}
}
func TestFinanceRechargeRequirementsCohortAmountsAreUnavailable(t *testing.T) {
query := RequirementsQuery{UserRole: "host", PayerType: "all", NewUserType: "all"}
if !requirementsHasCohortFilter(query) || !requirementsIncludesRevenue("revenue") {
t.Fatalf("cohort/revenue detection mismatch")
}
sections := []map[string]any{{
"key": "revenue",
"total": map[string]any{"recharge_usd_minor": int64(9999), "recharge_users": int64(2)},
"average": map[string]any{"recharge_usd_minor": float64(9999)},
"daily_series": []any{map[string]any{"stat_day": "2026-07-10", "recharge_usd_minor": int64(9999)}},
}}
markFinanceRechargeRequirementsUnavailable(sections)
total := sections[0]["total"].(map[string]any)
if total["recharge_usd_minor"] != nil || total["recharge_users"] != int64(2) || sections[0]["finance_recharge_available"] != false {
t.Fatalf("cohort should retain counts but hide non-Finance amounts: %#v", sections[0])
}
}
func mustMetric(t *testing.T, row map[string]any, key string) int64 {
t.Helper()
value, ok := metricInt64(row[key])
if !ok {
t.Fatalf("metric %s missing from %#v", key, row)
}
return value
}