hyapp-server/server/admin/internal/modules/dashboard/external_dashboard_test.go
2026-07-02 10:53:44 +08:00

337 lines
12 KiB
Go

package dashboard
import (
"context"
"database/sql/driver"
"math"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"hyapp-admin-server/internal/config"
)
func TestMySQLExternalDashboardSourceStatisticsOverview(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := NewMySQLExternalDashboardSource(db, config.DashboardExternalSourceConfig{
Enabled: true,
AppCode: "Yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
StatTimezone: "Asia/Riyadh",
RequestTimeout: time.Second,
})
if source == nil {
t.Fatal("source is nil")
}
refreshedAt := time.Date(2026, 6, 30, 23, 0, 0, 0, time.UTC)
current := externalMetricRow(refreshedAt,
7, 40, 5,
"25.00", "100.00", "20.00", "0.00", "5.00", "120.00", "2.00",
"30.00", "11.00", "900.00", "300.00", 4, "100.00", "10.00",
"300.00", 8, "110.00",
3, 6, 2, 10, 1, 20,
)
previous := externalMetricRow(refreshedAt.Add(-24*time.Hour),
5, 20, 4,
"10.00", "50.00", "10.00", "0.00", "5.00", "60.00", "0.00",
"0.00", "0.00", "400.00", "100.00", 2, "40.00", "0.00",
"180.00", 5, "100.00",
1, 5, 1, 8, 0, 10,
)
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(current...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-29", "2026-06-30").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(previous...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key")).AddRow(append([]driver.Value{"2026-06-30"}, current...)...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("country_code", "country_name")).AddRow(append([]driver.Value{"SA", "Saudi Arabia"}, current...)...))
expectExternalMetricQuery(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key", "country_code", "country_name")).AddRow(append([]driver.Value{"2026-06-30", "SA", "Saudi Arabia"}, current...)...))
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai)
overview, err := source.StatisticsOverview(context.Background(), StatisticsQuery{
AppCode: "yumi",
StatTZ: "Asia/Shanghai",
StartMS: start.UnixMilli(),
EndMS: start.AddDate(0, 0, 1).UnixMilli(),
})
if err != nil {
t.Fatalf("StatisticsOverview: %v", err)
}
assertEqual(t, overview["app_code"], "yumi")
assertEqual(t, overview["stat_tz"], "Asia/Riyadh")
assertEqual(t, overview["request_stat_tz"], "Asia/Shanghai")
assertInt64(t, overview["recharge_usd_minor"], 12_500)
assertInt64(t, overview["arppu_usd_minor"], 2_500)
assertInt64(t, overview["registered_users"], 7)
assertInt64(t, overview["recharge_users"], 5)
assertFloat(t, overview["paid_conversion_rate"], 0.125)
assertFloat(t, overview["recharge_conversion_rate"], 0.125)
assertFloat(t, overview["payer_rate_delta_pp"], -7.5)
assertInt64(t, overview["game_turnover"], 300)
assertInt64(t, overview["game_profit"], 190)
assertFloat(t, overview["d1_retention_rate"], 0.5)
if overview["coin_total"] != nil {
t.Fatalf("coin_total should be nil, got %#v", overview["coin_total"])
}
if overview["salary_usd_minor"] != nil {
t.Fatalf("salary_usd_minor should be nil, got %#v", overview["salary_usd_minor"])
}
series, ok := overview["daily_series"].([]map[string]any)
if !ok || len(series) != 1 {
t.Fatalf("daily_series mismatch: %#v", overview["daily_series"])
}
assertEqual(t, series[0]["stat_day"], "2026-06-30")
assertInt64(t, series[0]["recharge_usd_minor"], 12_500)
countries, ok := overview["country_breakdown"].([]map[string]any)
if !ok || len(countries) != 1 {
t.Fatalf("country_breakdown mismatch: %#v", overview["country_breakdown"])
}
assertEqual(t, countries[0]["country_code"], "SA")
assertEqual(t, countries[0]["country_name"], "Saudi Arabia")
sources, ok := overview["report_metric_sources"].([]map[string]any)
if !ok {
t.Fatalf("report_metric_sources mismatch: %#v", overview["report_metric_sources"])
}
assertMetricSourceUnavailable(t, sources, "coin_total")
assertMetricSourceUnavailable(t, sources, "salary_usd_minor")
assertMetricSourceUnavailable(t, sources, "coin_seller_stock_coin")
assertMetricSourceUnavailable(t, sources, "avg_mic_online_ms")
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLExternalDashboardSourceFiltersLegacyRegionCountries(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := NewMySQLExternalDashboardSource(db, config.DashboardExternalSourceConfig{
Enabled: true,
AppCode: "Yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
StatTimezone: "Asia/Riyadh",
RequestTimeout: time.Second,
}, staticDashboardRegionResolver{appCode: "yumi", regionID: 1001, countryCodes: []string{"SA", "AE"}})
if source == nil {
t.Fatal("source is nil")
}
refreshedAt := time.Date(2026, 6, 30, 23, 0, 0, 0, time.UTC)
current := externalMetricRow(refreshedAt,
7, 40, 5,
"25.00", "100.00", "20.00", "0.00", "5.00", "120.00", "2.00",
"30.00", "11.00", "900.00", "300.00", 4, "100.00", "10.00",
"300.00", 8, "110.00",
3, 6, 2, 10, 1, 20,
)
previous := externalMetricRow(refreshedAt.Add(-24*time.Hour),
5, 20, 4,
"10.00", "50.00", "10.00", "0.00", "5.00", "60.00", "0.00",
"0.00", "0.00", "400.00", "100.00", 2, "40.00", "0.00",
"180.00", 5, "100.00",
1, 5, 1, 8, 0, 10,
)
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(current...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-29", "2026-06-30", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns()).AddRow(previous...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key")).AddRow(append([]driver.Value{"2026-06-30"}, current...)...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("country_code", "country_name")).AddRow(append([]driver.Value{"SA", "Saudi Arabia"}, current...)...))
expectExternalMetricQueryWithCountries(mock, "Asia/Riyadh", "LIKEI", "2026-06-30", "2026-07-01", "AE", "SA").
WillReturnRows(sqlmock.NewRows(externalMetricColumns("period_key", "country_code", "country_name")).AddRow(append([]driver.Value{"2026-06-30", "SA", "Saudi Arabia"}, current...)...))
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai)
overview, err := source.StatisticsOverview(context.Background(), StatisticsQuery{
AppCode: "yumi",
StatTZ: "Asia/Shanghai",
StartMS: start.UnixMilli(),
EndMS: start.AddDate(0, 0, 1).UnixMilli(),
RegionID: 1001,
})
if err != nil {
t.Fatalf("StatisticsOverview: %v", err)
}
countries, ok := overview["country_breakdown"].([]map[string]any)
if !ok || len(countries) != 1 {
t.Fatalf("country_breakdown mismatch: %#v", overview["country_breakdown"])
}
assertEqual(t, countries[0]["region_id"], int64(1001))
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func expectExternalMetricQuery(mock sqlmock.Sqlmock, statTimezone string, sysOrigin string, startDate string, endDate string) *sqlmock.ExpectedQuery {
return mock.ExpectQuery("FROM country_dashboard_period_metric").
WithArgs(statTimezone, sysOrigin, startDate, endDate)
}
func expectExternalMetricQueryWithCountries(mock sqlmock.Sqlmock, statTimezone string, sysOrigin string, startDate string, endDate string, countryCodes ...string) *sqlmock.ExpectedQuery {
args := []driver.Value{statTimezone, sysOrigin, startDate, endDate}
for _, countryCode := range countryCodes {
args = append(args, countryCode)
}
return mock.ExpectQuery("FROM country_dashboard_period_metric").
WithArgs(args...)
}
type staticDashboardRegionResolver struct {
appCode string
regionID int64
countryCodes []string
}
func (r staticDashboardRegionResolver) CountryCodesForRegion(_ context.Context, appCode string, regionID int64) ([]string, bool, error) {
if r.appCode != appCode || r.regionID != regionID {
return nil, false, nil
}
return r.countryCodes, true, nil
}
func externalMetricColumns(prefix ...string) []string {
columns := []string{
"country_new_user",
"daily_active_user",
"daily_recharge_user",
"new_user_recharge",
"official_recharge",
"mifapay_recharge",
"google_recharge",
"dealer_recharge",
"user_recharge",
"new_dealer_user_recharge",
"salary_exchange",
"salary_transfer",
"gift_consume",
"lucky_gift_total_flow",
"lucky_gift_user",
"lucky_gift_payout",
"lucky_gift_anchor_share",
"game_total_flow",
"game_user",
"game_payout",
"d1_retention_user",
"d1_retention_base_user",
"d7_retention_user",
"d7_retention_base_user",
"d30_retention_user",
"d30_retention_base_user",
"refreshed_at",
}
return append(prefix, columns...)
}
func externalMetricRow(refreshedAt time.Time,
newUsers int64, activeUsers int64, paidUsers int64,
newUserRecharge string, officialRecharge string, mifaPayRecharge string, googleRecharge string, dealerRecharge string, userRecharge string, newDealerRecharge string,
salaryExchange string, salaryTransfer string, giftConsume string, luckyGiftTurnover string, luckyGiftPayers int64, luckyGiftPayout string, luckyGiftAnchorShare string,
gameTurnover string, gamePlayers int64, gamePayout string,
d1Users int64, d1Base int64, d7Users int64, d7Base int64, d30Users int64, d30Base int64,
) []driver.Value {
return []driver.Value{
newUsers,
activeUsers,
paidUsers,
newUserRecharge,
officialRecharge,
mifaPayRecharge,
googleRecharge,
dealerRecharge,
userRecharge,
newDealerRecharge,
salaryExchange,
salaryTransfer,
giftConsume,
luckyGiftTurnover,
luckyGiftPayers,
luckyGiftPayout,
luckyGiftAnchorShare,
gameTurnover,
gamePlayers,
gamePayout,
d1Users,
d1Base,
d7Users,
d7Base,
d30Users,
d30Base,
refreshedAt,
}
}
func assertEqual(t *testing.T, got any, want any) {
t.Helper()
if got != want {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func assertInt64(t *testing.T, got any, want int64) {
t.Helper()
value, ok := got.(int64)
if !ok {
t.Fatalf("got %T(%#v), want int64(%d)", got, got, want)
}
if value != want {
t.Fatalf("got %d, want %d", value, want)
}
}
func assertFloat(t *testing.T, got any, want float64) {
t.Helper()
value, ok := got.(float64)
if !ok {
t.Fatalf("got %T(%#v), want float64(%f)", got, got, want)
}
if math.Abs(value-want) > 0.000001 {
t.Fatalf("got %f, want %f", value, want)
}
}
func assertMetricSourceUnavailable(t *testing.T, sources []map[string]any, field string) {
t.Helper()
for _, source := range sources {
if source["field"] != field {
continue
}
if source["available"] != false {
t.Fatalf("%s should be unavailable: %#v", field, source)
}
if source["missing_reason"] == "" {
t.Fatalf("%s missing reason is empty: %#v", field, source)
}
return
}
t.Fatalf("metric source %s not found: %#v", field, sources)
}