2026-07-15 09:43:33 +08:00

452 lines
21 KiB
Go

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