package roomturnoverreward import ( "context" "errors" "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" messageservice "hyapp/services/activity-service/internal/service/message" ) 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 TestGrantSettlementCreatesSystemNoticeBeforeMarkGranted(t *testing.T) { repo := &retryBoundaryRepository{ settlement: roomTurnoverRewardGrantableSettlement(), } wallet := &retryBoundaryWallet{} notices := &fakeRewardNoticeService{} svc := New(repo, wallet, nil, WithNoticeService(notices)) svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-09T01:00:00Z") }) granted, err := svc.grantSettlement(appcode.WithContext(context.Background(), "lalu"), repo.settlement) if err != nil { t.Fatalf("grantSettlement returned error: %v", err) } if wallet.calls != 1 || repo.grantCalls != 1 || repo.failCalls != 0 { t.Fatalf("grant path mismatch: wallet=%d grant=%d fail=%d", wallet.calls, repo.grantCalls, repo.failCalls) } if notices.calls != 1 { t.Fatalf("expected one system notice, got %d", notices.calls) } if granted.Status != domain.SettlementStatusGranted || granted.WalletTransactionID != "wallet-tx-1" { t.Fatalf("settlement should be granted with wallet transaction, got %+v", granted) } cmd := notices.command if cmd.TargetUserID != repo.settlement.OwnerUserID || cmd.Producer != rewardNoticeProducer || cmd.ProducerEventID != repo.settlement.SettlementID { t.Fatalf("notice idempotency or target mismatch: %+v", cmd) } if cmd.ProducerEventType != rewardNoticeEventType || cmd.AggregateType != rewardNoticeAggregateType || cmd.AggregateID != repo.settlement.SettlementID { t.Fatalf("notice aggregate mismatch: %+v", cmd) } wantSummary := "Congratulations! You earned 123 coins from this week's room turnover reward." if cmd.Title != "Room turnover reward" || cmd.Summary != wantSummary || cmd.Body != wantSummary { t.Fatalf("notice content mismatch: title=%q summary=%q body=%q", cmd.Title, cmd.Summary, cmd.Body) } if cmd.SentAtMS != 1234 { t.Fatalf("notice sent_at_ms should use wallet grant time, got %d", cmd.SentAtMS) } if cmd.Metadata["settlement_id"] != repo.settlement.SettlementID || cmd.Metadata["room_id"] != repo.settlement.RoomID || cmd.Metadata["reward_coin_amount"] != repo.settlement.RewardCoinAmount || cmd.Metadata["wallet_transaction_id"] != "wallet-tx-1" { t.Fatalf("notice metadata mismatch: %+v", cmd.Metadata) } } func TestGrantSettlementFailsWhenSystemNoticeCannotBeCreated(t *testing.T) { repo := &retryBoundaryRepository{ settlement: roomTurnoverRewardGrantableSettlement(), } wallet := &retryBoundaryWallet{} notices := &fakeRewardNoticeService{err: errors.New("message unavailable")} svc := New(repo, wallet, nil, WithNoticeService(notices)) if _, err := svc.grantSettlement(appcode.WithContext(context.Background(), "lalu"), repo.settlement); err == nil { t.Fatal("expected grantSettlement to fail when notice creation fails") } if wallet.calls != 1 || notices.calls != 1 { t.Fatalf("wallet should be credited once and notice attempted once, wallet=%d notices=%d", wallet.calls, notices.calls) } if repo.grantCalls != 0 || repo.failCalls != 1 || repo.settlement.Status != domain.SettlementStatusFailed { t.Fatalf("notice failure must not mark granted: grant=%d fail=%d settlement=%+v", repo.grantCalls, repo.failCalls, repo.settlement) } } func roomTurnoverRewardGrantableSettlement() domain.Settlement { return domain.Settlement{ SettlementID: "settlement-room-turnover-1", AppCode: "lalu", RoomID: "room-1", OwnerUserID: 42, PeriodStartMS: mustUnixMilli("2026-06-01T01:00:00Z"), PeriodEndMS: mustUnixMilli("2026-06-08T01:00:00Z"), CoinSpent: 9000, TierID: 2, TierCode: "silver", ThresholdCoinSpent: 5000, RewardCoinAmount: 123, Status: domain.SettlementStatusPending, WalletCommandID: "room_turnover_reward:lalu:20260601:room-1", } } func mustUnixMilli(value string) int64 { parsed, err := time.Parse(time.RFC3339, value) if err != nil { panic(err) } return parsed.UnixMilli() } 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 req *walletv1.CreditRoomTurnoverRewardRequest } func (w *retryBoundaryWallet) CreditRoomTurnoverReward(_ context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) { w.calls++ w.req = req return &walletv1.CreditRoomTurnoverRewardResponse{TransactionId: "wallet-tx-1", GrantedAtMs: 1234}, nil } type fakeRewardNoticeService struct { calls int command messageservice.NoticeCommand err error } func (s *fakeRewardNoticeService) CreateSystemNotice(_ context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) { s.calls++ s.command = cmd if s.err != nil { return messageservice.NoticeResult{}, s.err } return messageservice.NoticeResult{Created: true}, nil }