1341 lines
64 KiB
Go

package mysql
import (
"context"
"encoding/json"
"math"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
)
func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_test",
})
activitySchema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "..", "activity-service", "deploy", "mysql", "initdb", "001_activity_service.sql"),
DatabasePrefix: "hy_activity_query_test",
})
repository, err := Open(ctx, statsSchema.DSN, activitySchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, lucky_gift_turnover, lucky_gift_payout, updated_at_ms
) VALUES ('lalu', '2026-06-06', 210, 100, 900, 1)`); err != nil {
t.Fatalf("seed stale statistics overview: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_lucky_gift_pool_day_country (
app_code, stat_day, country_id, pool_id, turnover_coin, payout_coin, updated_at_ms
) VALUES ('lalu', '2026-06-06', 210, 'lucky', 100, 900, 1)`); err != nil {
t.Fatalf("seed stale statistics pool: %v", err)
}
if _, err := repository.activityDB.ExecContext(ctx, `
INSERT INTO lucky_draw_pool_day_stats (
app_code, stat_day, visible_region_id, pool_id, gift_id, draw_count, turnover_coin, payout_coin,
base_reward_coin, room_atmosphere_reward_coin, activity_subsidy_coin, created_at_ms, updated_at_ms
) VALUES
('lalu', '2026-06-06', 210, 'super_lucky', '', 10, 2744832, 2401885, 2401885, 0, 0, 1, 1),
('lalu', '2026-06-06', 210, 'lucky', '', 5, 148322, 126415, 126415, 0, 0, 1, 1)`); err != nil {
t.Fatalf("seed activity databi stats: %v", err)
}
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 210})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.LuckyGiftTurnover != 2_893_154 || overview.LuckyGiftPayout != 2_528_300 || overview.LuckyGiftProfit != 364_854 {
t.Fatalf("activity lucky gift overview not applied: %+v", overview)
}
if len(overview.LuckyGiftPools) != 2 {
t.Fatalf("activity lucky gift pools not applied: %+v", overview.LuckyGiftPools)
}
if overview.LuckyGiftPools[0].PoolID != "super_lucky" || overview.LuckyGiftPools[0].TurnoverCoin != 2_744_832 || overview.LuckyGiftPools[0].PayoutCoin != 2_401_885 {
t.Fatalf("super_lucky pool mismatch: %+v", overview.LuckyGiftPools[0])
}
}
func TestConsumeAppTrackingEventsStoresRawEventsAndDeduplicates(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_app_tracking_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
occurredAt := time.Date(2026, 7, 2, 23, 59, 59, 0, time.UTC).UnixMilli()
result, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{
{
AppCode: "lalu",
EventID: "evt-banner-1",
EventName: "banner_view",
EventType: "view",
Screen: "home",
TargetType: "banner",
TargetID: "17",
UserID: 42,
DeviceID: "dev-1",
SessionID: "sess-1",
Platform: "ios",
AppVersion: "1.2.3",
Language: "en-US",
Timezone: "Asia/Shanghai",
CountryID: 86,
RegionID: 210,
DurationMS: 12,
Success: true,
Properties: json.RawMessage(`{"slot":"top"}`),
OccurredAtMS: occurredAt,
},
})
if err != nil {
t.Fatalf("consume app tracking event: %v", err)
}
if !result.Accepted || result.Received != 1 || result.Stored != 1 || result.Duplicated != 0 || result.ServerTimeMS <= 0 {
t.Fatalf("unexpected first consume result: %+v", result)
}
duplicate, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{
{
AppCode: "lalu",
EventID: "evt-banner-1",
EventName: "banner_view",
Screen: "changed",
UserID: 42,
DeviceID: "dev-1",
OccurredAtMS: occurredAt,
},
})
if err != nil {
t.Fatalf("consume duplicate app tracking event: %v", err)
}
if duplicate.Received != 1 || duplicate.Stored != 0 || duplicate.Duplicated != 1 {
t.Fatalf("duplicate consume result mismatch: %+v", duplicate)
}
var count int64
var statDay, screen, identityKey, propertiesJSON string
var userID, countryID, regionID, durationMS int64
var success bool
if err := repository.db.QueryRowContext(ctx, `
SELECT COUNT(*), MAX(CAST(stat_day AS CHAR)), MAX(screen), MAX(identity_key), MAX(user_id), MAX(country_id), MAX(region_id),
MAX(duration_ms), MAX(success), COALESCE(MAX(CAST(properties_json AS CHAR)), '{}')
FROM app_tracking_events
WHERE app_code = 'lalu' AND event_id = 'evt-banner-1'
`).Scan(&count, &statDay, &screen, &identityKey, &userID, &countryID, &regionID, &durationMS, &success, &propertiesJSON); err != nil {
t.Fatalf("query app tracking event: %v", err)
}
if count != 1 || statDay != "2026-07-02" || screen != "home" || identityKey != "u:42" || userID != 42 || countryID != 86 || regionID != 210 || durationMS != 12 || !success {
t.Fatalf("stored event mismatch: count=%d statDay=%s screen=%s identityKey=%s userID=%d countryID=%d regionID=%d durationMS=%d success=%v", count, statDay, screen, identityKey, userID, countryID, regionID, durationMS, success)
}
if DecodeJSON(propertiesJSON)["slot"] != "top" {
t.Fatalf("properties_json mismatch: %s", propertiesJSON)
}
}
func TestQueryAppTrackingFunnelAggregatesStepsAndD1Cohorts(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_app_tracking_funnel_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
event := func(userID int64, deviceID string, eventName string, offset time.Duration, properties string) AppTrackingEvent {
return AppTrackingEvent{
AppCode: "lalu",
EventID: "evt-" + strconv.FormatInt(userID, 10) + "-" + deviceID + "-" + eventName + "-" + strconv.FormatInt(int64(offset/time.Second), 10),
EventName: eventName,
UserID: userID,
DeviceID: deviceID,
Language: "en-US",
CountryID: 86,
RegionID: 210,
Properties: json.RawMessage(properties),
OccurredAtMS: start.Add(offset).UnixMilli(),
}
}
user1Props := `{"country":"US","channel":"ads","login_method":"google"}`
user2Props := `{"country":"BR","channel":"organic","login_method":"phone"}`
events := []AppTrackingEvent{
event(1, "dev-1", "login_start", 0, user1Props),
event(1, "dev-1", "login_success", time.Second, user1Props),
event(1, "dev-1", "profile_start", 2*time.Second, user1Props),
event(1, "dev-1", "profile_complete", 3*time.Second, user1Props),
event(1, "dev-1", "home_room_impression", 4*time.Second, user1Props),
event(1, "dev-1", "home_room_click", 5*time.Second, user1Props),
event(1, "dev-1", "room_join_success", 6*time.Second, user1Props),
event(1, "dev-1", "stay_30s", 30*time.Second, user1Props),
event(1, "dev-1", "stay_3m", 3*time.Minute, user1Props),
event(1, "dev-1", "send_message", 4*time.Minute, user1Props),
event(1, "dev-1", "follow_host", 5*time.Minute, user1Props),
event(1, "dev-1", "push_permission", 6*time.Minute, user1Props),
event(1, "dev-1", "send_message", 24*time.Hour+time.Second, user1Props),
event(2, "dev-2", "login_start", 0, user2Props),
event(2, "dev-2", "login_success", time.Second, user2Props),
event(2, "dev-2", "home_room_impression", 2*time.Second, user2Props),
event(2, "dev-2", "home_room_click", 3*time.Second, user2Props),
event(2, "dev-2", "room_join_fail", 4*time.Second, user2Props),
}
excluded := event(3, "dev-3", "login_start", 0, user1Props)
excluded.EventID = "evt-excluded-region-login-start"
excluded.RegionID = 999
events = append(events, excluded)
if _, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), events); err != nil {
t.Fatalf("consume app tracking events: %v", err)
}
funnel, err := repository.QueryAppTrackingFunnel(ctx, AppTrackingFunnelQuery{
AppCode: "lalu",
StartMS: start.UnixMilli(),
EndMS: start.Add(24 * time.Hour).UnixMilli(),
RegionIDs: []int64{210},
})
if err != nil {
t.Fatalf("query app tracking funnel: %v", err)
}
if funnel.Totals.LoginStartUsers != 2 || funnel.Totals.LoginSuccessUsers != 2 || funnel.Totals.RoomJoinSuccessUsers != 1 || funnel.Totals.RoomJoinFailUsers != 1 {
t.Fatalf("unexpected funnel totals: %+v", funnel.Totals)
}
if funnel.Totals.D1RetentionBaseUsers != 2 || funnel.Totals.D1RetentionUsers != 1 || math.Abs(funnel.Totals.D1RetentionRate-0.5) > 0.0001 {
t.Fatalf("unexpected d1 totals: %+v", funnel.Totals)
}
stepByEvent := map[string]AppTrackingFunnelStep{}
for _, step := range funnel.Steps {
stepByEvent[step.EventName] = step
}
if stepByEvent["login_start"].UserCount != 2 || stepByEvent["profile_complete"].UserCount != 1 || stepByEvent["stay_3m"].UserCount != 1 {
t.Fatalf("unexpected step counts: %+v", stepByEvent)
}
if !stepByEvent["room_join_fail"].IsFailure {
t.Fatalf("room_join_fail must be marked as failure: %+v", stepByEvent["room_join_fail"])
}
cohort := func(dimension, value string) AppTrackingFunnelCohort {
for _, item := range funnel.Cohorts {
if item.Dimension == dimension && item.Value == value {
return item
}
}
t.Fatalf("missing cohort %s=%s in %+v", dimension, value, funnel.Cohorts)
return AppTrackingFunnelCohort{}
}
us := cohort("country", "US")
if us.BaseUsers != 1 || us.D1RetentionUsers != 1 || math.Abs(us.D1RetentionRate-1) > 0.0001 {
t.Fatalf("unexpected US cohort: %+v", us)
}
stay := cohort("first_room_stay", "3m-10m")
if stay.BaseUsers != 1 || stay.D1RetentionUsers != 1 {
t.Fatalf("unexpected stay cohort: %+v", stay)
}
}
func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_social_requirements_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
user1, user2 := int64(1001), int64(1002)
for _, event := range []UserRegisteredEvent{
{AppCode: "lalu", EventID: "social:user:1001", UserID: user1, Role: "host", CountryID: 86, RegionID: 210, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:user:1002", UserID: user2, Role: "user", CountryID: 86, RegionID: 210, OccurredAtMS: start.Add(time.Minute).UnixMilli()},
} {
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration %d: %v", event.UserID, err)
}
}
for _, event := range []AppTrackingEvent{
{AppCode: "lalu", EventID: "social:first-open:1001", EventName: "app_first_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:first-open:1002", EventName: "app_first_open", UserID: user2, DeviceID: "dev-1002", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:app-session:1001", EventName: "app_session", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 600_000, Success: true, OccurredAtMS: start.Add(2 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:room-stay:1001", EventName: "room_stay", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 300_000, Success: true, OccurredAtMS: start.Add(3 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:gift-panel:1001", EventName: "gift_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(4 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:prob-panel:1001", EventName: "probability_game_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(5 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:chat:1001", EventName: "send_message", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(6 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:follow-user:1001", EventName: "follow_host", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(7 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:follow-room:1001", EventName: "follow_room", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(8 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:d1-active:1001", EventName: "home_view", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(24*time.Hour + time.Minute).UnixMilli()},
} {
if _, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{event}); err != nil {
t.Fatalf("consume app event %s: %v", event.EventName, err)
}
}
if err := repository.ConsumeUserActive(ctx, UserActiveEvent{AppCode: "lalu", EventID: "social:room-join:1001", EventType: "RoomUserJoined", UserID: user1, Role: "host", CountryID: 86, RegionID: 210, OccurredAtMS: start.Add(9 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume room join: %v", err)
}
if err := repository.ConsumeUserMicDailyStatsUpdated(ctx, UserMicDailyStatsUpdatedEvent{AppCode: "lalu", EventID: "social:mic:1001", UserID: user1, StatDate: "2026-07-01", MicOnlineDeltaMS: 120_000, OccurredAtMS: start.Add(10 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume mic: %v", err)
}
for _, event := range []RechargeEvent{
{AppCode: "lalu", EventID: "social:recharge:first", EventType: "WalletRechargeRecorded", UserID: user1, USDMinor: 500, RechargeSequence: 1, TargetCountryID: 86, TargetRegionID: 210, RechargeType: "google", OccurredAtMS: start.Add(11 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:recharge:repeat", EventType: "WalletRechargeRecorded", UserID: user1, USDMinor: 700, RechargeSequence: 2, TargetCountryID: 86, TargetRegionID: 210, RechargeType: "mifapay", OccurredAtMS: start.Add(12 * time.Minute).UnixMilli()},
} {
if err := repository.ConsumeRecharge(ctx, event); err != nil {
t.Fatalf("consume recharge %s: %v", event.EventID, err)
}
}
financeOrder := FinanceCoinSellerRechargeOrderEvent{
AppCode: "lalu", EventID: "social:finance:coin-seller:verified", VerifyStatus: "verified", TargetUserID: user2,
USDMinor: 900, CoinAmount: 90_000, CountryID: 86, RegionID: 210, CreatedAtMS: start.Add(12*time.Minute + 30*time.Second).UnixMilli(),
}
if err := repository.ConsumeFinanceCoinSellerRechargeOrder(ctx, financeOrder); err != nil {
t.Fatalf("consume finance coin-seller order: %v", err)
}
financeOrder.USDMinor = 1200
if err := repository.ConsumeFinanceCoinSellerRechargeOrder(ctx, financeOrder); err != nil {
t.Fatalf("consume duplicate finance coin-seller order: %v", err)
}
if err := repository.ConsumeFinanceCoinSellerRechargeOrder(ctx, FinanceCoinSellerRechargeOrderEvent{
AppCode: "lalu", EventID: "social:finance:coin-seller:pending", VerifyStatus: "pending", TargetUserID: user2,
USDMinor: 300, CoinAmount: 30_000, CountryID: 86, RegionID: 210, CreatedAtMS: start.Add(12*time.Minute + 45*time.Second).UnixMilli(),
}); err != nil {
t.Fatalf("consume pending finance coin-seller order: %v", err)
}
for _, event := range []RoomGiftEvent{
{AppCode: "lalu", EventID: "social:gift:normal", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 100, CoinSpent: 100, GiftTypeCode: "normal", OccurredAtMS: start.Add(13 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:gift:lucky", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 200, CoinSpent: 200, PoolID: "lucky", GiftTypeCode: "lucky", OccurredAtMS: start.Add(14 * time.Minute).UnixMilli()},
} {
if err := repository.ConsumeRoomGift(ctx, event); err != nil {
t.Fatalf("consume gift %s: %v", event.EventID, err)
}
}
if err := repository.ConsumeLuckyGiftReward(ctx, LuckyGiftRewardEvent{AppCode: "lalu", EventID: "social:lucky:payout", UserID: user1, CountryID: 86, RegionID: 210, PoolID: "lucky", Amount: 50, OccurredAtMS: start.Add(15 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume lucky payout: %v", err)
}
if err := repository.ConsumeGameOrder(ctx, GameOrderEvent{AppCode: "lalu", EventID: "social:probability:bet", UserID: user1, CountryID: 86, RegionID: 210, PlatformCode: "vendor", GameID: "slot", OpType: "bet", CoinAmount: 300, OccurredAtMS: start.Add(16 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume probability game: %v", err)
}
if err := repository.ConsumeSelfGameMatch(ctx, SelfGameMatchEvent{
AppCode: "lalu", EventID: "social:self-game:settled", EventType: "SelfGameMatchSettled", GameID: "dice", CountryID: 86, RegionID: 210,
StakeCoin: 400, UserParticipants: 1, UserStakeCoin: 400, Participants: []SelfGameMatchParticipantEvent{{UserID: user1, ParticipantType: "user", StakeCoin: 400, Result: "win"}},
OccurredAtMS: start.Add(17 * time.Minute).UnixMilli(),
}); err != nil {
t.Fatalf("consume self game: %v", err)
}
query := SocialRequirementsQuery{
AppCode: "lalu",
StartMS: start.UnixMilli(),
EndMS: start.Add(24 * time.Hour).UnixMilli(),
RegionIDs: []int64{210},
}
result, err := repository.QuerySocialRequirements(ctx, query)
if err != nil {
t.Fatalf("query social requirements: %v", err)
}
newUsers := socialTestSection(t, result, SocialSectionNewUsers)
if socialMetricInt(t, newUsers.Total, "app_download_users") != 2 ||
socialMetricInt(t, newUsers.Total, "registered_users") != 2 ||
socialMetricInt(t, newUsers.Total, "new_host_users") != 1 ||
socialMetricInt(t, newUsers.Total, "new_normal_users") != 1 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "room_join_success_rate")-0.5) > 0.0001 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "new_host_d1_retention_rate")-1) > 0.0001 {
t.Fatalf("P0 metrics mismatch: %+v", newUsers.Total.Metrics)
}
revenue := socialTestSection(t, result, SocialSectionRevenue)
if socialMetricInt(t, revenue.Total, "recharge_usd_minor") != 2100 ||
socialMetricInt(t, revenue.Total, "google_recharge_usd_minor") != 500 ||
socialMetricInt(t, revenue.Total, "third_party_recharge_usd_minor") != 900 ||
socialMetricInt(t, revenue.Total, "coin_seller_recharge_usd_minor") != 900 ||
socialMetricInt(t, revenue.Total, "recharge_count") != 3 ||
socialMetricInt(t, revenue.Total, "recharge_users") != 1 ||
math.Abs(socialMetricFloat(t, revenue.Total, "paid_rate")-0.5) > 0.0001 ||
socialMetricInt(t, revenue.Total, "arppu_usd_minor") != 1200 {
t.Fatalf("P1 default metrics mismatch: %+v", revenue.Total.Metrics)
}
paidRevenue, err := repository.QuerySocialRequirements(ctx, SocialRequirementsQuery{AppCode: "lalu", StartMS: query.StartMS, EndMS: query.EndMS, Section: SocialSectionRevenue, PayerType: "paid"})
if err != nil {
t.Fatalf("query revenue requirements: %v", err)
}
paidRevenueSection := socialTestSection(t, paidRevenue, SocialSectionRevenue)
if socialMetricInt(t, paidRevenueSection.Total, "recharge_usd_minor") != 1200 ||
socialMetricInt(t, paidRevenueSection.Total, "google_recharge_usd_minor") != 500 ||
socialMetricInt(t, paidRevenueSection.Total, "third_party_recharge_usd_minor") != 0 ||
socialMetricInt(t, paidRevenueSection.Total, "recharge_count") != 2 ||
socialMetricInt(t, paidRevenueSection.Total, "first_recharge_users") != 1 ||
socialMetricInt(t, paidRevenueSection.Total, "repeat_recharge_users") != 1 ||
math.Abs(socialMetricFloat(t, paidRevenueSection.Total, "paid_rate")-1) > 0.0001 {
t.Fatalf("P1 paid metrics mismatch: %+v", paidRevenueSection.Total.Metrics)
}
retention := socialTestSection(t, result, SocialSectionRetention)
if math.Abs(socialMetricFloat(t, retention.Total, "registered_d1_active_rate")-0.5) > 0.0001 ||
math.Abs(socialMetricFloat(t, retention.Total, "after_room_d1_room_rate")-1) > 0.0001 {
t.Fatalf("P2 retention metrics mismatch: %+v", retention.Total.Metrics)
}
active := socialTestSection(t, result, SocialSectionActive)
if socialMetricInt(t, active.Total, "active_users") != 2 ||
socialMetricInt(t, active.Total, "room_join_success_users") != 1 ||
socialMetricInt(t, active.Total, "mic_up_users") != 1 ||
math.Abs(socialMetricFloat(t, active.Total, "avg_app_stay_min")-5) > 0.0001 {
t.Fatalf("P2 active metrics mismatch: %+v", active.Total.Metrics)
}
gift := socialTestSection(t, result, SocialSectionGift)
if socialMetricInt(t, gift.Total, "normal_gift_coin") != 100 ||
socialMetricInt(t, gift.Total, "lucky_gift_coin") != 200 ||
socialMetricInt(t, gift.Total, "lucky_gift_payout_coin") != 50 ||
math.Abs(socialMetricFloat(t, gift.Total, "gift_panel_conversion_rate")-1) > 0.0001 ||
math.Abs(socialMetricFloat(t, gift.Total, "lucky_gift_rtp")-0.25) > 0.0001 {
t.Fatalf("P4 metrics mismatch: %+v", gift.Total.Metrics)
}
game := socialTestSection(t, result, SocialSectionGame)
if socialMetricInt(t, game.Total, "self_game_bet_coin") != 400 ||
socialMetricInt(t, game.Total, "probability_game_bet_coin") != 300 ||
socialMetricInt(t, game.Total, "probability_game_bet_count") != 1 ||
math.Abs(socialMetricFloat(t, game.Total, "self_game_conversion_rate")-0.5) > 0.0001 ||
math.Abs(socialMetricFloat(t, game.Total, "probability_game_bet_arppu_coin")-300) > 0.0001 {
t.Fatalf("P5 metrics mismatch: %+v", game.Total.Metrics)
}
}
func socialTestSection(t *testing.T, result SocialRequirements, key string) SocialRequirementSection {
t.Helper()
for _, section := range result.Sections {
if section.Key == key {
return section
}
}
t.Fatalf("missing social section %s in %+v", key, result.Sections)
return SocialRequirementSection{}
}
func socialMetricInt(t *testing.T, row SocialRequirementRow, key string) int64 {
t.Helper()
value, ok := row.Metrics[key]
if !ok {
t.Fatalf("missing metric %s in %+v", key, row.Metrics)
}
switch typed := value.(type) {
case int64:
return typed
case int:
return int64(typed)
case float64:
return int64(typed)
default:
t.Fatalf("metric %s has unexpected type %T", key, value)
return 0
}
}
func socialMetricFloat(t *testing.T, row SocialRequirementRow, key string) float64 {
t.Helper()
value, ok := row.Metrics[key]
if !ok {
t.Fatalf("missing metric %s in %+v", key, row.Metrics)
}
switch typed := value.(type) {
case float64:
return typed
case int64:
return float64(typed)
case int:
return float64(typed)
default:
t.Fatalf("metric %s has unexpected type %T", key, value)
return 0
}
}
func TestConsumeAppTrackingEventsRejectsInvalidProperties(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_app_tracking_invalid_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
for _, test := range []struct {
name string
properties json.RawMessage
}{
{name: "invalid json", properties: json.RawMessage(`{"bad"`)},
{name: "too large json", properties: json.RawMessage(`"` + strings.Repeat("a", appTrackingMaxPropertiesBytes+1) + `"`)},
} {
t.Run(test.name, func(t *testing.T) {
_, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{
{EventID: "evt-" + test.name, EventName: "open", DeviceID: "dev-1", Properties: test.properties, OccurredAtMS: time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC).UnixMilli()},
})
if err == nil {
t.Fatalf("invalid properties must be rejected")
}
})
}
}
func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_registration_idempotent_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
event := UserRegisteredEvent{
AppCode: "lalu",
EventID: "user_registered:327300172394536960",
UserID: 327300172394536960,
CountryID: 15,
RegionID: 10,
OccurredAtMS: time.Date(2026, 6, 22, 1, 2, 3, 0, time.UTC).UnixMilli(),
}
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration first time: %v", err)
}
// 生产环境会遇到 MQ 重投或历史补偿;注册 cohort 表去重后,国家日新增只能按真实新行累加。
event.EventID = "user_registered:327300172394536960:replay"
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration replay: %v", err)
}
var newUsers, activeUsers int64
if err := repository.db.QueryRowContext(ctx, `
SELECT new_users, active_users
FROM stat_app_day_country
WHERE app_code = 'lalu' AND stat_tz = 'UTC' AND stat_day = '2026-06-22' AND country_id = 15 AND region_id = 10
`).Scan(&newUsers, &activeUsers); err != nil {
t.Fatalf("query app day country: %v", err)
}
if newUsers != 1 {
t.Fatalf("registration replay must not duplicate new users, got %d", newUsers)
}
if activeUsers != 1 {
t.Fatalf("registration must count as one same-day active user, got %d", activeUsers)
}
var activeRows int64
if err := repository.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM stat_user_day_activity
WHERE app_code = 'lalu'
AND stat_tz = 'UTC'
AND stat_day = '2026-06-22'
AND country_id = 15
AND region_id = 10
AND user_id = 327300172394536960
`).Scan(&activeRows); err != nil {
t.Fatalf("query registration active row: %v", err)
}
if activeRows != 1 {
t.Fatalf("registration replay must keep one user-day active row, got %d", activeRows)
}
}
func TestConsumeReportMetricsAggregatesWalletSalaryGrantAndMic(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_report_metrics_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
occurredAt := time.Date(2026, 6, 22, 3, 4, 5, 0, time.UTC).UnixMilli()
for _, event := range []UserRegisteredEvent{
{AppCode: "lalu", EventID: "user_registered:report:1", UserID: 7001, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "user_registered:report:2", UserID: 7002, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
} {
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume user dimension: %v", err)
}
}
for _, event := range []WalletBalanceChangedEvent{
{AppCode: "lalu", EventID: "balance:coin:recharge", UserID: 7001, AssetType: "COIN", BizType: "google_play_recharge", AvailableDelta: 1000, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:coin:red-packet-create", UserID: 7001, AssetType: "COIN", BizType: "red_packet_create", AvailableDelta: -200, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:coin:red-packet-refund", UserID: 7001, AssetType: "COIN", BizType: "red_packet_refund", AvailableDelta: 50, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:coin:checkin-resource", UserID: 7001, AssetType: "COIN", BizType: "resource_grant", CommandID: "wcheckin_claim_1", AvailableDelta: 66, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:coin:manual", UserID: 7001, AssetType: "COIN", BizType: "manual_credit", AvailableDelta: 70, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:salary:host", UserID: 7001, AssetType: "HOST_SALARY_USD", BizType: "host_salary_settlement", AvailableDelta: 123, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:salary:transfer-debit", UserID: 7001, AssetType: "HOST_SALARY_USD", BizType: "salary_transfer_to_coin_seller", AvailableDelta: -23, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:salary:coin-seller", UserID: 9001, DimensionUserID: 7001, AssetType: "COIN_SELLER_COIN", BizType: "salary_transfer_to_coin_seller", AvailableDelta: 444, OccurredAtMS: occurredAt},
} {
if err := repository.ConsumeWalletBalanceChanged(ctx, event); err != nil {
t.Fatalf("consume wallet balance changed %s: %v", event.EventID, err)
}
}
if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
AppCode: "lalu", EventID: "grant:task:1", EventType: "WalletTaskRewardCredited", UserID: 7001, Amount: 33, OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume platform grant: %v", err)
}
if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
AppCode: "lalu", EventID: "grant:wheel:1", EventType: "WalletWheelRewardCredited", UserID: 7001, Amount: 40, OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume wheel output: %v", err)
}
for _, event := range []UserMicDailyStatsUpdatedEvent{
{AppCode: "lalu", EventID: "mic:7001:first", UserID: 7001, StatDate: "2026-06-22", MicOnlineDeltaMS: 60_000, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "mic:7001:second", UserID: 7001, StatDate: "2026-06-22", MicOnlineDeltaMS: 30_000, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "mic:7002:first", UserID: 7002, StatDate: "2026-06-22", MicOnlineDeltaMS: 30_000, OccurredAtMS: occurredAt},
} {
if err := repository.ConsumeUserMicDailyStatsUpdated(ctx, event); err != nil {
t.Fatalf("consume mic stats %s: %v", event.EventID, err)
}
}
start := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond)})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.CoinTotal != 986 || overview.ConsumedCoin != 200 || overview.OutputCoin != 90 || overview.ConsumeOutputDelta != 110 || overview.ConsumeOutputRatio != 0.45 || overview.PlatformGrantCoin != 99 || overview.ManualGrantCoin != 70 || overview.SalaryUSDMinor != 100 || overview.SalaryTransferCoin != 444 {
t.Fatalf("report wallet metrics mismatch: %+v", overview)
}
if overview.MicOnlineMS != 120_000 || overview.MicOnlineUsers != 2 || overview.AvgMicOnlineMS != 60_000 {
t.Fatalf("mic metrics mismatch: %+v", overview)
}
if len(overview.CountryBreakdown) != 1 {
t.Fatalf("country breakdown missing: %+v", overview.CountryBreakdown)
}
country := overview.CountryBreakdown[0]
if country.CoinTotal != 986 || country.ConsumedCoin != 200 || country.OutputCoin != 90 || country.PlatformGrantCoin != 99 || country.SalaryTransferCoin != 444 || country.AvgMicOnlineMS != 60_000 {
t.Fatalf("country report metrics mismatch: %+v", country)
}
if len(overview.DailySeries) != 1 || overview.DailySeries[0].OutputCoin != 90 || overview.DailySeries[0].SalaryTransferCoin != 444 || overview.DailySeries[0].AvgMicOnlineMS != 60_000 {
t.Fatalf("daily report metrics mismatch: %+v", overview.DailySeries)
}
}
func TestQueryOverviewUsesEndDayBalanceSnapshotsForRange(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_coin_snapshot_range_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, coin_total, salary_usd_minor, consumed_coin, output_coin, updated_at_ms
) VALUES
('lalu', '2026-06-24', 15, 10, 100, 12, 7, 3, 1),
('lalu', '2026-06-25', 15, 10, 250, 34, 11, 4, 1)`); err != nil {
t.Fatalf("seed coin snapshots: %v", err)
}
start := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), CountryID: 15, RegionID: 10})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.CoinTotal != 250 || overview.SalaryUSDMinor != 34 || overview.ConsumedCoin != 18 || overview.OutputCoin != 7 {
t.Fatalf("overview must use end-day balance snapshots and range flow sums: %+v", overview)
}
if len(overview.DailySeries) != 2 || overview.DailySeries[0].CoinTotal != 100 || overview.DailySeries[0].SalaryUSDMinor != 12 || overview.DailySeries[1].CoinTotal != 250 || overview.DailySeries[1].SalaryUSDMinor != 34 {
t.Fatalf("daily series must keep each day's balance snapshots: %+v", overview.DailySeries)
}
if len(overview.CountryBreakdown) != 1 || overview.CountryBreakdown[0].CoinTotal != 250 || overview.CountryBreakdown[0].SalaryUSDMinor != 34 {
t.Fatalf("country breakdown must use end-day balance snapshots: %+v", overview.CountryBreakdown)
}
}
func TestQueryPlatformGrantCoinDrilldown(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_platform_grant_detail_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
occurredAt := time.Date(2026, 6, 22, 3, 4, 5, 0, time.UTC).UnixMilli()
for _, event := range []UserRegisteredEvent{
{AppCode: "lalu", EventID: "user_registered:grant_detail:7001", UserID: 7001, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "user_registered:grant_detail:7002", UserID: 7002, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
} {
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume user dimension %d: %v", event.UserID, err)
}
}
if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
AppCode: "lalu", EventID: "grant:task:7001", EventType: "WalletTaskRewardCredited", UserID: 7001, Amount: 33, OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume task grant: %v", err)
}
if err := repository.ConsumeWalletBalanceChanged(ctx, WalletBalanceChangedEvent{
AppCode: "lalu", EventID: "grant:resource:7001", EventType: "WalletBalanceChanged", UserID: 7001, AssetType: "COIN", BizType: "resource_grant", CommandID: "weekly_star:2026-06-22", AvailableDelta: 7, OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume resource grant: %v", err)
}
if err := repository.ConsumeLuckyGiftReward(ctx, LuckyGiftRewardEvent{
AppCode: "lalu", EventID: "grant:lucky:7001", UserID: 7001, CountryID: 15, RegionID: 10, Amount: 22, OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume lucky grant: %v", err)
}
if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
AppCode: "lalu", EventID: "grant:task:7002", EventType: "WalletTaskRewardCredited", UserID: 7002, Amount: 12, OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume second task grant: %v", err)
}
users, err := repository.QueryPlatformGrantCoinUsers(ctx, PlatformGrantCoinQuery{
AppCode: "lalu",
StatDay: "2026-06-22",
CountryID: 15,
RegionID: 10,
Page: 1,
PageSize: 1,
})
if err != nil {
t.Fatalf("query grant users: %v", err)
}
if users.Total != 2 || users.Page != 1 || users.PageSize != 1 || len(users.Items) != 1 {
t.Fatalf("grant user page mismatch: %+v", users)
}
if got := users.Items[0]; got.UserID != 7001 || got.TotalCoin != 40 || got.RecordCount != 2 || got.CountryID != 15 || got.RegionID != 10 {
t.Fatalf("first grant user mismatch: %+v", got)
}
records, err := repository.QueryPlatformGrantCoinRecords(ctx, PlatformGrantCoinQuery{
AppCode: "lalu",
StatDay: "2026-06-22",
CountryID: 15,
RegionID: 10,
UserID: 7001,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("query grant records: %v", err)
}
if records.Total != 2 || len(records.Items) != 2 {
t.Fatalf("grant record page mismatch: %+v", records)
}
amountByType := map[string]int64{}
for _, item := range records.Items {
amountByType[item.EventType] += item.Amount
if item.UserID != 7001 || item.CountryID != 15 || item.RegionID != 10 {
t.Fatalf("grant record dimension mismatch: %+v", item)
}
}
if amountByType["WalletTaskRewardCredited"] != 33 || amountByType["WalletBalanceChanged"] != 7 || amountByType["WalletLuckyGiftRewardCredited"] != 0 {
t.Fatalf("grant record sources mismatch: %+v", amountByType)
}
taskRecords, err := repository.QueryPlatformGrantCoinRecords(ctx, PlatformGrantCoinQuery{
AppCode: "lalu",
StatDay: "2026-06-22",
CountryID: 15,
RegionID: 10,
UserID: 7001,
EventType: "WalletTaskRewardCredited",
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("query source filtered grant records: %v", err)
}
if taskRecords.Total != 1 || len(taskRecords.Items) != 1 || taskRecords.Items[0].EventType != "WalletTaskRewardCredited" || taskRecords.Items[0].Amount != 33 {
t.Fatalf("source filtered grant records mismatch: %+v", taskRecords)
}
}
func TestConsumeRechargeWritesUTCAndChinaStatDays(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_stat_tz_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
occurredAt := time.Date(2026, 6, 23, 16, 30, 0, 0, time.UTC).UnixMilli()
if err := repository.ConsumeRecharge(ctx, RechargeEvent{
AppCode: "lalu",
EventID: "recharge:tz-boundary:1",
EventType: "WalletRechargeRecorded",
UserID: 9001,
TargetCountryID: 63,
TargetRegionID: 210,
USDMinor: 80_000,
RechargeType: "mifapay",
OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume recharge: %v", err)
}
utcStart := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC).UnixMilli()
utcOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: utcStart, EndMS: utcStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
if err != nil {
t.Fatalf("query utc overview: %v", err)
}
if utcOverview.StatTZ != StatTZUTC || utcOverview.RechargeUSDMinor != 80_000 || utcOverview.MifaPayRechargeUSDMinor != 80_000 {
t.Fatalf("UTC stat day mismatch: %+v", utcOverview)
}
beijingStart := time.Date(2026, 6, 23, 16, 0, 0, 0, time.UTC).UnixMilli()
chinaOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StatTZ: StatTZAsiaShanghai, StartMS: beijingStart, EndMS: beijingStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
if err != nil {
t.Fatalf("query china overview: %v", err)
}
if chinaOverview.StatTZ != StatTZAsiaShanghai || chinaOverview.RechargeUSDMinor != 80_000 || len(chinaOverview.DailySeries) != 1 || chinaOverview.DailySeries[0].StatDay != "2026-06-24" {
t.Fatalf("China stat day mismatch: %+v", chinaOverview)
}
nextUTCStart := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC).UnixMilli()
nextUTCOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: nextUTCStart, EndMS: nextUTCStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
if err != nil {
t.Fatalf("query next utc overview: %v", err)
}
if nextUTCOverview.RechargeUSDMinor != 0 {
t.Fatalf("UTC 2026-06-24 must not include previous UTC day recharge: %+v", nextUTCOverview)
}
}
func TestConsumeRechargeWithStatTZScopeDoesNotTouchUTC(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_stat_tz_scope_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
occurredAt := time.Date(2026, 6, 23, 16, 30, 0, 0, time.UTC).UnixMilli()
if err := repository.ConsumeRecharge(WithStatTZScope(ctx, StatTZAsiaShanghai), RechargeEvent{
AppCode: "lalu",
EventID: "recharge:tz-scope:1",
EventType: "WalletRechargeRecorded",
UserID: 9002,
TargetCountryID: 63,
TargetRegionID: 210,
USDMinor: 90_000,
RechargeType: "mifapay",
OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume scoped recharge: %v", err)
}
utcStart := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC).UnixMilli()
utcOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: utcStart, EndMS: utcStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
if err != nil {
t.Fatalf("query utc overview: %v", err)
}
if utcOverview.RechargeUSDMinor != 0 {
t.Fatalf("scoped replay must not mutate UTC rows: %+v", utcOverview)
}
beijingStart := time.Date(2026, 6, 23, 16, 0, 0, 0, time.UTC).UnixMilli()
chinaOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StatTZ: StatTZAsiaShanghai, StartMS: beijingStart, EndMS: beijingStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
if err != nil {
t.Fatalf("query china overview: %v", err)
}
if chinaOverview.RechargeUSDMinor != 90_000 || chinaOverview.StatTZ != StatTZAsiaShanghai {
t.Fatalf("scoped replay must write China stat_tz only: %+v", chinaOverview)
}
}
func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_delta_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, active_users, paid_users, recharge_usd_minor, new_user_recharge_usd_minor,
mifapay_recharge_usd_minor, gift_coin_spent, coin_seller_transfer_coin, lucky_gift_turnover, lucky_gift_payout,
game_turnover, game_payout, game_refund, updated_at_ms
) VALUES
('lalu', '2026-06-05', 210, 10, 2, 100, 50, 100, 300, 80, 100, 50, 500, 100, 50, 1),
('lalu', '2026-06-06', 210, 20, 5, 200, 100, 200, 600, 200, 400, 100, 1000, 400, 100, 1)`); err != nil {
t.Fatalf("seed delta statistics: %v", err)
}
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 210})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.RechargeDeltaRate != 1 || overview.NewUserRechargeDeltaRate != 1 || overview.ActiveUsersDeltaRate != 1 {
t.Fatalf("top delta rates mismatch: %+v", overview)
}
if overview.PaidUsersDeltaRate != 1.5 || overview.ARPUDeltaRate != 0 || overview.ARPPUDeltaRate != -0.2 {
t.Fatalf("user delta rates mismatch: %+v", overview)
}
if overview.RechargeUsers != overview.PaidUsers || overview.PaidConversionRate != 0.25 || overview.RechargeConversionRate != 0.25 || overview.PayerRateDeltaPP != 5 {
t.Fatalf("conversion aliases mismatch: %+v", overview)
}
if overview.GiftCoinSpentDeltaRate != 1 || overview.CoinSellerTransferDeltaRate != 1.5 || overview.LuckyGiftTurnoverDeltaRate != 3 || overview.LuckyGiftPayoutDeltaRate != 1 || overview.LuckyGiftProfitDeltaRate != 5 {
t.Fatalf("coin delta rates mismatch: %+v", overview)
}
if overview.GameTurnoverDeltaRate != 1 || overview.GameProfitDeltaRate < 0.428 || overview.GameProfitDeltaRate > 0.429 {
t.Fatalf("game delta rates mismatch: %+v", overview)
}
}
func TestQueryOverviewReturnsRetentionForAllBreakdowns(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_retention_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
today, err := time.Parse("2006-01-02", statDayIn(time.Now().UnixMilli(), StatTZUTC))
if err != nil {
t.Fatalf("parse today: %v", err)
}
registeredAt := today.AddDate(0, 0, -1)
registeredDay := registeredAt.Format("2006-01-02")
retainedDay := today.Format("2006-01-02")
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, new_users, active_users, updated_at_ms
) VALUES ('lalu', ?, 86, 210, 2, 2, 1)`, registeredDay); err != nil {
t.Fatalf("seed day country stats: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_user_registration (
app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms
) VALUES
('lalu', 'UTC', 9001, ?, 86, 210, 1),
('lalu', 'UTC', 9002, ?, 86, 210, 2)`, registeredDay, registeredDay); err != nil {
t.Fatalf("seed registrations: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_user_day_activity (
app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms
) VALUES ('lalu', 'UTC', ?, 86, 210, 9001, 3)`, retainedDay); err != nil {
t.Fatalf("seed retained activity: %v", err)
}
overview, err := repository.QueryOverview(ctx, OverviewQuery{
AppCode: "lalu",
StartMS: registeredAt.UnixMilli(),
EndMS: registeredAt.AddDate(0, 0, 1).UnixMilli(),
RegionID: 210,
})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.Retention.RegisteredUsers != 2 {
t.Fatalf("retention registered users mismatch: %+v", overview.Retention)
}
requireInt64Ptr(t, "overview d1 users", overview.Retention.Day1Users, 1)
requireInt64Ptr(t, "overview d1 base", overview.Retention.Day1BaseUsers, 2)
requireFloat64Ptr(t, "overview d1 rate", overview.Retention.Day1Rate, 0.5)
requireNilRetention(t, "overview d7", overview.Retention.Day7Users, overview.Retention.Day7BaseUsers, overview.Retention.Day7Rate)
requireNilRetention(t, "overview d30", overview.Retention.Day30Users, overview.Retention.Day30BaseUsers, overview.Retention.Day30Rate)
if len(overview.DailySeries) != 1 {
t.Fatalf("daily series missing retention day: %+v", overview.DailySeries)
}
requireInt64Ptr(t, "daily d1 users", overview.DailySeries[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "daily d1 base", overview.DailySeries[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "daily d1 rate", overview.DailySeries[0].D1RetentionRate, 0.5)
requireNilRetention(t, "daily d7", overview.DailySeries[0].D7RetentionUsers, overview.DailySeries[0].D7RetentionBaseUsers, overview.DailySeries[0].D7RetentionRate)
if len(overview.CountryBreakdown) != 1 {
t.Fatalf("country breakdown missing retention country: %+v", overview.CountryBreakdown)
}
requireInt64Ptr(t, "country d1 users", overview.CountryBreakdown[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "country d1 base", overview.CountryBreakdown[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "country d1 rate", overview.CountryBreakdown[0].D1RetentionRate, 0.5)
if len(overview.DailyCountryBreakdown) != 1 {
t.Fatalf("daily country breakdown missing retention country: %+v", overview.DailyCountryBreakdown)
}
requireInt64Ptr(t, "daily country d1 users", overview.DailyCountryBreakdown[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "daily country d1 base", overview.DailyCountryBreakdown[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "daily country d1 rate", overview.DailyCountryBreakdown[0].D1RetentionRate, 0.5)
}
func TestQueryRetentionCubeRollsUpDailyCountryRows(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
repository := &Repository{db: db}
today := statDayIn(time.Now().UnixMilli(), StatTZUTC)
rows := sqlmock.NewRows([]string{
"registered_day", "country_id", "registered_users",
"d1_users", "d1_base", "d7_users", "d7_base", "d30_users", "d30_base",
}).
AddRow("2026-06-01", int64(86), int64(2), int64(1), int64(2), int64(0), int64(0), int64(0), int64(0)).
AddRow("2026-06-01", int64(66), int64(3), int64(0), int64(3), int64(1), int64(3), int64(0), int64(0)).
AddRow("2026-06-02", int64(86), int64(4), int64(2), int64(4), int64(0), int64(0), int64(0), int64(0))
mock.ExpectQuery("SELECT DATE_FORMAT").
WithArgs(today, today, today, today, today, today, "lalu", StatTZUTC, "2026-06-01", "2026-06-02", int64(210)).
WillReturnRows(rows)
cube, err := repository.queryRetentionCube(ctx, "lalu", StatTZUTC, "2026-06-01", "2026-06-02", 0, 210)
if err != nil {
t.Fatalf("query retention cube: %v", err)
}
if cube.Total.RegisteredUsers != 9 || cube.Total.D1Users != 3 || cube.Total.D1Base != 9 || cube.Total.D7Users != 1 || cube.Total.D7Base != 3 {
t.Fatalf("total retention rollup mismatch: %+v", cube.Total)
}
retention := cube.Total.toRetention()
requireInt64Ptr(t, "total d1 users", retention.Day1Users, 3)
requireFloat64Ptr(t, "total d1 rate", retention.Day1Rate, 1.0/3.0)
requireNilRetention(t, "total d30", retention.Day30Users, retention.Day30BaseUsers, retention.Day30Rate)
day := cube.ByDay["2026-06-01"]
if day.RegisteredUsers != 5 || day.D1Users != 1 || day.D1Base != 5 || day.D7Users != 1 || day.D7Base != 3 {
t.Fatalf("day retention rollup mismatch: %+v", day)
}
country := cube.ByCountry[86]
if country.RegisteredUsers != 6 || country.D1Users != 3 || country.D1Base != 6 {
t.Fatalf("country retention rollup mismatch: %+v", country)
}
dayCountry := cube.ByDayCountry[dailyCountryKey("2026-06-01", 66)]
if dayCountry.RegisteredUsers != 3 || dayCountry.D1Users != 0 || dayCountry.D1Base != 3 || dayCountry.D7Users != 1 || dayCountry.D7Base != 3 {
t.Fatalf("daily country retention rollup mismatch: %+v", dayCountry)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestQueryOverviewReturnsRealRoomRobotGiftStats(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_robot_gift_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_real_room_robot_gift_day_rooms (
app_code, stat_day, country_id, region_id, room_id,
normal_gift_coin, lucky_gift_coin, super_lucky_gift_coin, updated_at_ms
) VALUES
('lalu', '2026-06-06', 210, 8, 'room-a', 100, 200, 0, 1),
('lalu', '2026-06-06', 210, 8, 'room-b', 0, 0, 300, 1),
('lalu', '2026-06-06', 211, 9, 'room-c', 900, 0, 0, 1)`); err != nil {
t.Fatalf("seed real room robot gifts: %v", err)
}
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 210, RegionID: 8})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.RealRoomRobotNormalGiftCoin != 100 || overview.RealRoomRobotLuckyGiftCoin != 200 || overview.RealRoomRobotSuperGiftCoin != 300 {
t.Fatalf("robot gift breakdown mismatch: %+v", overview)
}
if overview.RealRoomRobotTotalGiftCoin != 600 || overview.RealRoomRobotGiftRoomCount != 2 || overview.RealRoomRobotAvgRoomGiftCoin != 300 {
t.Fatalf("robot gift aggregate mismatch: %+v", overview)
}
}
func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_country_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
coin_seller_recharge_usd_minor, coin_seller_stock_coin, google_recharge_usd_minor, updated_at_ms
) VALUES
('lalu', '2026-06-06', 86, 210, 10, 2, 150, 400, 880000, 150, 1),
('lalu', '2026-06-06', 66, 210, 5, 1, 100, 0, 0, 100, 1),
('lalu', '2026-06-06', 99, 220, 3, 1, 900, 0, 0, 900, 1)`); err != nil {
t.Fatalf("seed country statistics: %v", err)
}
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), RegionID: 210})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.RechargeUSDMinor != 650 || overview.CoinSellerRechargeUSDMinor != 400 || overview.CoinSellerStockCoin != 880000 {
t.Fatalf("overview recharge mismatch: %+v", overview)
}
if overview.RechargeUsers != 3 || overview.PaidConversionRate != 0.2 || overview.RechargeConversionRate != 0.2 {
t.Fatalf("overview conversion fields mismatch: %+v", overview)
}
if len(overview.CountryBreakdown) != 2 {
t.Fatalf("country breakdown should come from aggregate query: %+v", overview.CountryBreakdown)
}
if overview.CountryBreakdown[0].CountryID != 86 || overview.CountryBreakdown[0].RechargeUSDMinor != 550 || overview.CountryBreakdown[0].CoinSellerStockCoin != 880000 || overview.CountryBreakdown[0].ARPPUUSDMinor != 275 {
t.Fatalf("first country breakdown mismatch: %+v", overview.CountryBreakdown[0])
}
if overview.CountryBreakdown[0].RechargeUsers != 2 || overview.CountryBreakdown[0].PaidConversionRate != 0.2 || overview.CountryBreakdown[0].RechargeConversionRate != 0.2 {
t.Fatalf("first country conversion fields mismatch: %+v", overview.CountryBreakdown[0])
}
}
func TestQueryOverviewReturnsDailyCountryBreakdownAndSuperLuckyGift(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_daily_country_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
coin_seller_stock_coin, lucky_gift_turnover, lucky_gift_payout, updated_at_ms
) VALUES
('lalu', '2026-06-05', 86, 210, 10, 2, 100, 7000, 900, 200, 1),
('lalu', '2026-06-05', 66, 210, 5, 1, 80, 1000, 100, 20, 1),
('lalu', '2026-06-06', 86, 210, 12, 3, 150, 9000, 1200, 300, 1)`); err != nil {
t.Fatalf("seed app country stats: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_lucky_gift_pool_day_country (
app_code, stat_day, country_id, region_id, pool_id, turnover_coin, payout_coin, updated_at_ms
) VALUES
('lalu', '2026-06-05', 86, 210, 'super_lucky', 700, 120, 1),
('lalu', '2026-06-05', 66, 210, 'lucky', 100, 20, 1),
('lalu', '2026-06-06', 86, 210, 'super_lucky_v2', 900, 180, 1)`); err != nil {
t.Fatalf("seed lucky gift pools: %v", err)
}
start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), RegionID: 210})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.SuperLuckyGiftTurnover != 1600 || overview.SuperLuckyGiftPayout != 300 || overview.SuperLuckyGiftProfit != 1300 {
t.Fatalf("super lucky overview mismatch: %+v", overview)
}
if len(overview.DailyCountryBreakdown) != 3 {
t.Fatalf("daily country breakdown length mismatch: %+v", overview.DailyCountryBreakdown)
}
first := overview.DailyCountryBreakdown[0]
if first.StatDay != "2026-06-05" || first.CountryID != 86 || first.CoinSellerStockCoin != 7000 || first.SuperLuckyGiftTurnover != 700 || first.SuperLuckyGiftProfitRate <= 0 {
t.Fatalf("first daily country row mismatch: %+v", first)
}
if first.RechargeUsers != 2 || first.PaidConversionRate != 0.2 || first.RechargeConversionRate != 0.2 {
t.Fatalf("first daily country conversion fields mismatch: %+v", first)
}
if len(overview.ReportMetricSources) == 0 {
t.Fatalf("report metric sources should document available and missing report fields")
}
}
func TestQuerySelfGameOverviewUsesRealUserStakeForPlatformProfitRate(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_self_game_profit_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_self_game_match_day (
app_code, stat_day, game_id, stake_coin, settled_matches,
user_stake_coin, robot_stake_coin, payout_coin, refund_coin, platform_profit_coin, updated_at_ms
) VALUES
('lalu', '2026-06-06', 'dice', 100, 1, 1000, 1000, 700, 100, 200, 1),
('lalu', '2026-06-06', 'dice', 500, 1, 2000, 2000, 1500, 100, 400, 1)`); err != nil {
t.Fatalf("seed self game match stats: %v", err)
}
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"})
if err != nil {
t.Fatalf("query self game overview: %v", err)
}
if overview.MatchTotals.PlatformProfitRate != 0.2 {
t.Fatalf("platform profit rate should use real user stake only: %+v", overview.MatchTotals)
}
if len(overview.StakeBreakdown) != 2 || overview.StakeBreakdown[0].PlatformProfitRate != 0.2 || overview.StakeBreakdown[1].PlatformProfitRate != 0.2 {
t.Fatalf("stake platform profit rate should use real user stake only: %+v", overview.StakeBreakdown)
}
}
func TestQuerySelfGameOverviewKeepsParticipantWinRateInStakeBucket(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_self_game_participant_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_self_game_participant_day (
app_code, stat_day, game_id, stake_coin, participant_type, result, dice_point, participant_count, updated_at_ms
) VALUES
('lalu', '2026-06-06', 'dice', 100, 'user', 'win', 6, 1, 1),
('lalu', '2026-06-06', 'dice', 100, 'user', 'lose', 6, 3, 1),
('lalu', '2026-06-06', 'dice', 500, 'user', 'win', 6, 4, 1)`); err != nil {
t.Fatalf("seed self game participant stats: %v", err)
}
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"})
if err != nil {
t.Fatalf("query self game overview: %v", err)
}
rates := map[int64]float64{}
for _, item := range overview.ParticipantDistribution {
if item.Result == "win" {
rates[item.StakeCoin] = item.WinRate
}
}
if rates[100] != 0.25 || rates[500] != 1 {
t.Fatalf("participant win rate should stay in stake bucket: %+v", overview.ParticipantDistribution)
}
}
func TestQuerySelfGameOverviewUsesLatestPoolBalanceSnapshot(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_self_game_pool_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_self_game_pool_day (
app_code, stat_day, game_id, direction, reason, transaction_count, in_coin, out_coin, balance_after_coin, updated_at_ms
) VALUES
('lalu', '2026-06-05', 'dice', 'out', 'dice_settlement', 1, 0, 100, 5000, 1),
('lalu', '2026-06-06', 'dice', 'out', 'dice_settlement', 1, 0, 200, 4800, 2)`); err != nil {
t.Fatalf("seed self game pool stats: %v", err)
}
start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).UnixMilli()
overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), GameID: "dice"})
if err != nil {
t.Fatalf("query self game overview: %v", err)
}
if len(overview.PoolStats) != 1 || overview.PoolStats[0].OutCoin != 300 || overview.PoolStats[0].BalanceAfterCoin != 4800 {
t.Fatalf("pool balance should be latest snapshot, not max balance: %+v", overview.PoolStats)
}
}
func requireInt64Ptr(t *testing.T, name string, got *int64, want int64) {
t.Helper()
if got == nil || *got != want {
t.Fatalf("%s mismatch: got=%v want=%d", name, got, want)
}
}
func requireFloat64Ptr(t *testing.T, name string, got *float64, want float64) {
t.Helper()
if got == nil || math.Abs(*got-want) > 0.000001 {
t.Fatalf("%s mismatch: got=%v want=%f", name, got, want)
}
}
func requireNilRetention(t *testing.T, name string, users *int64, base *int64, rate *float64) {
t.Helper()
if users != nil || base != nil || rate != nil {
t.Fatalf("%s should be nil before cohort matures: users=%v base=%v rate=%v", name, users, base, rate)
}
}