package walletnotice import ( "context" "encoding/json" "errors" "testing" "time" "hyapp/pkg/tencentim" "hyapp/pkg/walletmq" ) func TestProcessWalletBalanceNoticesPublishesPrivateIM(t *testing.T) { repo := &fakeRepository{ events: []WalletBalanceEvent{{ AppCode: "lalu", EventID: "evt-1", EventType: "WalletBalanceChanged", TransactionID: "tx-1", CommandID: "cmd-1", UserID: 123, AssetType: "COIN", AvailableDelta: -10, FrozenDelta: 0, PayloadJSON: `{"available_after":90,"frozen_after":0,"balance_version":7}`, CreatedAtMS: 1710000000000, }}, } publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, repo, publisher) processed, err := service.ProcessWalletBalanceNotices(context.Background(), WalletNoticeWorkerOptions{MaxRetryCount: 3}) if err != nil { t.Fatalf("ProcessWalletBalanceNotices failed: %v", err) } if processed != 1 || len(repo.delivered) != 1 || len(publisher.messages) != 1 { t.Fatalf("unexpected process result: processed=%d delivered=%d messages=%d", processed, len(repo.delivered), len(publisher.messages)) } message := publisher.messages[0] if message.ToAccount != "123" || message.EventID != "evt-1" || message.Ext != "wallet_notice" { t.Fatalf("unexpected c2c message: %+v", message) } var payload map[string]any if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil { t.Fatalf("decode payload: %v", err) } if payload["event_id"] != "evt-1" || payload["asset_type"] != "COIN" || payload["balance_version"].(float64) != 7 { t.Fatalf("unexpected wallet payload: %+v", payload) } } func TestProcessWalletBalanceNoticesPublishesVipActivatedIM(t *testing.T) { repo := &fakeRepository{ events: []WalletBalanceEvent{{ AppCode: "lalu", EventID: "vip-evt-1", EventType: "VipActivated", TransactionID: "tx-vip-1", CommandID: "cmd-vip-1", UserID: 456, AssetType: "VIP", PayloadJSON: `{"level":3,"vip_name":"VIP3","source":"admin_grant","expires_at_ms":1710003600000}`, CreatedAtMS: 1710000000000, }}, } publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, repo, publisher) processed, err := service.ProcessWalletBalanceNotices(context.Background(), WalletNoticeWorkerOptions{MaxRetryCount: 3}) if err != nil { t.Fatalf("ProcessWalletBalanceNotices failed: %v", err) } if processed != 1 || len(repo.delivered) != 1 || len(publisher.messages) != 1 { t.Fatalf("unexpected process result: processed=%d delivered=%d messages=%d", processed, len(repo.delivered), len(publisher.messages)) } message := publisher.messages[0] if message.ToAccount != "456" || message.EventID != "vip-evt-1" || message.Desc != "VipActivated" || message.Ext != "vip_notice" { t.Fatalf("unexpected vip c2c message: %+v", message) } var payload map[string]any if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil { t.Fatalf("decode payload: %v", err) } if payload["event_type"] != "VipActivated" || payload["asset_type"] != "VIP" || payload["level"].(float64) != 3 { t.Fatalf("unexpected vip payload: %+v", payload) } } func TestProcessWalletOutboxMessagePublishesPrivateIM(t *testing.T) { repo := &fakeRepository{} publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, repo, publisher) handled, err := service.ProcessWalletOutboxMessage(context.Background(), walletmq.WalletOutboxMessage{ AppCode: "lalu", EventID: "evt-mq-1", EventType: "WalletBalanceChanged", TransactionID: "tx-mq-1", CommandID: "cmd-mq-1", UserID: 789, AssetType: "COIN", AvailableDelta: 30, PayloadJSON: `{"available_after":130}`, OccurredAtMS: 1710000000000, }, WalletNoticeWorkerOptions{MaxRetryCount: 3}) if err != nil { t.Fatalf("ProcessWalletOutboxMessage failed: %v", err) } if !handled || len(repo.claimedMessages) != 1 || len(repo.delivered) != 1 || len(publisher.messages) != 1 { t.Fatalf("unexpected mq result: handled=%v claimed=%d delivered=%d messages=%d", handled, len(repo.claimedMessages), len(repo.delivered), len(publisher.messages)) } if publisher.messages[0].ToAccount != "789" || publisher.messages[0].EventID != "evt-mq-1" { t.Fatalf("unexpected mq c2c message: %+v", publisher.messages[0]) } } func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) { repo := &fakeRepository{ events: []WalletBalanceEvent{{ AppCode: "lalu", EventID: "evt-2", EventType: "WalletBalanceChanged", UserID: 123, AssetType: "COIN", PayloadJSON: `{"available_after":90,"balance_version":8}`, RetryCount: 0, }}, } service := New(Config{NodeID: "node-a"}, repo, &fakePublisher{err: errors.New("im timeout")}) processed, err := service.ProcessWalletBalanceNotices(context.Background(), WalletNoticeWorkerOptions{ MaxRetryCount: 3, InitialBackoff: time.Second, MaxBackoff: time.Minute, }) if err != nil { t.Fatalf("ProcessWalletBalanceNotices failed: %v", err) } if processed != 1 || len(repo.retryable) != 1 { t.Fatalf("unexpected retry result: processed=%d retryable=%d", processed, len(repo.retryable)) } if repo.retryable[0].retryCount != 1 || repo.retryable[0].nextRetryAtMS <= 0 { t.Fatalf("unexpected retry marker: %+v", repo.retryable[0]) } } type fakeRepository struct { events []WalletBalanceEvent claimedMessages []walletmq.WalletOutboxMessage delivered []WalletBalanceEvent retryable []retryMarker failed []WalletBalanceEvent } type retryMarker struct { event WalletBalanceEvent retryCount int nextRetryAtMS int64 lastErr string } func (r *fakeRepository) ClaimWalletBalanceEvents(context.Context, string, int, time.Duration) ([]WalletBalanceEvent, error) { return r.events, nil } func (r *fakeRepository) ClaimWalletBalanceMessage(_ context.Context, _ string, message walletmq.WalletOutboxMessage, _ time.Duration) (WalletBalanceEvent, bool, error) { r.claimedMessages = append(r.claimedMessages, message) return WalletBalanceEvent{ AppCode: message.AppCode, EventID: message.EventID, EventType: message.EventType, TransactionID: message.TransactionID, CommandID: message.CommandID, UserID: message.UserID, AssetType: message.AssetType, AvailableDelta: message.AvailableDelta, FrozenDelta: message.FrozenDelta, PayloadJSON: message.PayloadJSON, CreatedAtMS: message.OccurredAtMS, }, true, nil } func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ json.RawMessage, _ int64) error { r.delivered = append(r.delivered, event) return nil } func (r *fakeRepository) MarkWalletBalanceRetryable(_ context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, _ int64) error { r.retryable = append(r.retryable, retryMarker{event: event, retryCount: retryCount, nextRetryAtMS: nextRetryAtMS, lastErr: lastErr}) return nil } func (r *fakeRepository) MarkWalletBalanceFailed(_ context.Context, event WalletBalanceEvent, _ int, _ string, _ int64) error { r.failed = append(r.failed, event) return nil } type fakePublisher struct { messages []tencentim.CustomUserMessage err error } func (p *fakePublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error { if p.err != nil { return p.err } p.messages = append(p.messages, message) return nil }