2026-06-17 10:41:57 +08:00

323 lines
12 KiB
Go

package roomturnoverreward
import (
"context"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
)
func TestWeekWindowUsesUTCOneMondayBoundary(t *testing.T) {
cases := []struct {
name string
now string
wantStart string
wantEnd string
}{
{
name: "monday zero still belongs to previous week",
now: "2026-06-01T00:00:00Z",
wantStart: "2026-05-25T01:00:00Z",
wantEnd: "2026-06-01T01:00:00Z",
},
{
name: "monday one starts new contribution week",
now: "2026-06-01T01:00:00Z",
wantStart: "2026-06-01T01:00:00Z",
wantEnd: "2026-06-08T01:00:00Z",
},
{
name: "sunday belongs to current week",
now: "2026-06-07T23:59:59Z",
wantStart: "2026-06-01T01:00:00Z",
wantEnd: "2026-06-08T01:00:00Z",
},
{
name: "cross month keeps utc week",
now: "2026-07-01T12:30:00Z",
wantStart: "2026-06-29T01:00:00Z",
wantEnd: "2026-07-06T01:00:00Z",
},
{
name: "cross year keeps utc week",
now: "2027-01-01T08:00:00Z",
wantStart: "2026-12-28T01:00:00Z",
wantEnd: "2027-01-04T01:00:00Z",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
now := mustParseTime(t, tc.now)
start, end := WeekWindow(now)
if got := start.Format(time.RFC3339); got != tc.wantStart {
t.Fatalf("unexpected start: got %s want %s", got, tc.wantStart)
}
if got := end.Format(time.RFC3339); got != tc.wantEnd {
t.Fatalf("unexpected end: got %s want %s", got, tc.wantEnd)
}
})
}
}
func TestPreviousWeekWindowReturnsCompletedUTCWeek(t *testing.T) {
start, end := PreviousWeekWindow(mustParseTime(t, "2026-06-08T01:00:00Z"))
if got := start.Format(time.RFC3339); got != "2026-06-01T01:00:00Z" {
t.Fatalf("unexpected previous start: %s", got)
}
if got := end.Format(time.RFC3339); got != "2026-06-08T01:00:00Z" {
t.Fatalf("unexpected previous end: %s", got)
}
}
func TestMatchHighestTierReturnsHighestThreshold(t *testing.T) {
tiers := []domain.Tier{
{TierID: 1, TierCode: "bronze", ThresholdCoinSpent: 100, RewardCoinAmount: 10, Status: domain.TierStatusActive},
{TierID: 2, TierCode: "silver", ThresholdCoinSpent: 500, RewardCoinAmount: 60, Status: domain.TierStatusActive},
{TierID: 3, TierCode: "gold", ThresholdCoinSpent: 1000, RewardCoinAmount: 160, Status: domain.TierStatusActive},
{TierID: 4, TierCode: "hidden", ThresholdCoinSpent: 700, RewardCoinAmount: 90, Status: domain.TierStatusInactive},
}
tier, ok := MatchHighestTier(tiers, 850)
if !ok {
t.Fatal("expected a matched tier")
}
if tier.TierCode != "silver" {
t.Fatalf("unexpected tier: got %s want silver", tier.TierCode)
}
}
func TestRetrySettlementRejectsTierNotMatchedWithoutGrantSideEffects(t *testing.T) {
repo := &retryBoundaryRepository{
settlement: domain.Settlement{
SettlementID: "settlement-tier-not-matched",
RoomID: "room-1",
Status: domain.SettlementStatusFailed,
FailureReason: "tier_not_matched",
WalletCommandID: "room_turnover_reward:lalu:1:room-1",
RewardCoinAmount: 0,
},
}
room := &retryBoundaryRoom{ownerID: 10001}
wallet := &retryBoundaryWallet{}
svc := New(repo, wallet, room)
got, err := svc.RetrySettlement(context.Background(), repo.settlement.SettlementID)
if err == nil {
t.Fatal("expected retry to reject tier_not_matched settlement")
}
if got.Status != domain.SettlementStatusFailed || got.FailureReason != "tier_not_matched" {
t.Fatalf("retry should return original failed settlement, got %+v", got)
}
if repo.resetCalls != 0 || repo.grantCalls != 0 || repo.failCalls != 0 {
t.Fatalf("tier_not_matched retry must not mutate settlement: reset=%d grant=%d fail=%d", repo.resetCalls, repo.grantCalls, repo.failCalls)
}
if room.calls != 0 {
t.Fatalf("tier_not_matched retry must not fetch room owner, calls=%d", room.calls)
}
if wallet.calls != 0 {
t.Fatalf("tier_not_matched retry must not call wallet, calls=%d", wallet.calls)
}
}
func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) {
repo := &roomGiftEventRepository{}
svc := New(repo, nil, nil)
svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-03T12:00:00Z") })
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
GiftValue: 900,
CoinSpent: 1200,
})
if err != nil {
t.Fatalf("marshal gift: %v", err)
}
result, err := svc.HandleRoomEvent(context.Background(), &roomeventsv1.EventEnvelope{
EventId: "room-gift-heat-value",
EventType: "RoomGiftSent",
RoomId: "room-1",
AppCode: appcode.Default,
OccurredAtMs: mustParseTime(t, "2026-06-03T10:00:00Z").UnixMilli(),
Body: body,
})
if err != nil {
t.Fatalf("HandleRoomEvent returned error: %v", err)
}
if result.Status != domain.EventStatusConsumed {
t.Fatalf("unexpected result: %+v", result)
}
if repo.event.CoinSpent != 900 {
t.Fatalf("room turnover reward must consume gift_value/HeatValue, got %+v", repo.event)
}
if got := time.UnixMilli(repo.periodStartMS).UTC().Format(time.RFC3339); got != "2026-06-01T01:00:00Z" {
t.Fatalf("unexpected period start: %s", got)
}
}
func mustParseTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time %q: %v", value, err)
}
return parsed
}
type retryBoundaryRepository struct {
settlement domain.Settlement
resetCalls int
grantCalls int
failCalls int
}
type roomGiftEventRepository struct {
event domain.RoomGiftEvent
periodStartMS int64
periodEndMS int64
}
func (r *roomGiftEventRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) {
return domain.Config{}, false, nil
}
func (r *roomGiftEventRepository) UpdateRoomTurnoverRewardConfig(context.Context, domain.Config, int64) (domain.Config, error) {
return domain.Config{}, nil
}
func (r *roomGiftEventRepository) ConsumeRoomTurnoverGiftEvent(_ context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, _ int64) (domain.EventResult, error) {
r.event = event
r.periodStartMS = periodStartMS
r.periodEndMS = periodEndMS
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed}, nil
}
func (r *roomGiftEventRepository) GetRoomTurnoverRewardProgress(context.Context, string, int64) (domain.Progress, bool, error) {
return domain.Progress{}, false, nil
}
func (r *roomGiftEventRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) {
return domain.Settlement{}, false, nil
}
func (r *roomGiftEventRepository) ListRoomTurnoverRewardSettlements(context.Context, domain.SettlementQuery) ([]domain.Settlement, int64, error) {
return nil, 0, nil
}
func (r *roomGiftEventRepository) ListUnsettledRoomTurnoverRewardProgress(context.Context, int64, int) ([]domain.Progress, error) {
return nil, nil
}
func (r *roomGiftEventRepository) InsertRoomTurnoverRewardSettlement(context.Context, domain.Settlement, int64) (domain.Settlement, bool, error) {
return domain.Settlement{}, false, nil
}
func (r *roomGiftEventRepository) ListPendingRoomTurnoverRewardSettlements(context.Context, int) ([]domain.Settlement, error) {
return nil, nil
}
func (r *roomGiftEventRepository) GetRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) {
return domain.Settlement{}, false, nil
}
func (r *roomGiftEventRepository) ResetRoomTurnoverRewardSettlementForRetry(context.Context, string, int64, int64) (domain.Settlement, error) {
return domain.Settlement{}, nil
}
func (r *roomGiftEventRepository) MarkRoomTurnoverRewardSettlementGranted(context.Context, string, string, int64) (domain.Settlement, error) {
return domain.Settlement{}, nil
}
func (r *roomGiftEventRepository) MarkRoomTurnoverRewardSettlementFailed(context.Context, string, string, int64) error {
return nil
}
func (r *retryBoundaryRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) {
panic("unexpected GetRoomTurnoverRewardConfig")
}
func (r *retryBoundaryRepository) UpdateRoomTurnoverRewardConfig(context.Context, domain.Config, int64) (domain.Config, error) {
panic("unexpected UpdateRoomTurnoverRewardConfig")
}
func (r *retryBoundaryRepository) ConsumeRoomTurnoverGiftEvent(context.Context, domain.RoomGiftEvent, int64, int64, int64) (domain.EventResult, error) {
panic("unexpected ConsumeRoomTurnoverGiftEvent")
}
func (r *retryBoundaryRepository) GetRoomTurnoverRewardProgress(context.Context, string, int64) (domain.Progress, bool, error) {
panic("unexpected GetRoomTurnoverRewardProgress")
}
func (r *retryBoundaryRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) {
panic("unexpected GetLatestRoomTurnoverRewardSettlement")
}
func (r *retryBoundaryRepository) ListRoomTurnoverRewardSettlements(context.Context, domain.SettlementQuery) ([]domain.Settlement, int64, error) {
panic("unexpected ListRoomTurnoverRewardSettlements")
}
func (r *retryBoundaryRepository) ListUnsettledRoomTurnoverRewardProgress(context.Context, int64, int) ([]domain.Progress, error) {
panic("unexpected ListUnsettledRoomTurnoverRewardProgress")
}
func (r *retryBoundaryRepository) InsertRoomTurnoverRewardSettlement(context.Context, domain.Settlement, int64) (domain.Settlement, bool, error) {
panic("unexpected InsertRoomTurnoverRewardSettlement")
}
func (r *retryBoundaryRepository) ListPendingRoomTurnoverRewardSettlements(context.Context, int) ([]domain.Settlement, error) {
panic("unexpected ListPendingRoomTurnoverRewardSettlements")
}
func (r *retryBoundaryRepository) GetRoomTurnoverRewardSettlement(_ context.Context, settlementID string) (domain.Settlement, bool, error) {
if settlementID != r.settlement.SettlementID {
return domain.Settlement{}, false, nil
}
return r.settlement, true, nil
}
func (r *retryBoundaryRepository) ResetRoomTurnoverRewardSettlementForRetry(_ context.Context, _ string, ownerUserID int64, _ int64) (domain.Settlement, error) {
r.resetCalls++
r.settlement.Status = domain.SettlementStatusPending
r.settlement.OwnerUserID = ownerUserID
r.settlement.FailureReason = ""
return r.settlement, nil
}
func (r *retryBoundaryRepository) MarkRoomTurnoverRewardSettlementGranted(_ context.Context, _ string, walletTransactionID string, _ int64) (domain.Settlement, error) {
r.grantCalls++
r.settlement.Status = domain.SettlementStatusGranted
r.settlement.WalletTransactionID = walletTransactionID
return r.settlement, nil
}
func (r *retryBoundaryRepository) MarkRoomTurnoverRewardSettlementFailed(_ context.Context, _ string, reason string, _ int64) error {
r.failCalls++
r.settlement.Status = domain.SettlementStatusFailed
r.settlement.FailureReason = reason
return nil
}
type retryBoundaryRoom struct {
ownerID int64
calls int
}
func (r *retryBoundaryRoom) AdminGetRoom(context.Context, *roomv1.AdminGetRoomRequest, ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error) {
r.calls++
return &roomv1.AdminGetRoomResponse{Room: &roomv1.AdminRoomListItem{OwnerUserId: r.ownerID}}, nil
}
type retryBoundaryWallet struct {
calls int
}
func (w *retryBoundaryWallet) CreditRoomTurnoverReward(context.Context, *walletv1.CreditRoomTurnoverRewardRequest, ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) {
w.calls++
return &walletv1.CreditRoomTurnoverRewardResponse{TransactionId: "wallet-tx-1", GrantedAtMs: 1234}, nil
}