452 lines
21 KiB
Go
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)
|
|
}
|
|
}
|