2026-07-13 21:28:41 +08:00

175 lines
7.7 KiB
Go

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,
}
}