package luckygift import ( "context" "errors" "testing" "time" "google.golang.org/protobuf/proto" luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1" "hyapp/pkg/luckygiftmq" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" ) type captureLuckyGiftEventPublisher struct { envelope *luckygifteventsv1.EventEnvelope err error } func (p *captureLuckyGiftEventPublisher) PublishLuckyGiftEvent(_ context.Context, envelope *luckygifteventsv1.EventEnvelope) error { p.envelope = proto.Clone(envelope).(*luckygifteventsv1.EventEnvelope) return p.err } // settlementEventRepository embeds the full port only to keep this test focused on the methods used by the granted branch. // Any unexpected repository call still panics through the nil embedded interface and exposes a widened code path immediately. type settlementEventRepository struct { Repository rewardState domain.DrawRewardState deliveredOutbox string retryableOutbox string failedOutbox string failedDrawUpdate bool } func (r *settlementEventRepository) GetLuckyGiftDrawRewardState(context.Context, string, []string) (domain.DrawRewardState, error) { return r.rewardState, nil } func (r *settlementEventRepository) MarkLuckyGiftOutboxDelivered(_ context.Context, event domain.DrawOutbox, _ int64) error { r.deliveredOutbox = event.OutboxID return nil } func (r *settlementEventRepository) MarkLuckyGiftOutboxRetryable(_ context.Context, event domain.DrawOutbox, _ int, _ int64, _ string, _ int64) error { r.retryableOutbox = event.OutboxID return nil } func (r *settlementEventRepository) MarkLuckyGiftOutboxFailed(_ context.Context, event domain.DrawOutbox, _ int, _ string, _ int64) error { r.failedOutbox = event.OutboxID return nil } func (r *settlementEventRepository) MarkLuckyGiftDrawsFailed(context.Context, string, []string, string, int64) error { r.failedDrawUpdate = true return nil } func TestPublishLuckyGiftDrawnFactCarriesGrantedSnapshot(t *testing.T) { publisher := &captureLuckyGiftEventPublisher{} service := New(nil, WithEventPublisher(publisher)) service.SetClock(func() time.Time { return time.UnixMilli(1783940398374).UTC() }) payload := testLuckyGiftDrawnPayload() if err := service.publishLuckyGiftDrawnFact(context.Background(), payload, luckyGiftRewardReceipt{WalletTransactionID: "wallet-tx-1", RewardGrantedAtMS: 1783940398320}); err != nil { t.Fatalf("publish lucky gift fact: %v", err) } if publisher.envelope == nil || publisher.envelope.GetEventType() != luckygiftmq.EventTypeLuckyGiftDrawn || publisher.envelope.GetOccurredAtMs() != payload.CreatedAtMS { t.Fatalf("unexpected owner envelope: %+v", publisher.envelope) } var fact luckygifteventsv1.LuckyGiftDrawn if err := proto.Unmarshal(publisher.envelope.GetBody(), &fact); err != nil { t.Fatalf("decode owner fact: %v", err) } if fact.GetRewardStatus() != domain.StatusGranted || fact.GetWalletTransactionId() != "wallet-tx-1" || fact.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || fact.GetSenderName() != "Lucky Sender" || fact.GetRewardGrantedAtMs() != 1783940398320 { t.Fatalf("unexpected owner fact: %+v", &fact) } } func TestPublishLuckyGiftDrawnFactIsStableAcrossBrokerRetries(t *testing.T) { publisher := &captureLuckyGiftEventPublisher{} service := New(nil, WithEventPublisher(publisher)) payload := testLuckyGiftDrawnPayload() receipt := luckyGiftRewardReceipt{WalletTransactionID: "wallet-tx-stable", RewardGrantedAtMS: 1783940398320} service.SetClock(func() time.Time { return time.UnixMilli(1783940400000).UTC() }) if err := service.publishLuckyGiftDrawnFact(context.Background(), payload, receipt); err != nil { t.Fatalf("first publish: %v", err) } first := proto.Clone(publisher.envelope).(*luckygifteventsv1.EventEnvelope) service.SetClock(func() time.Time { return time.UnixMilli(1783940500000).UTC() }) if err := service.publishLuckyGiftDrawnFact(context.Background(), payload, receipt); err != nil { t.Fatalf("retry publish: %v", err) } if !proto.Equal(first, publisher.envelope) { t.Fatalf("same event_id changed across retry:\nfirst=%+v\nretry=%+v", first, publisher.envelope) } } func TestGrantedDrawKeepsSettlementRetryableUntilBrokerAcceptsFact(t *testing.T) { repository := &settlementEventRepository{rewardState: domain.DrawRewardState{AllGranted: true, WalletTransactionID: "wallet-tx-existing", RewardGrantedAtMS: 1783940398320}} publisher := &captureLuckyGiftEventPublisher{err: errors.New("broker unavailable")} service := New(repository, WithEventPublisher(publisher)) service.SetClock(func() time.Time { return time.UnixMilli(1783940398374).UTC() }) event := domain.DrawOutbox{AppCode: "lalu", OutboxID: "lucky_reward_draw-10x", EventType: domain.EventTypeLuckyGiftRewardSettlement} err := service.processRewardSettlementOutbox(context.Background(), event, testLuckyGiftDrawnPayload(), WorkerOptions{MaxRetry: 8}) if err != nil { t.Fatalf("retry transition should be persisted without returning a second error: %v", err) } if repository.retryableOutbox != event.OutboxID || repository.deliveredOutbox != "" || repository.failedOutbox != "" { t.Fatalf("unexpected outbox transition: retryable=%q delivered=%q failed=%q", repository.retryableOutbox, repository.deliveredOutbox, repository.failedOutbox) } if repository.failedDrawUpdate { t.Fatal("broker failure must never overwrite an already granted draw") } if publisher.envelope == nil { t.Fatal("granted retry must still publish the owner fact") } } func TestGrantedDrawBrokerRetryExhaustionNeverMarksCreditedDrawFailed(t *testing.T) { repository := &settlementEventRepository{rewardState: domain.DrawRewardState{AllGranted: true, WalletTransactionID: "wallet-tx-existing", RewardGrantedAtMS: 1783940398320}} publisher := &captureLuckyGiftEventPublisher{err: errors.New("broker outage exceeded realtime retry window")} service := New(repository, WithEventPublisher(publisher)) service.SetClock(func() time.Time { return time.UnixMilli(1783940398374).UTC() }) event := domain.DrawOutbox{ AppCode: "lalu", OutboxID: "lucky_reward_draw-10x", EventType: domain.EventTypeLuckyGiftRewardSettlement, PayloadJSON: `{"draw_id":"draw-10x"}`, RetryCount: 7, } if err := service.processRewardSettlementOutbox(context.Background(), event, testLuckyGiftDrawnPayload(), WorkerOptions{MaxRetry: 8}); err != nil { t.Fatalf("terminal outbox transition: %v", err) } if repository.failedOutbox != event.OutboxID || repository.retryableOutbox != "" || repository.deliveredOutbox != "" { t.Fatalf("unexpected terminal transition: failed=%q retryable=%q delivered=%q", repository.failedOutbox, repository.retryableOutbox, repository.deliveredOutbox) } if repository.failedDrawUpdate { t.Fatal("credited draw must remain granted when realtime fact exhausts retries") } } func testLuckyGiftDrawnPayload() luckyGiftDrawnPayload { return luckyGiftDrawnPayload{ EventType: "lucky_gift_drawn", EventID: "lucky_gift_drawn:draw-10x", AppCode: "lalu", DrawID: "draw-10x", DrawIDs: []string{"draw-10x"}, CommandID: "cmd-10x", PoolID: "super_lucky", RoomID: "room-1", GiftID: "43", GiftCount: 1, UserID: 123456, SenderUserID: 123456, TargetUserID: 654321, SenderName: "Lucky Sender", SenderAvatar: "https://cdn.example/sender.png", SenderDisplayUserID: "123456", VisibleRegionID: 1, CountryID: 86, CoinSpent: 100, MultiplierPPM: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM, BaseRewardCoins: 1000, EffectiveRewardCoins: 1000, CreatedAtMS: 1783940398274, } }