package wallet_test import ( "context" "database/sql" "encoding/json" "fmt" "os" "strings" "testing" "time" _ "github.com/go-sql-driver/mysql" "hyapp/services/wallet-service/internal/domain/ledger" walletservice "hyapp/services/wallet-service/internal/service/wallet" mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" ) func TestRealMySQLGrantVipWritesActivationOutbox(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("WALLET_REAL_MYSQL_DSN")) if dsn == "" { t.Skip("set WALLET_REAL_MYSQL_DSN to run real MySQL VIP grant validation") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() db, err := sql.Open("mysql", dsn) if err != nil { t.Fatalf("open mysql: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := db.PingContext(ctx); err != nil { t.Fatalf("ping mysql: %v", err) } repository, err := mysqlstorage.Open(ctx, dsn) if err != nil { t.Fatalf("open wallet repository: %v", err) } defer repository.Close() level, vipName := realActiveVIPLevel(t, ctx, db) suffix := time.Now().UnixNano() userID := int64(990100000 + suffix%100000) commandID := fmt.Sprintf("real_vip_grant_%d", suffix) operatorUserID := int64(990000001) if !keepWalletRealRows() { t.Cleanup(func() { cleanupRealVIPGrantRows(context.Background(), t, db, userID, commandID) }) } service := walletservice.New(repository) receipt, err := service.GrantVip(ctx, ledger.GrantVipCommand{ CommandID: commandID, AppCode: "lalu", TargetUserID: userID, Level: level, GrantSource: ledger.VipGrantSourceAdmin, OperatorUserID: operatorUserID, Reason: "real mysql vip grant validation", }) if err != nil { t.Fatalf("GrantVip real mysql failed: %v", err) } if receipt.TransactionID == "" || receipt.Vip.UserID != userID || receipt.Vip.Level != level || !receipt.Vip.Active { t.Fatalf("unexpected GrantVip receipt: %+v", receipt) } again, err := service.GrantVip(ctx, ledger.GrantVipCommand{ CommandID: commandID, AppCode: "lalu", TargetUserID: userID, Level: level, GrantSource: ledger.VipGrantSourceAdmin, OperatorUserID: operatorUserID, Reason: "real mysql vip grant validation", }) if err != nil { t.Fatalf("GrantVip real mysql retry failed: %v", err) } if again.TransactionID != receipt.TransactionID || again.Vip.Level != receipt.Vip.Level { t.Fatalf("GrantVip idempotency mismatch: first=%+v again=%+v", receipt, again) } var membershipLevel int32 var membershipName string if err := db.QueryRowContext(ctx, ` SELECT level, name FROM user_vip_memberships WHERE app_code = 'lalu' AND user_id = ? AND status = 'active'`, userID, ).Scan(&membershipLevel, &membershipName); err != nil { t.Fatalf("query real vip membership: %v", err) } if membershipLevel != level || membershipName != vipName { t.Fatalf("unexpected real vip membership: level=%d name=%s", membershipLevel, membershipName) } var outboxStatus string var assetType string var payloadRaw string if err := db.QueryRowContext(ctx, ` SELECT status, asset_type, COALESCE(CAST(payload AS CHAR), '{}') FROM wallet_outbox WHERE app_code = 'lalu' AND transaction_id = ? AND event_type = 'VipActivated'`, receipt.TransactionID, ).Scan(&outboxStatus, &assetType, &payloadRaw); err != nil { t.Fatalf("query real vip outbox: %v", err) } var payload map[string]any if err := json.Unmarshal([]byte(payloadRaw), &payload); err != nil { t.Fatalf("decode real vip outbox payload: %v", err) } if outboxStatus != "pending" || assetType != "VIP" || payload["event_type"] != "VipActivated" || payload["source"] != ledger.VipGrantSourceAdmin || int32(payload["level"].(float64)) != level { t.Fatalf("unexpected real vip outbox: status=%s asset=%s payload=%+v", outboxStatus, assetType, payload) } t.Logf("real wallet VIP grant user_id=%d level=%d transaction_id=%s outbox_status=%s", userID, level, receipt.TransactionID, outboxStatus) } func realActiveVIPLevel(t *testing.T, ctx context.Context, db *sql.DB) (int32, string) { t.Helper() var level int32 var name string err := db.QueryRowContext(ctx, ` SELECT level, name FROM vip_levels WHERE app_code = 'lalu' AND status = 'active' ORDER BY level ASC LIMIT 1`, ).Scan(&level, &name) if err == sql.ErrNoRows { t.Skip("real hyapp_wallet.vip_levels has no active VIP level") } if err != nil { t.Fatalf("query active vip level: %v", err) } return level, name } func cleanupRealVIPGrantRows(ctx context.Context, t testing.TB, db *sql.DB, userID int64, commandID string) { t.Helper() rewardCommandID := "vip_reward:" + commandID _, _ = db.ExecContext(ctx, `DELETE FROM wallet_outbox WHERE app_code = 'lalu' AND (command_id = ? OR command_id = ? OR command_id LIKE ?)`, commandID, rewardCommandID, "resource_group_grant:"+rewardCommandID+"%") _, _ = db.ExecContext(ctx, `DELETE FROM wallet_entries WHERE app_code = 'lalu' AND user_id = ? AND transaction_id IN (SELECT transaction_id FROM wallet_transactions WHERE app_code = 'lalu' AND (command_id = ? OR command_id LIKE ?))`, userID, commandID, "resource_group_grant:"+rewardCommandID+"%") _, _ = db.ExecContext(ctx, `DELETE FROM wallet_transactions WHERE app_code = 'lalu' AND (command_id = ? OR command_id = ? OR command_id LIKE ?)`, commandID, rewardCommandID, "resource_group_grant:"+rewardCommandID+"%") _, _ = db.ExecContext(ctx, `DELETE FROM user_resource_equipment WHERE app_code = 'lalu' AND user_id = ?`, userID) _, _ = db.ExecContext(ctx, `DELETE FROM user_resource_entitlements WHERE app_code = 'lalu' AND user_id = ?`, userID) _, _ = db.ExecContext(ctx, ` DELETE rgi FROM resource_grant_items rgi JOIN resource_grants rg ON rg.app_code = rgi.app_code AND rg.grant_id = rgi.grant_id WHERE rg.app_code = 'lalu' AND rg.target_user_id = ? AND rg.command_id = ?`, userID, rewardCommandID, ) _, _ = db.ExecContext(ctx, `DELETE FROM resource_grants WHERE app_code = 'lalu' AND target_user_id = ? AND command_id = ?`, userID, rewardCommandID) _, _ = db.ExecContext(ctx, `DELETE FROM user_vip_history WHERE app_code = 'lalu' AND user_id = ?`, userID) _, _ = db.ExecContext(ctx, `DELETE FROM user_vip_memberships WHERE app_code = 'lalu' AND user_id = ?`, userID) } func keepWalletRealRows() bool { value := strings.ToLower(strings.TrimSpace(os.Getenv("WALLET_REAL_MYSQL_KEEP_ROWS"))) return value == "1" || value == "true" || value == "yes" }