175 lines
7.7 KiB
Go
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,
|
|
}
|
|
}
|