278 lines
12 KiB
Go
278 lines
12 KiB
Go
package wheel
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
"hyapp/pkg/appcode"
|
|
domain "hyapp/services/activity-service/internal/domain/wheel"
|
|
)
|
|
|
|
func TestDrawGrantsResourceReward(t *testing.T) {
|
|
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
|
|
DrawID: "wheel_draw_1",
|
|
DrawIDs: []string{"wheel_draw_1"},
|
|
CommandID: "cmd-1",
|
|
WheelID: "classic",
|
|
SelectedTierID: "classic-1",
|
|
RewardType: domain.RewardTypeGift,
|
|
RewardID: "gift-1",
|
|
RewardCount: 2,
|
|
RewardStatus: domain.StatusPending,
|
|
MetadataJSON: `{"resource_id":326}`,
|
|
}}
|
|
wallet := &fakeWheelWallet{}
|
|
service := New(repository, WithWallet(wallet))
|
|
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
|
|
|
|
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
|
|
CommandID: "cmd-1",
|
|
WheelID: "classic",
|
|
UserID: 1001,
|
|
DeviceID: "device-1",
|
|
CoinSpent: 10000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Draw returned error: %v", err)
|
|
}
|
|
if result.RewardStatus != domain.StatusGranted {
|
|
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
|
|
}
|
|
if len(wallet.resourceGrants) != 1 {
|
|
t.Fatalf("resource grants mismatch: %+v", wallet.resourceGrants)
|
|
}
|
|
req := wallet.resourceGrants[0]
|
|
if req.GetCommandId() != "wheel_resource:wheel_draw_1" || req.GetTargetUserId() != 1001 || req.GetResourceId() != 326 || req.GetQuantity() != 2 {
|
|
t.Fatalf("grant request mismatch: %+v", req)
|
|
}
|
|
if req.GetReason() != "wheel_reward" || req.GetGrantSource() != "wheel_reward" || req.GetOperatorUserId() != 1001 {
|
|
t.Fatalf("grant audit fields mismatch: %+v", req)
|
|
}
|
|
if len(repository.markGrantedDrawIDs) != 1 || repository.markGrantedDrawIDs[0] != "wheel_draw_1" {
|
|
t.Fatalf("marked draw IDs mismatch: %+v", repository.markGrantedDrawIDs)
|
|
}
|
|
}
|
|
|
|
func TestDrawFulfillsBatchCoinAndResourceRewards(t *testing.T) {
|
|
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
|
|
DrawID: "wheel_draw_batch",
|
|
DrawIDs: []string{"wheel_draw_batch"},
|
|
CommandID: "cmd-batch",
|
|
WheelID: "advanced",
|
|
RewardType: "batch",
|
|
RewardStatus: domain.StatusPending,
|
|
Rewards: []domain.DrawReward{
|
|
{SelectedTierID: "advanced-1", RewardType: domain.RewardTypeCoin, RewardCoins: 100},
|
|
{SelectedTierID: "advanced-2", RewardType: domain.RewardTypeProp, RewardID: "901", RewardCount: 1, MetadataJSON: `{"resource_id":901}`},
|
|
},
|
|
}}
|
|
wallet := &fakeWheelWallet{coinBalanceAfter: 9900}
|
|
service := New(repository, WithWallet(wallet))
|
|
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
|
|
|
|
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
|
|
CommandID: "cmd-batch",
|
|
WheelID: "advanced",
|
|
UserID: 2002,
|
|
DeviceID: "device-2",
|
|
CoinSpent: 50000,
|
|
DrawCount: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Draw returned error: %v", err)
|
|
}
|
|
if result.RewardStatus != domain.StatusGranted {
|
|
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
|
|
}
|
|
if len(wallet.coinCredits) != 1 || wallet.coinCredits[0].GetCommandId() != "wheel_reward:wheel_draw_batch:1" {
|
|
t.Fatalf("coin credit mismatch: %+v", wallet.coinCredits)
|
|
}
|
|
if len(wallet.resourceGrants) != 1 || wallet.resourceGrants[0].GetCommandId() != "wheel_resource:wheel_draw_batch:2" {
|
|
t.Fatalf("resource grant mismatch: %+v", wallet.resourceGrants)
|
|
}
|
|
if result.CoinBalanceAfter != 9900 {
|
|
t.Fatalf("coin balance mismatch: %d", result.CoinBalanceAfter)
|
|
}
|
|
if len(result.WalletTransactionID) > 128 {
|
|
t.Fatalf("batch reward receipt reference exceeds wheel_draw_records boundary: %d", len(result.WalletTransactionID))
|
|
}
|
|
}
|
|
|
|
func TestWheelRewardReceiptReferenceCompactsMultiRewardReceipts(t *testing.T) {
|
|
receipts := []string{
|
|
"wtx_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"rgr_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
}
|
|
first := wheelRewardReceiptReference(receipts)
|
|
second := wheelRewardReceiptReference(receipts)
|
|
if first != second || !strings.HasPrefix(first, "batch:2:") || len(first) > 128 {
|
|
t.Fatalf("receipt reference must be stable and bounded: first=%q second=%q", first, second)
|
|
}
|
|
}
|
|
|
|
type fakeWheelRepository struct {
|
|
executeResult domain.DrawResult
|
|
markGrantedDrawIDs []string
|
|
claimRecords []domain.RewardOutboxRecord
|
|
claimApps []string
|
|
deliveredOutboxID string
|
|
deliveredWorkerID string
|
|
deliveredDrawIDs []string
|
|
deliveredTransactionID string
|
|
deliverErr error
|
|
failedOutboxID string
|
|
failedReason string
|
|
}
|
|
|
|
func (r *fakeWheelRepository) PublishWheelRuleConfig(context.Context, domain.RuleConfig, int64) (domain.RuleConfig, error) {
|
|
return domain.RuleConfig{}, nil
|
|
}
|
|
|
|
func (r *fakeWheelRepository) GetWheelRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
|
|
return domain.RuleConfig{}, false, nil
|
|
}
|
|
|
|
func (r *fakeWheelRepository) ExecuteWheelDraw(context.Context, domain.DrawCommand, int64) (domain.DrawResult, error) {
|
|
return r.executeResult, nil
|
|
}
|
|
|
|
func (r *fakeWheelRepository) ListWheelDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (r *fakeWheelRepository) GetWheelDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) {
|
|
return domain.DrawSummary{}, nil
|
|
}
|
|
|
|
func (r *fakeWheelRepository) MarkWheelDrawsGranted(_ context.Context, _ string, drawIDs []string, _ string, _ int64) error {
|
|
r.markGrantedDrawIDs = append([]string(nil), drawIDs...)
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeWheelRepository) ClaimWheelRewardOutbox(ctx context.Context, _ string, _ int64, _ int64, _ int, _ int) ([]domain.RewardOutboxRecord, error) {
|
|
r.claimApps = append(r.claimApps, appcode.FromContext(ctx))
|
|
records := append([]domain.RewardOutboxRecord(nil), r.claimRecords...)
|
|
r.claimRecords = nil
|
|
return records, nil
|
|
}
|
|
|
|
func (r *fakeWheelRepository) MarkWheelRewardOutboxDelivered(_ context.Context, outboxID string, workerID string, drawIDs []string, transactionID string, _ int64) error {
|
|
r.deliveredOutboxID = outboxID
|
|
r.deliveredWorkerID = workerID
|
|
r.deliveredDrawIDs = append([]string(nil), drawIDs...)
|
|
r.deliveredTransactionID = transactionID
|
|
return r.deliverErr
|
|
}
|
|
|
|
func (r *fakeWheelRepository) MarkWheelRewardOutboxFailed(_ context.Context, outboxID string, _ string, _ int64, _ int64, _ int, lastError string) error {
|
|
r.failedOutboxID = outboxID
|
|
r.failedReason = lastError
|
|
return nil
|
|
}
|
|
|
|
type fakeWheelWallet struct {
|
|
coinCredits []*walletv1.CreditWheelRewardRequest
|
|
resourceGrants []*walletv1.GrantResourceRequest
|
|
coinBalanceAfter int64
|
|
}
|
|
|
|
func (w *fakeWheelWallet) CreditWheelReward(_ context.Context, req *walletv1.CreditWheelRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error) {
|
|
w.coinCredits = append(w.coinCredits, req)
|
|
return &walletv1.CreditWheelRewardResponse{
|
|
TransactionId: "tx-" + req.GetCommandId(),
|
|
Balance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: w.coinBalanceAfter},
|
|
}, nil
|
|
}
|
|
|
|
func (w *fakeWheelWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
|
|
w.resourceGrants = append(w.resourceGrants, req)
|
|
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-" + req.GetCommandId()}}, nil
|
|
}
|
|
|
|
func TestProcessPendingRewardsReplaysStableWalletCommandAndMarksDelivered(t *testing.T) {
|
|
repository := &fakeWheelRepository{claimRecords: []domain.RewardOutboxRecord{{
|
|
AppCode: "fami",
|
|
OutboxID: "wheel_reward_wheel_draw_replay",
|
|
// 线上历史 payload 的 rewards 使用 Go 默认字段名;补偿解码必须兼容该不可变快照,不能改读当前奖档。
|
|
Payload: []byte(`{"app_code":"fami","draw_id":"wheel_draw_replay","draw_ids":["wheel_draw_replay"],"command_id":"draw-command","user_id":1001,"wheel_id":"classic","rewards":[{"SelectedTierID":"tier-1","RewardType":"coin","RewardCoins":200}],"visible_region_id":9}`),
|
|
}}}
|
|
wallet := &fakeWheelWallet{coinBalanceAfter: 1200}
|
|
service := New(repository, WithWallet(wallet))
|
|
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
|
|
|
|
result, err := service.ProcessPendingRewards(context.Background(), RewardWorkerOptions{
|
|
WorkerID: "activity-1:wheel-reward",
|
|
AppCodes: []string{"fami"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ProcessPendingRewards returned error: %v", err)
|
|
}
|
|
if result.Claimed != 1 || result.Delivered != 1 || result.Failed != 0 {
|
|
t.Fatalf("unexpected batch result: %+v", result)
|
|
}
|
|
if len(wallet.coinCredits) != 1 {
|
|
t.Fatalf("wallet credit count = %d, want 1", len(wallet.coinCredits))
|
|
}
|
|
request := wallet.coinCredits[0]
|
|
if request.GetCommandId() != "wheel_reward:wheel_draw_replay" || request.GetAppCode() != "fami" || request.GetTargetUserId() != 1001 || request.GetAmount() != 200 {
|
|
t.Fatalf("wallet reconciliation command mismatch: %+v", request)
|
|
}
|
|
if repository.deliveredOutboxID != "wheel_reward_wheel_draw_replay" || repository.deliveredWorkerID != "activity-1:wheel-reward" || repository.deliveredTransactionID != "tx-wheel_reward:wheel_draw_replay" {
|
|
t.Fatalf("delivered marker mismatch: %+v", repository)
|
|
}
|
|
if len(repository.deliveredDrawIDs) != 1 || repository.deliveredDrawIDs[0] != "wheel_draw_replay" {
|
|
t.Fatalf("delivered draw ids mismatch: %+v", repository.deliveredDrawIDs)
|
|
}
|
|
if len(repository.claimApps) != 1 || repository.claimApps[0] != "fami" {
|
|
t.Fatalf("claim must use explicit app partition, got %+v", repository.claimApps)
|
|
}
|
|
}
|
|
|
|
func TestProcessPendingRewardsRejectsCrossAppPayloadBeforeWallet(t *testing.T) {
|
|
repository := &fakeWheelRepository{claimRecords: []domain.RewardOutboxRecord{{
|
|
AppCode: "fami",
|
|
OutboxID: "wheel_reward_cross_app",
|
|
Payload: []byte(`{"app_code":"huwaa","draw_id":"cross_app","command_id":"cmd","user_id":1001,"wheel_id":"classic","reward_type":"coin","reward_coins":200,"selected_tier_id":"tier-1"}`),
|
|
}}}
|
|
wallet := &fakeWheelWallet{}
|
|
service := New(repository, WithWallet(wallet))
|
|
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
|
|
|
|
result, err := service.ProcessPendingRewards(context.Background(), RewardWorkerOptions{WorkerID: "worker", AppCodes: []string{"fami"}})
|
|
if err == nil || result.Claimed != 1 || result.Failed != 1 || result.Delivered != 0 {
|
|
t.Fatalf("cross-app payload must fail closed, result=%+v err=%v", result, err)
|
|
}
|
|
if len(wallet.coinCredits) != 0 || len(wallet.resourceGrants) != 0 {
|
|
t.Fatalf("cross-app payload must not reach wallet: coin=%d resource=%d", len(wallet.coinCredits), len(wallet.resourceGrants))
|
|
}
|
|
if repository.failedOutboxID != "wheel_reward_cross_app" || repository.failedReason == "" {
|
|
t.Fatalf("failed outbox marker missing: %+v", repository)
|
|
}
|
|
}
|
|
|
|
func TestProcessPendingRewardsLeavesCompletedWalletCommandForLeaseRecoveryWhenDeliveryMarkFails(t *testing.T) {
|
|
repository := &fakeWheelRepository{
|
|
deliverErr: errors.New("mysql unavailable"),
|
|
claimRecords: []domain.RewardOutboxRecord{{
|
|
AppCode: "lalu",
|
|
OutboxID: "wheel_reward_mark_failure",
|
|
Payload: []byte(`{"app_code":"lalu","draw_id":"mark_failure","command_id":"cmd","user_id":1001,"wheel_id":"classic","reward_type":"coin","reward_coins":20,"selected_tier_id":"tier-1"}`),
|
|
}},
|
|
}
|
|
wallet := &fakeWheelWallet{}
|
|
service := New(repository, WithWallet(wallet))
|
|
|
|
result, err := service.ProcessPendingRewards(context.Background(), RewardWorkerOptions{WorkerID: "worker", AppCodes: []string{"lalu"}})
|
|
if err == nil || result.Failed != 1 || len(wallet.coinCredits) != 1 {
|
|
t.Fatalf("delivery mark failure mismatch: result=%+v wallet=%d err=%v", result, len(wallet.coinCredits), err)
|
|
}
|
|
if repository.failedOutboxID != "" {
|
|
t.Fatalf("lost lease/delivery failure must stay delivering for safe idempotent reclaim, got failed marker %q", repository.failedOutboxID)
|
|
}
|
|
}
|