package activitytemplate import ( "context" "errors" "strings" "testing" "time" "google.golang.org/grpc" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/activitytemplate" messageservice "hyapp/services/activity-service/internal/service/message" ) type leaderboardRuntimeRepository struct { RuntimeRepository currentRuntime domain.Runtime currentFound bool periodRuntime domain.Runtime periodFound bool currentCalls int periodCalls int periodVersionNo int64 leaderboardCall int giftEvents []domain.GiftEvent } func (f *leaderboardRuntimeRepository) FindCurrentActivityTemplateRuntime(context.Context, string, int64, int64) (domain.Runtime, bool, error) { f.currentCalls++ return f.currentRuntime, f.currentFound, nil } func (f *leaderboardRuntimeRepository) ConsumeActivityTemplateGiftEvent(_ context.Context, event domain.GiftEvent, _ int64) (domain.EventResult, error) { f.giftEvents = append(f.giftEvents, event) return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed}, nil } func (f *leaderboardRuntimeRepository) FindActivityTemplateRuntimeForPeriod(_ context.Context, _ string, versionNo int64, _ int64, _ string, _ string, _ int64) (domain.Runtime, bool, error) { f.periodCalls++ f.periodVersionNo = versionNo return f.periodRuntime, f.periodFound, nil } func (f *leaderboardRuntimeRepository) FindActivityTemplateRuntimeForExactLeaderboard(_ context.Context, _ string, versionNo int64, _ string, _ string, _ int64) (domain.Runtime, bool, error) { f.periodCalls++ f.periodVersionNo = versionNo return f.periodRuntime, f.periodFound, nil } func (f *leaderboardRuntimeRepository) ListActivityTemplateRuntimeLeaderboard(_ context.Context, runtime domain.Runtime, query domain.LeaderboardQuery) (domain.LeaderboardResult, error) { f.leaderboardCall++ return domain.LeaderboardResult{ Runtime: runtime, Period: domain.RankPeriod{ TemplateID: runtime.Template.TemplateID, TemplateCode: runtime.Template.TemplateCode, VersionNo: runtime.VersionNo, BoardType: query.BoardType, PeriodKey: query.PeriodKey, }, Entries: []domain.LeaderboardEntry{}, }, nil } func TestExactLeaderboardAllowsNonParticipantCrossRegionViewerAfterActivityEnded(t *testing.T) { now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC) ended := domain.Runtime{ Template: domain.Template{TemplateID: "acttpl_1", TemplateCode: "summer_gifts", EndMS: now.Add(-time.Hour).UnixMilli()}, VersionNo: 4, RuntimeFromMS: now.Add(-7 * 24 * time.Hour).UnixMilli(), RuntimeToMS: now.Add(-time.Hour).UnixMilli(), } repository := &leaderboardRuntimeRepository{periodRuntime: ended, periodFound: true} svc := New(&fakeRepository{}, nil, nil) svc.BindRuntime(repository, nil) svc.now = func() time.Time { return now } got, err := svc.ListRuntimeLeaderboard(context.Background(), domain.LeaderboardQuery{ // RegionID is deliberately zero: the gateway does not resolve the viewer's mutable current region // for exact historical links, and repository resolution does not require participation. TemplateCode: "summer_gifts", VersionNo: 4, UserID: 70001, RegionID: 0, BoardType: domain.BoardTypeTotal, PeriodKey: "total", Page: 1, PageSize: 20, }) if err != nil { t.Fatalf("ListRuntimeLeaderboard() error = %v", err) } if got.Runtime.VersionNo != 4 || repository.periodVersionNo != 4 || repository.periodCalls != 1 || repository.currentCalls != 0 || repository.leaderboardCall != 1 { t.Fatalf("historical leaderboard resolution = %+v; repository=%+v", got.Runtime, repository) } } func TestListRuntimeLeaderboardWithoutPeriodRemainsCurrentOnly(t *testing.T) { repository := &leaderboardRuntimeRepository{} svc := New(&fakeRepository{}, nil, nil) svc.BindRuntime(repository, nil) _, err := svc.ListRuntimeLeaderboard(context.Background(), domain.LeaderboardQuery{ TemplateCode: "summer_gifts", UserID: 70001, RegionID: 8, BoardType: domain.BoardTypeTotal, }) if !xerr.IsCode(err, xerr.NotFound) { t.Fatalf("ListRuntimeLeaderboard() error = %v, want not found", err) } if repository.currentCalls != 1 || repository.periodCalls != 0 { t.Fatalf("unexpected lookup calls: current=%d period=%d", repository.currentCalls, repository.periodCalls) } } func TestHandleRoomEventExcludesEveryRobotGiftClassAndUsesCoinSpent(t *testing.T) { repository := &leaderboardRuntimeRepository{} svc := New(&fakeRepository{}, nil, nil) svc.BindRuntime(repository, nil) now := time.Date(2026, 7, 14, 9, 0, 0, 0, time.UTC) svc.now = func() time.Time { return now } tests := []struct { name string edit func(*roomeventsv1.RoomGiftSent) want int32 }{ {name: "is robot gift", edit: func(item *roomeventsv1.RoomGiftSent) { item.IsRobotGift = true }}, {name: "synthetic lucky gift", edit: func(item *roomeventsv1.RoomGiftSent) { item.SyntheticLuckyGift = true }}, {name: "real room robot gift", edit: func(item *roomeventsv1.RoomGiftSent) { item.RealRoomRobotGift = true }}, {name: "real user gift", edit: func(*roomeventsv1.RoomGiftSent) {}, want: 1}, } for index, tt := range tests { t.Run(tt.name, func(t *testing.T) { before := len(repository.giftEvents) gift := &roomeventsv1.RoomGiftSent{ SenderUserId: 90001, GiftId: "rose", GiftCount: 3, GiftValue: 999_999, CoinSpent: 750, RegionId: 12, VisibleRegionId: 99, } tt.edit(gift) body, err := proto.Marshal(gift) if err != nil { t.Fatalf("marshal gift: %v", err) } consumed, err := svc.HandleRoomEvent(context.Background(), &roomeventsv1.EventEnvelope{ EventId: "evt-" + tt.name, EventType: "RoomGiftSent", AppCode: "hyapp", OccurredAtMs: now.Add(time.Duration(index) * time.Millisecond).UnixMilli(), Body: body, }) if err != nil || consumed != tt.want { t.Fatalf("HandleRoomEvent() = (%d, %v), want (%d, nil)", consumed, err, tt.want) } if tt.want == 0 && len(repository.giftEvents) != before { t.Fatalf("robot gift reached score repository: %+v", repository.giftEvents[before:]) } }) } if len(repository.giftEvents) != 1 || repository.giftEvents[0].CoinAmount != 750 || repository.giftEvents[0].GiftCount != 3 || repository.giftEvents[0].RegionID != 12 { t.Fatalf("real gift score fact = %+v; coin_spent must be source of truth", repository.giftEvents) } } type retryRewardRepository struct { RuntimeRepository job domain.RewardJob taskRuntime domain.Runtime taskClaim domain.TaskClaim claimCalls int taskClaimCalls int taskVersionNo int64 markedGranted []string markedFailed []string taskGranted []string taskFailed []string walletGrantIDs []string leaseOwners []string } func (f *retryRewardRepository) FindCurrentActivityTemplateRuntime(context.Context, string, int64, int64) (domain.Runtime, bool, error) { return domain.Runtime{}, false, nil } func (f *retryRewardRepository) FindActivityTemplateRuntimeForUserTask(_ context.Context, _ string, versionNo int64, _ int64, _ string, _ string) (domain.Runtime, bool, error) { f.taskVersionNo = versionNo return f.taskRuntime, f.taskRuntime.VersionNo > 0, nil } func (f *retryRewardRepository) PrepareActivityTemplateTaskClaim(context.Context, domain.Runtime, domain.ClaimCommand, int64) (domain.TaskClaim, error) { f.taskClaimCalls++ claim := f.taskClaim claim.Status = domain.ClaimStatusRunning claim.AttemptCount = int32(f.taskClaimCalls) return claim, nil } func (f *retryRewardRepository) MarkActivityTemplateTaskClaimGranted(_ context.Context, claimID, walletGrantID string, nowMS int64) (domain.TaskClaim, error) { f.taskGranted = append(f.taskGranted, claimID) claim := f.taskClaim claim.Status = domain.ClaimStatusGranted claim.WalletGrantID = walletGrantID claim.GrantedAtMS = nowMS return claim, nil } func (f *retryRewardRepository) MarkActivityTemplateTaskClaimFailed(_ context.Context, claimID, _ string, _ int64) error { f.taskFailed = append(f.taskFailed, claimID) return nil } func (f *retryRewardRepository) ClaimActivityTemplateRewardJobForManualRetry(_ context.Context, _, _ string, workerID string, _ int64, _ time.Duration) (domain.RewardJob, error) { f.claimCalls++ f.leaseOwners = append(f.leaseOwners, workerID) job := f.job job.Status = domain.ClaimStatusRunning job.AttemptCount = int32(f.claimCalls) return job, nil } func (f *retryRewardRepository) ExtendActivityTemplateRewardJobLease(_ context.Context, _, workerID string, _ int64, _ time.Duration) error { f.leaseOwners = append(f.leaseOwners, workerID) return nil } func (f *retryRewardRepository) MarkActivityTemplateRewardJobGrantedWithLease(_ context.Context, jobID, workerID, walletGrantID string, _ int64) error { f.markedGranted = append(f.markedGranted, jobID) f.leaseOwners = append(f.leaseOwners, workerID) f.walletGrantIDs = append(f.walletGrantIDs, walletGrantID) return nil } func (f *retryRewardRepository) MarkActivityTemplateRewardJobFailedWithLease(_ context.Context, jobID, workerID, _ string, _ int64) error { f.markedFailed = append(f.markedFailed, jobID) f.leaseOwners = append(f.leaseOwners, workerID) return nil } type sequenceRuntimeWallet struct { requests []*walletv1.GrantPinnedResourceGroupRequest errors []error } func (f *sequenceRuntimeWallet) GrantPinnedResourceGroup(_ context.Context, req *walletv1.GrantPinnedResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { f.requests = append(f.requests, req) index := len(f.requests) - 1 if index < len(f.errors) && f.errors[index] != nil { return nil, f.errors[index] } return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "wallet-grant-1"}}, nil } type captureRuntimeNotice struct { commands []messageservice.NoticeCommand } type settlementLeaseRepository struct { RuntimeRepository periods []domain.RankPeriod finalizable []domain.RankPeriod jobs []domain.RewardJob periodOwner string jobOwner string leaseOwners []string prepared []string finished []string granted []string failed []string } func (f *settlementLeaseRepository) ClaimDueActivityTemplateRankPeriods(_ context.Context, workerID string, _ int64, _ time.Duration, _ int32) ([]domain.RankPeriod, error) { f.periodOwner = workerID return append([]domain.RankPeriod(nil), f.periods...), nil } func (f *settlementLeaseRepository) PrepareActivityTemplateRankSettlement(_ context.Context, period domain.RankPeriod, _ int64) ([]domain.RewardJob, error) { f.prepared = append(f.prepared, period.BoardType+":"+period.PeriodKey) return []domain.RewardJob{}, nil } func (f *settlementLeaseRepository) ClaimFinalizableActivityTemplateRankPeriods(_ context.Context, _ string, _ int64, _ time.Duration, _ int32) ([]domain.RankPeriod, error) { return append([]domain.RankPeriod(nil), f.finalizable...), nil } func (f *settlementLeaseRepository) FinishActivityTemplateRankPeriodIfComplete(_ context.Context, period domain.RankPeriod, _ int64) (bool, error) { f.finished = append(f.finished, period.BoardType+":"+period.PeriodKey) return true, nil } func (f *settlementLeaseRepository) ClaimActivityTemplateRewardJobs(_ context.Context, workerID string, _ int64, _ time.Duration, _ int32) ([]domain.RewardJob, error) { f.jobOwner = workerID return append([]domain.RewardJob(nil), f.jobs...), nil } func (f *settlementLeaseRepository) ExtendActivityTemplateRewardJobLease(_ context.Context, _ string, workerID string, _ int64, _ time.Duration) error { f.leaseOwners = append(f.leaseOwners, workerID) return nil } func (f *settlementLeaseRepository) MarkActivityTemplateRewardJobGrantedWithLease(_ context.Context, jobID, workerID, _ string, _ int64) error { f.leaseOwners = append(f.leaseOwners, workerID) f.granted = append(f.granted, jobID) return nil } func (f *settlementLeaseRepository) MarkActivityTemplateRewardJobFailedWithLease(_ context.Context, jobID, workerID, _ string, _ int64) error { f.leaseOwners = append(f.leaseOwners, workerID) f.failed = append(f.failed, jobID) return nil } func TestSettlementSeparatesPeriodMaterializationFromBoundedLeasedJobDelivery(t *testing.T) { periodDaily := domain.RankPeriod{AppCode: "hyapp", TemplateID: "tpl-1", TemplateCode: "summer", VersionNo: 3, BoardType: domain.BoardTypeDaily, PeriodKey: "2026-07-14"} periodTotal := domain.RankPeriod{AppCode: "hyapp", TemplateID: "tpl-1", TemplateCode: "summer", VersionNo: 3, BoardType: domain.BoardTypeTotal, PeriodKey: "total"} job := domain.RewardJob{ AppCode: "hyapp", RewardJobID: "job-1", TemplateID: "tpl-1", TemplateCode: "summer", VersionNo: 3, BoardType: domain.BoardTypeDaily, PeriodKey: "2026-07-14", UserID: 1001, ResourceGroupID: 81, WalletCommandID: "wallet-stable", RewardSnapshot: domain.RewardSnapshot{SnapshotID: "rgs-1", SnapshotHash: strings.Repeat("d", 64)}, } repository := &settlementLeaseRepository{periods: []domain.RankPeriod{periodDaily, periodTotal}, jobs: []domain.RewardJob{job}} wallet := &sequenceRuntimeWallet{} svc := New(&fakeRepository{}, nil, nil) svc.BindRuntime(repository, wallet) ctx := appcode.WithContext(context.Background(), "hyapp") claimed, processed, success, failure, hasMore, err := svc.ProcessRuntimeSettlementBatch(ctx, "run-77", "node-a", 2, 30*time.Second) if err != nil || claimed != 1 || processed != 1 || success != 1 || failure != 0 || !hasMore { t.Fatalf("settlement counters = (%d,%d,%d,%d,%v,%v)", claimed, processed, success, failure, hasMore, err) } if repository.periodOwner != "node-a:run-77" || repository.jobOwner != repository.periodOwner || len(repository.prepared) != 2 || len(repository.granted) != 1 || len(repository.failed) != 0 || len(wallet.requests) != 1 { t.Fatalf("bounded settlement state mismatch: repository=%+v wallet=%+v", repository, wallet.requests) } for _, owner := range repository.leaseOwners { if owner != repository.jobOwner { t.Fatalf("job completion escaped lease owner: owners=%v job_owner=%q", repository.leaseOwners, repository.jobOwner) } } if len(repository.finished) != 3 { // two post-materialization checks plus one post-job finalization t.Fatalf("period finalization checks = %v", repository.finished) } if activityTemplateRewardLeaseOwner("node-a", "old-run") == activityTemplateRewardLeaseOwner("node-a", "new-run") { t.Fatal("settlement invocations on one node must have distinct fencing owners") } } func TestSettlementHasMoreIgnoresFiftyBlockedPeriodsAndDoesNotStarvePending(t *testing.T) { // Repository discovery represents the production SQL result after fifty older settling periods with // dead/backoff jobs were filtered out: only the later pending period is actionable. pending := domain.RankPeriod{ AppCode: "hyapp", TemplateID: "pending-after-50-blocked", TemplateCode: "summer", VersionNo: 3, BoardType: domain.BoardTypeTotal, PeriodKey: "total", } repository := &settlementLeaseRepository{periods: []domain.RankPeriod{pending}} svc := New(&fakeRepository{}, nil, nil) svc.BindRuntime(repository, &sequenceRuntimeWallet{}) ctx := appcode.WithContext(context.Background(), "hyapp") claimed, processed, success, failure, hasMore, err := svc.ProcessRuntimeSettlementBatch( ctx, "run-no-hot-loop", "node-a", 50, time.Minute, ) if err != nil || claimed != 0 || processed != 0 || success != 0 || failure != 0 { t.Fatalf("settlement counters = (%d,%d,%d,%d,%v): %v", claimed, processed, success, failure, hasMore, err) } if hasMore { t.Fatal("blocked settling periods must not keep has_more true after the sole pending period is processed") } if len(repository.prepared) != 1 || repository.prepared[0] != "total:total" { t.Fatalf("later pending period was starved: prepared=%v", repository.prepared) } } func (f *captureRuntimeNotice) CreateActivityNotice(_ context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) { f.commands = append(f.commands, cmd) return messageservice.NoticeResult{Created: true}, nil } func TestRetryRewardJobReplaysStableWalletCommandAndCreatesIdempotentWinnerNotice(t *testing.T) { job := domain.RewardJob{ RewardJobID: "reward-job-1", TemplateID: "acttpl_1", TemplateCode: "summer", VersionNo: 3, BoardType: domain.BoardTypeTotal, PeriodKey: "total", RankNo: 2, UserID: 88001, ResourceGroupID: 77, WalletCommandID: "activity-template-rank:stable-command", RewardSnapshot: domain.RewardSnapshot{SnapshotID: "rgs_rank", SnapshotHash: strings.Repeat("a", 64), Name: "Champion reward"}, } repository := &retryRewardRepository{job: job} wallet := &sequenceRuntimeWallet{errors: []error{errors.New("wallet timeout"), nil}} notices := &captureRuntimeNotice{} svc := New(&fakeRepository{}, nil, nil) svc.BindRuntime(repository, wallet) svc.BindNotice(notices) ctx := appcode.WithContext(context.Background(), "hyapp") if _, err := svc.RetryRewardJob(ctx, job.TemplateID, job.RewardJobID, 90001); err == nil { t.Fatal("first retry must expose wallet failure") } got, err := svc.RetryRewardJob(ctx, job.TemplateID, job.RewardJobID, 90001) if err != nil { t.Fatalf("second RetryRewardJob() error = %v", err) } if len(wallet.requests) != 2 || wallet.requests[0].GetCommandId() != job.WalletCommandID || wallet.requests[1].GetCommandId() != job.WalletCommandID { t.Fatalf("wallet commands are not stable: %+v", wallet.requests) } if got.Status != domain.ClaimStatusGranted || got.WalletGrantID != "wallet-grant-1" || len(repository.markedFailed) != 1 || len(repository.markedGranted) != 1 { t.Fatalf("retry result=%+v repository failed=%v granted=%v", got, repository.markedFailed, repository.markedGranted) } if len(repository.leaseOwners) != 7 || repository.leaseOwners[0] == "" || repository.leaseOwners[0] != repository.leaseOwners[1] || repository.leaseOwners[1] != repository.leaseOwners[2] || repository.leaseOwners[3] == repository.leaseOwners[0] || repository.leaseOwners[3] != repository.leaseOwners[4] || repository.leaseOwners[4] != repository.leaseOwners[5] || repository.leaseOwners[5] != repository.leaseOwners[6] { t.Fatalf("manual retry lease fencing mismatch: %+v", repository.leaseOwners) } if len(notices.commands) != 1 || notices.commands[0].ProducerEventID != job.RewardJobID+":granted" || notices.commands[0].ActionType != "activity_detail" || notices.commands[0].ActionParam != "board_type=total&period_key=total&template_code=summer&version_no=3" { t.Fatalf("winner notice is not stable/readable: %+v", notices.commands) } notice := notices.commands[0] if notice.Title != "activity_template.rank_reward.title" || notice.Summary != "activity_template.rank_reward.summary" || strings.Contains(notice.Title, "Daily leaderboard") || notice.Metadata["content_key"] != "activity_template.rank_reward_granted" || notice.Metadata["template_code"] != "summer" || notice.Metadata["board_type"] != domain.BoardTypeTotal || notice.Metadata["period_key"] != "total" || notice.Metadata["reward_name"] != "Champion reward" { t.Fatalf("winner notice must use localization keys and immutable metadata: %+v", notice) } } func TestTaskClaimWalletFailureRemainsRetryableAfterActivityEnds(t *testing.T) { taskDay := "2026-07-14" endedAt := time.Date(2026, 7, 14, 23, 59, 0, 0, time.UTC) runtime := domain.Runtime{ Template: domain.Template{TemplateID: "acttpl_1", TemplateCode: "summer", EndMS: endedAt.UnixMilli()}, VersionNo: 3, RuntimeFromMS: endedAt.Add(-24 * time.Hour).UnixMilli(), RuntimeToMS: endedAt.UnixMilli(), } claim := domain.TaskClaim{ ClaimID: "task-claim-1", TemplateID: runtime.Template.TemplateID, TemplateCode: runtime.Template.TemplateCode, VersionNo: runtime.VersionNo, TaskDay: taskDay, TaskKey: "gift-10", UserID: 77001, RewardResourceGroupID: 88, WalletCommandID: "activity-template-task:stable-command", RewardSnapshot: domain.RewardSnapshot{SnapshotID: "rgs_task", SnapshotHash: strings.Repeat("b", 64)}, } repository := &retryRewardRepository{taskRuntime: runtime, taskClaim: claim} wallet := &sequenceRuntimeWallet{errors: []error{errors.New("wallet timeout"), nil}} svc := New(&fakeRepository{}, nil, nil) svc.BindRuntime(repository, wallet) svc.now = func() time.Time { return endedAt.Add(2 * time.Hour) } command := domain.ClaimCommand{ UserID: claim.UserID, RegionID: 6, TemplateCode: claim.TemplateCode, VersionNo: claim.VersionNo, TaskDay: taskDay, TaskKey: claim.TaskKey, CommandID: "client-command-1", } ctx := appcode.WithContext(context.Background(), "hyapp") if _, err := svc.ClaimRuntimeTaskReward(ctx, command); err == nil { t.Fatal("first claim must expose wallet failure") } got, err := svc.ClaimRuntimeTaskReward(ctx, command) if err != nil { t.Fatalf("retry after activity end error = %v", err) } if repository.taskVersionNo != claim.VersionNo || repository.taskClaimCalls != 2 || len(repository.taskFailed) != 1 || len(repository.taskGranted) != 1 || got.Status != domain.ClaimStatusGranted { t.Fatalf("task compensation result=%+v repository=%+v", got, repository) } if len(wallet.requests) != 2 || wallet.requests[0].GetCommandId() != claim.WalletCommandID || wallet.requests[1].GetCommandId() != claim.WalletCommandID { t.Fatalf("task wallet command changed across retries: %+v", wallet.requests) } }