471 lines
16 KiB
Go
471 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
|
"hyapp/internal/testutil/mysqlschema"
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/gamemq"
|
|
"hyapp/pkg/roommq"
|
|
"hyapp/pkg/walletmq"
|
|
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
|
|
)
|
|
|
|
func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, file, _, _ := runtime.Caller(0)
|
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
|
InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"),
|
|
DatabasePrefix: "hy_stats_flow",
|
|
})
|
|
repo, err := mysqlstorage.Open(ctx, schema.DSN)
|
|
if err != nil {
|
|
t.Fatalf("open statistics repository failed: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = repo.Close() })
|
|
|
|
occurredAt := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC).UnixMilli()
|
|
dayStart := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC).UnixMilli()
|
|
const (
|
|
appCode = "lalu"
|
|
countryID = int64(86)
|
|
regionID = int64(210)
|
|
userID = int64(42)
|
|
)
|
|
|
|
googleRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
|
|
AppCode: appCode,
|
|
EventID: "WalletRechargeRecorded:google:1",
|
|
EventType: "WalletRechargeRecorded",
|
|
TransactionID: "wallet_tx_google_1",
|
|
CommandID: "google_order_1",
|
|
UserID: userID,
|
|
PayloadJSON: mustJSON(t, map[string]any{
|
|
"amount_micro": int64(1_500_000),
|
|
"country_id": countryID,
|
|
"region_id": regionID,
|
|
"channel": "google",
|
|
"recharge_sequence": int64(1),
|
|
}),
|
|
OccurredAtMS: occurredAt,
|
|
})
|
|
recharge, ok, err := rechargeEvent(googleRecharge)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode google recharge failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeRecharge(ctx, recharge); err != nil {
|
|
t.Fatalf("consume google recharge failed: %v", err)
|
|
}
|
|
|
|
coinSellerStock := walletMessage(t, walletmq.WalletOutboxMessage{
|
|
AppCode: appCode,
|
|
EventID: "WalletCoinSellerStockPurchased:1",
|
|
EventType: "WalletCoinSellerStockPurchased",
|
|
TransactionID: "wallet_tx_coin_seller_stock_1",
|
|
CommandID: "coin_seller_stock_1",
|
|
UserID: userID + 10,
|
|
PayloadJSON: mustJSON(t, map[string]any{
|
|
"seller_user_id": userID + 10,
|
|
"country_id": countryID,
|
|
"region_id": regionID,
|
|
"coin_amount": int64(400_000),
|
|
"paid_amount_micro": int64(4_000_000),
|
|
"counts_as_seller_recharge": true,
|
|
"stock_type": "usdt_purchase",
|
|
}),
|
|
OccurredAtMS: occurredAt,
|
|
})
|
|
stock, ok, err := coinSellerStockEvent(coinSellerStock)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode coin seller stock failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeCoinSellerStock(ctx, stock); err != nil {
|
|
t.Fatalf("consume coin seller stock failed: %v", err)
|
|
}
|
|
|
|
coinSellerRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
|
|
AppCode: appCode,
|
|
EventID: "WalletRechargeRecorded:coin_seller:1",
|
|
EventType: "WalletRechargeRecorded",
|
|
TransactionID: "wallet_tx_coin_seller_1",
|
|
CommandID: "coin_seller_transfer_1",
|
|
UserID: userID + 1,
|
|
PayloadJSON: mustJSON(t, map[string]any{
|
|
"amount": int64(160_000),
|
|
"recharge_type": "coin_seller_transfer",
|
|
"target_country_id": countryID,
|
|
"target_region_id": regionID,
|
|
"recharge_sequence": int64(1),
|
|
}),
|
|
OccurredAtMS: occurredAt,
|
|
})
|
|
coinSeller, ok, err := rechargeEvent(coinSellerRecharge)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode coin seller recharge failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeRecharge(ctx, coinSeller); err != nil {
|
|
t.Fatalf("consume coin seller recharge failed: %v", err)
|
|
}
|
|
|
|
roomGiftBody := roomGiftMessage(t, appCode, "room_gift_1", "room_1", occurredAt, &roomeventsv1.RoomGiftSent{
|
|
SenderUserId: userID,
|
|
TargetUserId: 99,
|
|
GiftId: "rose",
|
|
GiftCount: 10,
|
|
GiftValue: 1_000,
|
|
CoinSpent: 1_250,
|
|
VisibleRegionId: regionID,
|
|
CommandId: "gift_command_1",
|
|
PoolId: "lucky_100",
|
|
})
|
|
gift, ok, err := roomGiftEvent(roomGiftBody)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode room gift failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeRoomGift(ctx, gift); err != nil {
|
|
t.Fatalf("consume room gift failed: %v", err)
|
|
}
|
|
|
|
luckyReward := walletMessage(t, walletmq.WalletOutboxMessage{
|
|
AppCode: appCode,
|
|
EventID: "WalletLuckyGiftRewardCredited:1",
|
|
EventType: "WalletLuckyGiftRewardCredited",
|
|
TransactionID: "wallet_tx_lucky_1",
|
|
CommandID: "lucky_reward:lucky_draw_1",
|
|
UserID: userID,
|
|
PayloadJSON: mustJSON(t, map[string]any{
|
|
"user_id": userID,
|
|
"draw_id": "lucky_draw_1",
|
|
"room_id": "room_1",
|
|
"pool_id": "lucky_100",
|
|
"amount": int64(300),
|
|
"country_id": countryID,
|
|
"visible_region_id": regionID,
|
|
}),
|
|
OccurredAtMS: occurredAt,
|
|
})
|
|
reward, ok, err := luckyGiftRewardEvent(luckyReward)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode lucky reward failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeLuckyGiftReward(ctx, reward); err != nil {
|
|
t.Fatalf("consume lucky reward failed: %v", err)
|
|
}
|
|
|
|
for _, message := range []gamemq.GameOutboxMessage{
|
|
gameMessage(appCode, "game_order_debit_1", userID, "debit", 700, countryID, regionID, occurredAt),
|
|
gameMessage(appCode, "game_order_credit_1", userID, "credit", 200, countryID, regionID, occurredAt),
|
|
} {
|
|
body, err := gamemq.EncodeGameOutboxMessage(message)
|
|
if err != nil {
|
|
t.Fatalf("encode game message failed: %v", err)
|
|
}
|
|
event, ok, err := gameOrderEvent(body)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode game order failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeGameOrder(ctx, event); err != nil {
|
|
t.Fatalf("consume game order failed: %v", err)
|
|
}
|
|
}
|
|
|
|
overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
|
|
AppCode: appCode,
|
|
StartMS: dayStart,
|
|
EndMS: dayStart + int64(24*time.Hour/time.Millisecond),
|
|
RegionID: regionID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("query overview failed: %v", err)
|
|
}
|
|
if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 400 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 550 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 {
|
|
t.Fatalf("google recharge metrics mismatch: %+v", overview)
|
|
}
|
|
if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 {
|
|
t.Fatalf("lucky gift metrics mismatch: %+v", overview)
|
|
}
|
|
if len(overview.LuckyGiftPools) != 1 {
|
|
t.Fatalf("lucky gift pool breakdown missing: %+v", overview.LuckyGiftPools)
|
|
}
|
|
pool := overview.LuckyGiftPools[0]
|
|
if pool.PoolID != "lucky_100" || pool.TurnoverCoin != 1_250 || pool.PayoutCoin != 300 || pool.ProfitCoin != 950 {
|
|
t.Fatalf("lucky gift pool metrics mismatch: %+v", pool)
|
|
}
|
|
if overview.GameTurnover != 700 || overview.GamePayout != 200 || overview.GameProfit != 500 || overview.GamePlayers != 1 {
|
|
t.Fatalf("game metrics mismatch: %+v", overview)
|
|
}
|
|
}
|
|
|
|
func TestLocalSelfGameStatisticsFlow(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, file, _, _ := runtime.Caller(0)
|
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
|
InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"),
|
|
DatabasePrefix: "hy_self_game_stats",
|
|
})
|
|
repo, err := mysqlstorage.Open(ctx, schema.DSN)
|
|
if err != nil {
|
|
t.Fatalf("open statistics repository failed: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = repo.Close() })
|
|
|
|
occurredAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC).UnixMilli()
|
|
dayStart := time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC).UnixMilli()
|
|
const (
|
|
appCode = "lalu"
|
|
regionID = int64(210)
|
|
userID = int64(42)
|
|
)
|
|
appCtx := appcode.WithContext(ctx, appCode)
|
|
for _, eventName := range []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"} {
|
|
if err := repo.ConsumeSelfGameH5Event(appCtx, mysqlstorage.SelfGameH5Event{
|
|
AppCode: appCode,
|
|
EventID: "h5:" + eventName,
|
|
EventName: eventName,
|
|
GameID: "dice",
|
|
UserID: userID,
|
|
RegionID: regionID,
|
|
H5Version: "dice-h5-test",
|
|
EntrySource: "room",
|
|
DurationMS: 20,
|
|
Success: true,
|
|
OccurredAtMS: occurredAt,
|
|
}); err != nil {
|
|
t.Fatalf("consume h5 event %s failed: %v", eventName, err)
|
|
}
|
|
}
|
|
|
|
body, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
|
|
AppCode: appCode,
|
|
EventID: "SelfGameMatchSettled:dice_match_1",
|
|
EventType: gamemq.EventTypeSelfGameMatchSettled,
|
|
OrderID: "",
|
|
UserID: userID,
|
|
PlatformCode: "self",
|
|
GameID: "dice",
|
|
PayloadJSON: mustJSON(t, map[string]any{
|
|
"match_id": "dice_match_1",
|
|
"game_id": "dice",
|
|
"platform_code": "self",
|
|
"region_id": regionID,
|
|
"stake_coin": int64(500),
|
|
"status": "settled",
|
|
"result": "win",
|
|
"match_mode": "robot",
|
|
"forced_result": "",
|
|
"user_participants": int64(1),
|
|
"robot_participants": int64(1),
|
|
"winner_count": int64(1),
|
|
"loser_count": int64(1),
|
|
"draw_count": int64(0),
|
|
"user_stake_coin": int64(500),
|
|
"robot_stake_coin": int64(500),
|
|
"payout_coin": int64(900),
|
|
"refund_coin": int64(0),
|
|
"platform_profit_coin": int64(100),
|
|
"pool_delta_coin": int64(50),
|
|
"wait_ms": int64(1200),
|
|
"duration_ms": int64(4800),
|
|
"participants": []map[string]any{
|
|
{
|
|
"user_id": userID,
|
|
"participant_type": "user",
|
|
"stake_coin": int64(500),
|
|
"dice_points": []int64{6},
|
|
"result": "win",
|
|
"payout_coin": int64(900),
|
|
"net_win_coin": int64(400),
|
|
},
|
|
{
|
|
"user_id": int64(0),
|
|
"participant_type": "robot",
|
|
"stake_coin": int64(500),
|
|
"dice_points": []int64{2},
|
|
"result": "lose",
|
|
"payout_coin": int64(0),
|
|
"net_win_coin": int64(-500),
|
|
},
|
|
},
|
|
}),
|
|
OccurredAtMS: occurredAt,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("encode self game match failed: %v", err)
|
|
}
|
|
match, ok, err := selfGameMatchEvent(body)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode self game match failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeSelfGameMatch(appCtx, match); err != nil {
|
|
t.Fatalf("consume self game match failed: %v", err)
|
|
}
|
|
poolBody, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
|
|
AppCode: appCode,
|
|
EventID: "SelfGamePoolAdjusted:pool_adjust_1",
|
|
EventType: gamemq.EventTypeSelfGamePoolAdjusted,
|
|
UserID: userID,
|
|
PlatformCode: "self",
|
|
GameID: "dice",
|
|
PayloadJSON: mustJSON(t, map[string]any{
|
|
"adjustment_id": "pool_adjust_1",
|
|
"game_id": "dice",
|
|
"match_id": "dice_match_1",
|
|
"user_id": userID,
|
|
"direction": "in",
|
|
"amount_coin": int64(50),
|
|
"reason": "match_pool_in",
|
|
"balance_after": int64(1050),
|
|
}),
|
|
OccurredAtMS: occurredAt,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("encode self game pool failed: %v", err)
|
|
}
|
|
pool, ok, err := selfGamePoolAdjustmentEvent(poolBody)
|
|
if err != nil || !ok {
|
|
t.Fatalf("decode self game pool failed: ok=%v err=%v", ok, err)
|
|
}
|
|
if err := repo.ConsumeSelfGamePoolAdjustment(appCtx, pool); err != nil {
|
|
t.Fatalf("consume self game pool failed: %v", err)
|
|
}
|
|
for _, dayOffset := range []int{1, 7} {
|
|
if err := repo.ConsumeSelfGameMatch(appCtx, mysqlstorage.SelfGameMatchEvent{
|
|
AppCode: appCode,
|
|
EventID: "SelfGameMatchSettled:dice_retention_" + string(rune('0'+dayOffset)),
|
|
EventType: gamemq.EventTypeSelfGameMatchSettled,
|
|
MatchID: "dice_retention",
|
|
GameID: "dice",
|
|
RegionID: regionID,
|
|
StakeCoin: 500,
|
|
UserParticipants: 1,
|
|
WinnerCount: 1,
|
|
UserStakeCoin: 500,
|
|
PayoutCoin: 900,
|
|
Participants: []mysqlstorage.SelfGameMatchParticipantEvent{{
|
|
UserID: userID,
|
|
ParticipantType: "user",
|
|
StakeCoin: 500,
|
|
DicePoints: []int64{5},
|
|
Result: "win",
|
|
PayoutCoin: 900,
|
|
NetWinCoin: 400,
|
|
}},
|
|
OccurredAtMS: time.UnixMilli(occurredAt).AddDate(0, 0, dayOffset).UnixMilli(),
|
|
}); err != nil {
|
|
t.Fatalf("consume retention self game match day+%d failed: %v", dayOffset, err)
|
|
}
|
|
}
|
|
|
|
overview, err := repo.QuerySelfGameOverview(ctx, mysqlstorage.SelfGameOverviewQuery{
|
|
AppCode: appCode,
|
|
StartMS: dayStart,
|
|
EndMS: dayStart + int64(24*time.Hour/time.Millisecond),
|
|
GameID: "dice",
|
|
RegionID: regionID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("query self game overview failed: %v", err)
|
|
}
|
|
if overview.Conversion.PageToMatchClickRate != 1 || overview.Conversion.MatchClickToSuccessRate != 1 || overview.Conversion.SuccessToSettlementRate != 1 || overview.Conversion.SettlementToPlayAgainRate != 1 {
|
|
t.Fatalf("self game funnel mismatch: %+v", overview.Conversion)
|
|
}
|
|
if overview.MatchTotals.SettledMatches != 1 || overview.MatchTotals.RobotMatches != 1 || overview.MatchTotals.UserStakeCoin != 500 || overview.MatchTotals.PayoutCoin != 900 || overview.MatchTotals.PlatformProfitCoin != 100 || overview.MatchTotals.PoolDeltaCoin != 50 {
|
|
t.Fatalf("self game match totals mismatch: %+v", overview.MatchTotals)
|
|
}
|
|
if len(overview.StakeBreakdown) != 1 || overview.StakeBreakdown[0].StakeCoin != 500 || overview.StakeBreakdown[0].PlatformProfitCoin != 100 {
|
|
t.Fatalf("self game stake breakdown mismatch: %+v", overview.StakeBreakdown)
|
|
}
|
|
if len(overview.PoolStats) != 1 || overview.PoolStats[0].InCoin != 50 || overview.PoolStats[0].BalanceAfterCoin != 1050 {
|
|
t.Fatalf("self game pool stats mismatch: %+v", overview.PoolStats)
|
|
}
|
|
if len(overview.UserRisk) != 1 || overview.UserRisk[0].UserID != userID || overview.UserRisk[0].WinCount != 1 || overview.UserRisk[0].NetWinCoin != 400 {
|
|
t.Fatalf("self game user risk mismatch: %+v", overview.UserRisk)
|
|
}
|
|
if overview.Retention.CohortUsers != 1 || overview.Retention.Day1Users != 1 || overview.Retention.Day7Users != 1 || overview.Retention.Day1Rate != 1 || overview.Retention.Day7Rate != 1 {
|
|
t.Fatalf("self game retention mismatch: %+v", overview.Retention)
|
|
}
|
|
if len(overview.MetricDefinitions) == 0 {
|
|
t.Fatalf("self game metric definitions missing")
|
|
}
|
|
}
|
|
|
|
func walletMessage(t *testing.T, message walletmq.WalletOutboxMessage) []byte {
|
|
t.Helper()
|
|
body, err := walletmq.EncodeWalletOutboxMessage(message)
|
|
if err != nil {
|
|
t.Fatalf("encode wallet message failed: %v", err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func roomGiftMessage(t *testing.T, appCode string, eventID string, roomID string, occurredAt int64, gift *roomeventsv1.RoomGiftSent) []byte {
|
|
t.Helper()
|
|
giftBody, err := proto.Marshal(gift)
|
|
if err != nil {
|
|
t.Fatalf("marshal gift failed: %v", err)
|
|
}
|
|
body, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{
|
|
EventId: eventID,
|
|
RoomId: roomID,
|
|
EventType: "RoomGiftSent",
|
|
RoomVersion: 1,
|
|
OccurredAtMs: occurredAt,
|
|
Body: giftBody,
|
|
AppCode: appCode,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("encode room message failed: %v", err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func gameMessage(appCode string, orderID string, userID int64, opType string, amount int64, countryID int64, regionID int64, occurredAt int64) gamemq.GameOutboxMessage {
|
|
return gamemq.GameOutboxMessage{
|
|
AppCode: appCode,
|
|
EventID: "GameOrderSettled:" + orderID,
|
|
EventType: "GameOrderSettled",
|
|
OrderID: orderID,
|
|
UserID: userID,
|
|
PlatformCode: "yomi",
|
|
GameID: "fish_001",
|
|
OpType: opType,
|
|
CoinAmount: amount,
|
|
PayloadJSON: mustJSONNoT(map[string]any{
|
|
"order_id": orderID,
|
|
"platform_code": "yomi",
|
|
"game_id": "fish_001",
|
|
"user_id": userID,
|
|
"room_id": "room_1",
|
|
"visible_region_id": regionID,
|
|
"country_id": countryID,
|
|
"region_id": regionID,
|
|
"op_type": opType,
|
|
"coin_amount": amount,
|
|
}),
|
|
OccurredAtMS: occurredAt,
|
|
}
|
|
}
|
|
|
|
func mustJSON(t *testing.T, value map[string]any) string {
|
|
t.Helper()
|
|
body, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatalf("marshal json failed: %v", err)
|
|
}
|
|
return string(body)
|
|
}
|
|
|
|
func mustJSONNoT(value map[string]any) string {
|
|
body, err := json.Marshal(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(body)
|
|
}
|