2026-06-12 00:35:14 +08:00

547 lines
21 KiB
Go

package mysql
import (
"context"
"fmt"
"strings"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
dicedomain "hyapp/services/game-service/internal/domain/dice"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
func TestMarkOrderSucceededCreatesClaimableLevelOutboxEvent(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
order := gamedomain.GameOrder{
AppCode: "lalu",
OrderID: "order_1",
PlatformCode: "demo",
ProviderOrderID: "provider_order_1",
GameID: "demo_rocket_001",
ProviderGameID: "rocket_001",
UserID: 42,
RoomID: "room_1",
OpType: "debit",
CoinAmount: 120,
Status: gamedomain.OrderStatusWalletApplying,
RequestHash: "hash_1",
CreatedAtMS: 1700000000000,
UpdatedAtMS: 1700000000000,
}
if _, _, err := repo.CreateGameOrder(ctx, order); err != nil {
t.Fatalf("create order failed: %v", err)
}
if err := repo.MarkOrderSucceeded(ctx, "lalu", "order_1", "wtx-game-1", 880, 1700000001000); err != nil {
t.Fatalf("mark order succeeded failed: %v", err)
}
events, err := repo.ClaimPendingLevelEvents(ctx, "worker_1", 1700000002000, 30*time.Second, 10)
if err != nil {
t.Fatalf("claim level events failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected one level event, got %+v", events)
}
event := events[0]
if event.EventID != "game_level:order_1" || event.UserID != 42 || event.CoinAmount != 120 {
t.Fatalf("level event mismatch: %+v", event)
}
if event.PayloadJSON == "" || event.WalletTransactionID != "wtx-game-1" {
t.Fatalf("level event payload mismatch: %+v", event)
}
}
func TestClaimPendingLevelEventsPreservesCreatedOrderAcrossStatuses(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
insertLevelEvent := func(eventID string, orderID string, status string, nextRetryAtMS int64, createdAtMS int64, payload string) {
t.Helper()
if _, err := repo.db.ExecContext(ctx, `
INSERT INTO game_level_event_outbox (
app_code, event_id, order_id, user_id, game_id, provider_order_id,
provider_round_id, coin_amount, wallet_transaction_id, payload_json,
status, attempt_count, next_retry_at_ms, locked_by, locked_until_ms,
failure_reason, occurred_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 42, 'game_1', ?, '', 100, 'wallet_tx_1', ?,
?, 0, ?, '', 0, '', ?, ?, ?)`,
"lalu", eventID, orderID, "provider_"+orderID, payload, status, nextRetryAtMS, createdAtMS, createdAtMS, createdAtMS,
); err != nil {
t.Fatalf("insert level event %s failed: %v", eventID, err)
}
}
insertLevelEvent("event_failed_old", "order_failed_old", "failed", 1000, 1000, `{"source":"failed"}`)
insertLevelEvent("event_failed_future", "order_failed_future", "failed", 999999, 1500, `{"source":"future"}`)
insertLevelEvent("event_pending_new", "order_pending_new", "pending", 0, 2000, `{"source":"pending"}`)
events, err := repo.ClaimPendingLevelEvents(ctx, "worker_1", 3000, 30*time.Second, 2)
if err != nil {
t.Fatalf("claim level events failed: %v", err)
}
if len(events) != 2 {
t.Fatalf("expected two claimed events, got %+v", events)
}
if events[0].EventID != "event_failed_old" || events[0].Status != "failed" || !strings.Contains(events[0].PayloadJSON, `"source": "failed"`) {
t.Fatalf("first claimed event should be the older failed payload, got %+v", events[0])
}
if events[1].EventID != "event_pending_new" || events[1].Status != "pending" || !strings.Contains(events[1].PayloadJSON, `"source": "pending"`) {
t.Fatalf("second claimed event should be the pending payload, got %+v", events[1])
}
var runningCount int
if err := repo.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM game_level_event_outbox
WHERE app_code = 'lalu' AND status = 'running' AND locked_by = 'worker_1' AND locked_until_ms = 33000`,
).Scan(&runningCount); err != nil {
t.Fatalf("query running events failed: %v", err)
}
if runningCount != 2 {
t.Fatalf("expected two running claimed events, got %d", runningCount)
}
var futureStatus string
if err := repo.db.QueryRowContext(ctx, `
SELECT status FROM game_level_event_outbox WHERE app_code = 'lalu' AND event_id = 'event_failed_future'`,
).Scan(&futureStatus); err != nil {
t.Fatalf("query future retry event failed: %v", err)
}
if futureStatus != "failed" {
t.Fatalf("future retry event should stay failed, got %s", futureStatus)
}
}
func TestLevelEventClaimKeyQueryUsesOrderIndexWithoutFilesort(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
var plan string
if err := repo.db.QueryRowContext(ctx, `
EXPLAIN FORMAT=JSON
SELECT event_id, created_at_ms
FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim_order)
WHERE app_code = ? AND status = ? AND locked_until_ms = 0 AND next_retry_at_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT 100`,
"lalu", "pending", int64(1780542672724),
).Scan(&plan); err != nil {
t.Fatalf("explain level event claim key query failed: %v", err)
}
if !strings.Contains(plan, `"key": "idx_game_level_event_claim_order"`) {
t.Fatalf("claim key query did not use idx_game_level_event_claim_order:\n%s", plan)
}
if strings.Contains(strings.ToLower(plan), `"using_filesort": true`) {
t.Fatalf("claim key query should avoid filesort:\n%s", plan)
}
}
func TestPickActiveDiceRobotRotatesByCooldownBeforeReusing(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
nowMS := int64(1780542672000)
userIDs := []int64{101, 102, 103, 104}
for index, userID := range userIDs {
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO game_self_game_robots (
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms,
last_used_at_ms, used_count
) VALUES (?, ?, ?, ?, 7, ?, ?, 0, 0)`,
"lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index),
); err != nil {
t.Fatalf("insert robot %d failed: %v", userID, err)
}
}
for attempt, wantUserID := range userIDs {
robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_rotate_%d", attempt), nowMS+100+int64(attempt))
if err != nil {
t.Fatalf("pick active dice robot failed: %v", err)
}
if robot.UserID != wantUserID {
t.Fatalf("rotation pick %d mismatch: got %+v want user_id=%d", attempt, robot, wantUserID)
}
if robot.LastUsedAtMS != nowMS+100+int64(attempt) || robot.UsedCount != 1 {
t.Fatalf("picked robot should return updated cooldown fields, got %+v", robot)
}
}
reused, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_rotate_reuse", nowMS+200)
if err != nil {
t.Fatalf("pick reusable dice robot failed: %v", err)
}
if reused.UserID != userIDs[0] || reused.UsedCount != 2 {
t.Fatalf("after every robot has cooled once, rotation should reuse the oldest robot first, got %+v", reused)
}
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO game_dice_participants (
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, joined_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 2, ?, 100, ?, ?)`,
"lalu", "match_random_robot", int64(101), dicedomain.ParticipantTypeRobot, dicedomain.ParticipantStatusJoined, nowMS, nowMS,
); err != nil {
t.Fatalf("insert existing robot participant failed: %v", err)
}
excluded, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_random_robot", nowMS+300)
if err != nil {
t.Fatalf("pick active dice robot with existing participant failed: %v", err)
}
if excluded.UserID == 101 {
t.Fatalf("picked robot already in this match: %+v", excluded)
}
rockRobot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.SelfGameIDRock, "match_random_robot_rock", nowMS+400)
if err != nil {
t.Fatalf("rock should reuse the app-wide robot pool: %v", err)
}
if rockRobot.GameID != dicedomain.SelfGameIDRock || !containsInt64(userIDs, rockRobot.UserID) {
t.Fatalf("rock robot should come from the shared app robot pool, got %+v", rockRobot)
}
}
func TestUniversalDiceRobotManagementIgnoresGameID(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
nowMS := int64(1780542672000)
rows := []struct {
gameID string
userID int64
status string
}{
{gameID: dicedomain.DefaultGameID, userID: 201, status: dicedomain.RobotStatusActive},
{gameID: dicedomain.SelfGameIDRock, userID: 201, status: dicedomain.RobotStatusDisabled},
{gameID: dicedomain.SelfGameIDRock, userID: 202, status: dicedomain.RobotStatusDisabled},
}
for index, row := range rows {
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO game_self_game_robots (
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 7, ?, ?)`,
"lalu", row.gameID, row.userID, row.status, nowMS+int64(index), nowMS+int64(index),
); err != nil {
t.Fatalf("insert robot row %+v failed: %v", row, err)
}
}
active, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusActive, 20, "")
if err != nil {
t.Fatalf("list active robots failed: %v", err)
}
if len(active) != 1 || active[0].UserID != 201 || active[0].Status != dicedomain.RobotStatusActive {
t.Fatalf("active list should aggregate user 201 once, got %+v", active)
}
disabled, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusDisabled, 20, "")
if err != nil {
t.Fatalf("list disabled robots failed: %v", err)
}
if len(disabled) != 1 || disabled[0].UserID != 202 || disabled[0].Status != dicedomain.RobotStatusDisabled {
t.Fatalf("disabled list should only include fully disabled users, got %+v", disabled)
}
updated, err := repo.SetDiceRobotStatus(ctx, "lalu", dicedomain.SelfGameIDRock, 201, dicedomain.RobotStatusDisabled, nowMS+100)
if err != nil {
t.Fatalf("set universal robot status failed: %v", err)
}
if updated.GameID != dicedomain.SelfGameIDRock || updated.Status != dicedomain.RobotStatusDisabled {
t.Fatalf("status response should keep requested game context while updating shared rows, got %+v", updated)
}
var activeRows int
if err := repo.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ? AND status = ?`,
"lalu", int64(201), dicedomain.RobotStatusActive,
).Scan(&activeRows); err != nil {
t.Fatalf("query active rows failed: %v", err)
}
if activeRows != 0 {
t.Fatalf("status update should affect all game_id rows for user 201, active rows=%d", activeRows)
}
if err := repo.DeleteDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, 201); err != nil {
t.Fatalf("delete universal robot failed: %v", err)
}
var remainingRows int
if err := repo.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`,
"lalu", int64(201),
).Scan(&remainingRows); err != nil {
t.Fatalf("query remaining rows failed: %v", err)
}
if remainingRows != 0 {
t.Fatalf("delete should remove every game_id row for user 201, rows=%d", remainingRows)
}
registered, err := repo.RegisterDiceRobots(ctx, "lalu", dicedomain.SelfGameIDRock, []int64{203}, 7, nowMS+200)
if err != nil {
t.Fatalf("register universal robot failed: %v", err)
}
if !robotListContains(registered, 203, dicedomain.RobotStatusActive) {
t.Fatalf("registered robot should be returned from shared list, got %+v", registered)
}
var registeredGameID string
if err := repo.db.QueryRowContext(ctx,
`SELECT game_id FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`,
"lalu", int64(203),
).Scan(&registeredGameID); err != nil {
t.Fatalf("query registered robot failed: %v", err)
}
if registeredGameID != dicedomain.DefaultGameID {
t.Fatalf("new universal robots should use default compatibility game_id, got %s", registeredGameID)
}
}
func TestHasSelfGameUserWinOnlyCountsSettledHumanWinsInSameGame(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
nowMS := int64(1780542672000)
rows := []struct {
matchID string
gameID string
status string
userID int64
participantType string
result string
}{
{matchID: "settled_human_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusSettled, userID: 301, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin},
{matchID: "rock_human_win", gameID: dicedomain.SelfGameIDRock, status: dicedomain.MatchStatusSettled, userID: 302, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin},
{matchID: "robot_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusSettled, userID: 303, participantType: dicedomain.ParticipantTypeRobot, result: dicedomain.ParticipantResultWin},
{matchID: "pending_human_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusPayoutApplying, userID: 304, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin},
}
for index, row := range rows {
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO game_dice_matches (
app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
min_players, max_players, current_players, stake_coin, round_no, status, result,
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
) VALUES (?, ?, ?, 'dice', ?, '', 0, 2, 2, 2, 100, 1, ?, ?, 0, ?, ?, ?, ?, 0, 500, 100, 'robot', '', 0)`,
"lalu", row.matchID, row.gameID, row.gameID, row.status, row.result,
nowMS+int64(index), nowMS+int64(index), nowMS+int64(index), nowMS+int64(index),
); err != nil {
t.Fatalf("insert match %+v failed: %v", row, err)
}
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO game_dice_participants (
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json,
rps_gesture, result, payout_coin, joined_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 1, ?, 100, CAST('[6]' AS JSON), '', ?, 188, ?, ?)`,
"lalu", row.matchID, row.userID, row.participantType, dicedomain.ParticipantStatusSettled, row.result, nowMS+int64(index), nowMS+int64(index),
); err != nil {
t.Fatalf("insert participant %+v failed: %v", row, err)
}
}
hasWin, err := repo.HasSelfGameUserWin(ctx, "lalu", dicedomain.DefaultGameID, 301)
if err != nil {
t.Fatalf("query settled human win failed: %v", err)
}
if !hasWin {
t.Fatalf("settled human win in same game should count")
}
for _, tt := range []struct {
name string
gameID string
userID int64
}{
{name: "other game", gameID: dicedomain.DefaultGameID, userID: 302},
{name: "robot win", gameID: dicedomain.DefaultGameID, userID: 303},
{name: "unfinished win", gameID: dicedomain.DefaultGameID, userID: 304},
{name: "missing user", gameID: dicedomain.DefaultGameID, userID: 305},
} {
t.Run(tt.name, func(t *testing.T) {
hasWin, err := repo.HasSelfGameUserWin(ctx, "lalu", tt.gameID, tt.userID)
if err != nil {
t.Fatalf("query win failed: %v", err)
}
if hasWin {
t.Fatalf("%s should not count as existing win", tt.name)
}
})
}
}
func containsInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func robotListContains(robots []dicedomain.Robot, userID int64, status string) bool {
for _, robot := range robots {
if robot.UserID == userID && robot.Status == status {
return true
}
}
return false
}
func TestUpsertCatalogCreatesDefaultVoiceRoomDisplayRule(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
item := gamedomain.CatalogItem{
AppCode: "lalu",
GameID: "sync_game_001",
PlatformCode: "demo",
ProviderGameID: "provider_sync_001",
GameName: "Sync Game",
Category: "casino",
LaunchMode: gamedomain.LaunchModeH5Popup,
Orientation: "portrait",
Status: gamedomain.StatusActive,
SortOrder: 70,
Tags: []string{"sync"},
}
if _, err := repo.UpsertCatalog(ctx, item); err != nil {
t.Fatalf("upsert catalog failed: %v", err)
}
var scene string
var visible, enabled bool
var sortOrder int32
if err := repo.db.QueryRowContext(ctx,
`SELECT scene, visible, enabled, sort_order
FROM game_display_rules WHERE app_code = ? AND rule_id = ? AND game_id = ?`,
"lalu", defaultVoiceRoomRuleID(item.GameID), item.GameID,
).Scan(&scene, &visible, &enabled, &sortOrder); err != nil {
t.Fatalf("query display rule failed: %v", err)
}
if scene != gamedomain.SceneVoiceRoom || !visible || !enabled || sortOrder != item.SortOrder {
t.Fatalf("display rule mismatch: scene=%s visible=%v enabled=%v sort=%d", scene, visible, enabled, sortOrder)
}
}
func TestDeleteCatalogRemovesGameAndDisplayRule(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
DatabasePrefix: "hy_game_test",
})
ctx := appcode.WithContext(context.Background(), "lalu")
repo, err := Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
item := gamedomain.CatalogItem{
AppCode: "lalu",
GameID: "delete_game_001",
PlatformCode: "demo",
ProviderGameID: "provider_delete_001",
GameName: "Delete Game",
Category: "casino",
LaunchMode: gamedomain.LaunchModeH5Popup,
Orientation: "portrait",
Status: gamedomain.StatusActive,
SortOrder: 80,
}
if _, err := repo.UpsertCatalog(ctx, item); err != nil {
t.Fatalf("upsert catalog failed: %v", err)
}
if err := repo.DeleteCatalog(ctx, "lalu", item.GameID); err != nil {
t.Fatalf("delete catalog failed: %v", err)
}
var gameCount int
if err := repo.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM game_catalog WHERE app_code = ? AND game_id = ?`,
"lalu", item.GameID,
).Scan(&gameCount); err != nil {
t.Fatalf("query catalog count failed: %v", err)
}
if gameCount != 0 {
t.Fatalf("game_catalog count = %d, want 0", gameCount)
}
var ruleCount int
if err := repo.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM game_display_rules WHERE app_code = ? AND game_id = ?`,
"lalu", item.GameID,
).Scan(&ruleCount); err != nil {
t.Fatalf("query display rule count failed: %v", err)
}
if ruleCount != 0 {
t.Fatalf("game_display_rules count = %d, want 0", ruleCount)
}
}