package activitytemplate import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/statisticsclient" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/middleware" activityv1 "hyapp.local/api/proto/activity/v1" "github.com/gin-gonic/gin" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) type revisionConflictClient struct { activityclient.Client } type activityTemplateDataOwnerClient struct { activityclient.Client leaderboard *activityv1.AdminListActivityTemplateLeaderboardResponse rewards *activityv1.ListActivityTemplateRewardJobsResponse retryReward *activityv1.RetryActivityTemplateRewardJobResponse claims *activityv1.ListActivityTemplateTaskClaimsResponse retryClaim *activityv1.RetryActivityTemplateTaskClaimResponse lastClaims *activityv1.ListActivityTemplateTaskClaimsRequest lastRetry *activityv1.RetryActivityTemplateTaskClaimRequest lastRewards *activityv1.ListActivityTemplateRewardJobsRequest lastRewardRetry *activityv1.RetryActivityTemplateRewardJobRequest } type activityTemplateVersionsOwnerClient struct { activityclient.Client response *activityv1.ListActivityTemplateVersionsResponse } func (f activityTemplateVersionsOwnerClient) ListActivityTemplateVersions(context.Context, *activityv1.ListActivityTemplateVersionsRequest) (*activityv1.ListActivityTemplateVersionsResponse, error) { return f.response, nil } func (f activityTemplateDataOwnerClient) ListActivityTemplateLeaderboard(context.Context, *activityv1.AdminListActivityTemplateLeaderboardRequest) (*activityv1.AdminListActivityTemplateLeaderboardResponse, error) { return f.leaderboard, nil } func (f *activityTemplateDataOwnerClient) ListActivityTemplateRewardJobs(_ context.Context, req *activityv1.ListActivityTemplateRewardJobsRequest) (*activityv1.ListActivityTemplateRewardJobsResponse, error) { f.lastRewards = req return f.rewards, nil } func (f *activityTemplateDataOwnerClient) RetryActivityTemplateRewardJob(_ context.Context, req *activityv1.RetryActivityTemplateRewardJobRequest) (*activityv1.RetryActivityTemplateRewardJobResponse, error) { f.lastRewardRetry = req return f.retryReward, nil } func (f *activityTemplateDataOwnerClient) ListActivityTemplateTaskClaims(_ context.Context, req *activityv1.ListActivityTemplateTaskClaimsRequest) (*activityv1.ListActivityTemplateTaskClaimsResponse, error) { f.lastClaims = req return f.claims, nil } func (f *activityTemplateDataOwnerClient) RetryActivityTemplateTaskClaim(_ context.Context, req *activityv1.RetryActivityTemplateTaskClaimRequest) (*activityv1.RetryActivityTemplateTaskClaimResponse, error) { f.lastRetry = req return f.retryClaim, nil } type captureActivityTemplateUsers struct { calls int userIDs []int64 users map[int64]*userclient.User err error } func (f *captureActivityTemplateUsers) BatchGetUsers(_ context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) { f.calls++ f.userIDs = append([]int64(nil), req.UserIDs...) return f.users, f.err } type captureActivityTemplateDataClient struct { query statisticsclient.ActivityTemplateDataQuery } func (c *captureActivityTemplateDataClient) ActivityTemplateData(_ context.Context, query statisticsclient.ActivityTemplateDataQuery) (statisticsclient.ActivityTemplateData, error) { c.query = query return statisticsclient.ActivityTemplateData{ Overview: statisticsclient.ActivityTemplateOverview{TemplateID: query.TemplateID, PublishedVersion: query.PublishedVersion, VisitCount: 13}, Trend: []statisticsclient.ActivityTemplateTrendPoint{{StatDay: "2026-07-14", VisitCount: 7}}, Gifts: []statisticsclient.ActivityTemplateGiftStat{}, Tasks: []statisticsclient.ActivityTemplateTaskStat{}, Granularity: "day", Timezone: "UTC", StartMS: query.StartMS, EndMS: query.EndMS, RegionID: query.RegionID, }, nil } func (revisionConflictClient) UpdateActivityTemplate(context.Context, *activityv1.UpdateActivityTemplateRequest) (*activityv1.UpdateActivityTemplateResponse, error) { return nil, status.Error(codes.FailedPrecondition, "activity template revision changed") } func TestUpdateMapsRevisionConflictToHTTP409(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/activities/templates/acttpl_1", strings.NewReader(`{ "template_code":"gift_challenge_2026", "name":"Gift Challenge", "activity_type":"gift_challenge", "expected_revision":7 }`)) ctx.Request.Header.Set("Content-Type", "application/json") ctx.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} New(revisionConflictClient{}, nil).Update(ctx) if recorder.Code != http.StatusConflict { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusConflict, recorder.Body.String()) } var body struct { Code int `json:"code"` } if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body.Code != 40900 { t.Fatalf("response code = %d, want 40900", body.Code) } } func TestListRejectsMalformedTimeFilterBeforeCallingOwnerService(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates?start_ms=not-a-time", nil) New(nil, nil).List(ctx) if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusBadRequest, recorder.Body.String()) } } func TestVersionsExposeActualRuntimeWindow(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/versions?page=1&page_size=20", nil) ctx.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} client := activityTemplateVersionsOwnerClient{response: &activityv1.ListActivityTemplateVersionsResponse{ Total: 1, Versions: []*activityv1.ActivityTemplateVersion{{ TemplateId: "acttpl_1", VersionNo: 1, Snapshot: &activityv1.ActivityTemplate{TemplateId: "acttpl_1", LifecycleStatus: "disabled"}, PublishedByAdminId: 90001, PublishedAtMs: 100, RuntimeFromMs: 100, RuntimeToMs: 500, }}, }} New(client, nil).Versions(ctx) if recorder.Code != http.StatusOK { t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String()) } var body struct { Data struct { Items []struct { RuntimeFromMS int64 `json:"runtime_from_ms"` RuntimeToMS int64 `json:"runtime_to_ms"` Snapshot struct { LifecycleStatus string `json:"lifecycle_status"` } `json:"snapshot"` } `json:"items"` } `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if len(body.Data.Items) != 1 || body.Data.Items[0].RuntimeFromMS != 100 || body.Data.Items[0].RuntimeToMS != 500 || body.Data.Items[0].Snapshot.LifecycleStatus != "disabled" { t.Fatalf("version runtime response = %+v", body.Data.Items) } } func TestDataNormalizesRequestedWindowToUTCWholeDays(t *testing.T) { gin.SetMode(gin.TestMode) stats := &captureActivityTemplateDataClient{} recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) start := time.Date(2026, 7, 14, 12, 30, 0, 0, time.UTC).UnixMilli() end := time.Date(2026, 7, 15, 8, 45, 0, 0, time.UTC).UnixMilli() ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/data?version_no=3®ion_id=7&start_ms="+strconv.FormatInt(start, 10)+"&end_ms="+strconv.FormatInt(end, 10), nil) ctx.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} New(nil, nil, stats).Data(ctx) if recorder.Code != http.StatusOK { t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String()) } wantStart := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC).UnixMilli() wantEnd := time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC).UnixMilli() if stats.query.TemplateID != "acttpl_1" || stats.query.PublishedVersion != 3 || stats.query.RegionID != 7 || stats.query.StartMS != wantStart || stats.query.EndMS != wantEnd { t.Fatalf("normalized statistics query = %+v", stats.query) } var body struct { Data statisticsclient.ActivityTemplateData `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if body.Data.StartMS != wantStart || body.Data.EndMS != wantEnd || body.Data.Granularity != "day" || body.Data.Timezone != "UTC" || body.Data.Overview.VisitCount != 13 || len(body.Data.Trend) != 1 || body.Data.Trend[0].VisitCount != 7 { t.Fatalf("effective range not echoed: %+v", body.Data) } } func TestLeaderboardAndRewardsBatchDecorateUsersWithoutOwningProfileFacts(t *testing.T) { gin.SetMode(gin.TestMode) owner := activityTemplateDataOwnerClient{ leaderboard: &activityv1.AdminListActivityTemplateLeaderboardResponse{ Period: &activityv1.ActivityTemplateRankPeriod{TemplateId: "acttpl_1", BoardType: "daily", PeriodKey: "2026-07-14"}, Entries: []*activityv1.ActivityTemplateLeaderboardEntry{ {RankNo: 1, UserId: 501, Score: 900}, {RankNo: 2, UserId: 501, Score: 800}, {RankNo: 3, UserId: 502, Score: 700}, }, Total: 3, }, rewards: &activityv1.ListActivityTemplateRewardJobsResponse{ Jobs: []*activityv1.ActivityTemplateRewardJob{{RewardJobId: "job-1", UserId: 502, Status: "granted"}}, Total: 1, }, } users := &captureActivityTemplateUsers{users: map[int64]*userclient.User{ 501: {UserID: 501, DisplayUserID: "L501", Username: "first", Avatar: "first.png"}, 502: {UserID: 502, DisplayUserID: "L502", Username: "second", Avatar: "second.png"}, }} handler := New(&owner, nil).BindUserBatchGetter(users) leaderboardRecorder := httptest.NewRecorder() leaderboardContext, _ := gin.CreateTestContext(leaderboardRecorder) leaderboardContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/leaderboard?version_no=1&board_type=daily&period_key=2026-07-14", nil) leaderboardContext.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} handler.Leaderboard(leaderboardContext) if leaderboardRecorder.Code != http.StatusOK || users.calls != 1 || len(users.userIDs) != 2 || users.userIDs[0] != 501 || users.userIDs[1] != 502 { t.Fatalf("leaderboard batch enrichment: status=%d calls=%d ids=%v body=%s", leaderboardRecorder.Code, users.calls, users.userIDs, leaderboardRecorder.Body.String()) } var leaderboardBody struct { Data struct { Items []struct { User *activityTemplateUserDTO `json:"user"` } `json:"items"` } `json:"data"` } if err := json.Unmarshal(leaderboardRecorder.Body.Bytes(), &leaderboardBody); err != nil || len(leaderboardBody.Data.Items) != 3 || leaderboardBody.Data.Items[0].User == nil || leaderboardBody.Data.Items[0].User.DisplayUserID != "L501" { t.Fatalf("leaderboard user decoration mismatch: err=%v body=%s", err, leaderboardRecorder.Body.String()) } users.calls, users.userIDs = 0, nil rewardsRecorder := httptest.NewRecorder() rewardsContext, _ := gin.CreateTestContext(rewardsRecorder) rewardsContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/rewards?version_no=1", nil) rewardsContext.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} handler.RewardJobs(rewardsContext) if rewardsRecorder.Code != http.StatusOK || users.calls != 1 || len(users.userIDs) != 1 || users.userIDs[0] != 502 || !strings.Contains(rewardsRecorder.Body.String(), `"display_user_id":"L502"`) { t.Fatalf("reward user decoration: status=%d calls=%d ids=%v body=%s", rewardsRecorder.Code, users.calls, users.userIDs, rewardsRecorder.Body.String()) } } func TestRewardJobsRequiresPublishedVersion(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/rewards", nil) ctx.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} New(&activityTemplateDataOwnerClient{}, nil).RewardJobs(ctx) if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusBadRequest, recorder.Body.String()) } } func TestRewardJobsExposeDeadBackoffAndAllowExplicitRevival(t *testing.T) { gin.SetMode(gin.TestMode) dead := &activityv1.ActivityTemplateRewardJob{ RewardJobId: "job-dead", TemplateId: "acttpl_1", VersionNo: 2, UserId: 502, Status: "dead", FailureReason: "maximum delivery attempts exhausted", AttemptCount: 8, MaxAttempts: 8, NextRetryAtMs: 0, } running := proto.Clone(dead).(*activityv1.ActivityTemplateRewardJob) running.Status, running.AttemptCount, running.MaxAttempts = "running", 9, 9 owner := &activityTemplateDataOwnerClient{ rewards: &activityv1.ListActivityTemplateRewardJobsResponse{Jobs: []*activityv1.ActivityTemplateRewardJob{dead}, Total: 1}, retryReward: &activityv1.RetryActivityTemplateRewardJobResponse{Job: running}, } handler := New(owner, nil) listRecorder := httptest.NewRecorder() listContext, _ := gin.CreateTestContext(listRecorder) listContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/rewards?version_no=2&status=dead", nil) listContext.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} handler.RewardJobs(listContext) if listRecorder.Code != http.StatusOK || owner.lastRewards == nil || owner.lastRewards.GetStatus() != "dead" || !strings.Contains(listRecorder.Body.String(), `"max_attempts":8`) || !strings.Contains(listRecorder.Body.String(), `"next_retry_at_ms":0`) { t.Fatalf("dead reward list mismatch: status=%d request=%+v body=%s", listRecorder.Code, owner.lastRewards, listRecorder.Body.String()) } retryRecorder := httptest.NewRecorder() retryContext, _ := gin.CreateTestContext(retryRecorder) retryContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/activities/templates/acttpl_1/rewards/job-dead/retry", nil) retryContext.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}, {Key: "reward_job_id", Value: "job-dead"}} retryContext.Set(middleware.ContextUserID, uint(90001)) handler.RetryRewardJob(retryContext) if retryRecorder.Code != http.StatusOK || owner.lastRewardRetry == nil || owner.lastRewardRetry.GetRewardJobId() != "job-dead" || owner.lastRewardRetry.GetOperatorAdminId() != 90001 || !strings.Contains(retryRecorder.Body.String(), `"max_attempts":9`) { t.Fatalf("dead reward revival mismatch: status=%d request=%+v body=%s", retryRecorder.Code, owner.lastRewardRetry, retryRecorder.Body.String()) } } func TestTaskClaimsFilterEnrichAndRetryPreserveImmutableAttribution(t *testing.T) { gin.SetMode(gin.TestMode) rewardItem := &activityv1.ActivityTemplateRewardItem{ItemType: "resource", ResourceId: 91, ResourceType: "avatar_frame", Name: "Frame", Quantity: 1} claim := &activityv1.ActivityTemplateTaskClaim{ ClaimId: "claim-1", CommandId: "device-command", TemplateId: "acttpl_1", TemplateCode: "summer", VersionNo: 3, TaskDay: "2026-07-14", TaskKey: "send_gift", UserId: 503, RewardResourceGroupId: 81, WalletCommandId: "wallet-command", WalletGrantId: "wallet-grant", Status: "failed", FailureReason: "timeout", AttemptCount: 2, RewardSnapshotId: "rgs-task", RewardSnapshotHash: strings.Repeat("a", 64), RewardName: "Task reward", RewardItems: []*activityv1.ActivityTemplateRewardItem{rewardItem}, EarningRegionId: 210, AttributionAtMs: 1784073000000, } owner := &activityTemplateDataOwnerClient{ claims: &activityv1.ListActivityTemplateTaskClaimsResponse{Claims: []*activityv1.ActivityTemplateTaskClaim{claim}, Total: 1}, retryClaim: &activityv1.RetryActivityTemplateTaskClaimResponse{Claim: claim}, } users := &captureActivityTemplateUsers{users: map[int64]*userclient.User{ 503: {UserID: 503, DisplayUserID: "L503", Username: "winner", Avatar: "winner.png"}, }} handler := New(owner, nil).BindUserBatchGetter(users) listRecorder := httptest.NewRecorder() listContext, _ := gin.CreateTestContext(listRecorder) listContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/task-claims?version_no=3&task_day=2026-07-14&task_key=send_gift&user_id=503&status=failed&page=2&page_size=10", nil) listContext.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} handler.TaskClaims(listContext) if listRecorder.Code != http.StatusOK || owner.lastClaims == nil || owner.lastClaims.GetVersionNo() != 3 || owner.lastClaims.GetTaskDay() != "2026-07-14" || owner.lastClaims.GetTaskKey() != "send_gift" || owner.lastClaims.GetUserId() != 503 || owner.lastClaims.GetStatus() != "failed" || owner.lastClaims.GetPage() != 2 || owner.lastClaims.GetPageSize() != 10 { t.Fatalf("task claim filters mismatch: status=%d request=%+v body=%s", listRecorder.Code, owner.lastClaims, listRecorder.Body.String()) } var listBody struct { Data struct { Items []taskClaimDTO `json:"items"` } `json:"data"` } if err := json.Unmarshal(listRecorder.Body.Bytes(), &listBody); err != nil || len(listBody.Data.Items) != 1 { t.Fatalf("decode task claims: err=%v body=%s", err, listRecorder.Body.String()) } item := listBody.Data.Items[0] if item.RewardSnapshotHash != strings.Repeat("a", 64) || item.RewardName != "Task reward" || len(item.RewardItems) != 1 || item.EarningRegionID != 210 || item.AttributionAtMS != 1784073000000 || item.User == nil || item.User.DisplayUserID != "L503" { t.Fatalf("task claim immutable/admin view mismatch: %+v", item) } retryRecorder := httptest.NewRecorder() retryContext, _ := gin.CreateTestContext(retryRecorder) retryContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/activities/templates/acttpl_1/task-claims/claim-1/retry", nil) retryContext.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}, {Key: "claim_id", Value: "claim-1"}} retryContext.Set(middleware.ContextUserID, uint(90001)) handler.RetryTaskClaim(retryContext) if retryRecorder.Code != http.StatusOK || owner.lastRetry == nil || owner.lastRetry.GetTemplateId() != "acttpl_1" || owner.lastRetry.GetClaimId() != "claim-1" || owner.lastRetry.GetOperatorAdminId() != 90001 { t.Fatalf("task claim retry mismatch: status=%d request=%+v body=%s", retryRecorder.Code, owner.lastRetry, retryRecorder.Body.String()) } } func TestAdminRewardDTOsExposePublishedSnapshots(t *testing.T) { item := &activityv1.ActivityTemplateRewardItem{ItemType: "wallet_asset", WalletAssetType: "coin", WalletAssetAmount: 500, Name: "Coins"} template := templateFromProto(&activityv1.ActivityTemplate{ Tasks: []*activityv1.ActivityTemplateTask{{TaskKey: "visit", RewardSnapshotId: "task-snapshot", RewardSnapshotHash: strings.Repeat("b", 64), RewardName: "Visit reward", RewardItems: []*activityv1.ActivityTemplateRewardItem{item}}}, RankRewards: []*activityv1.ActivityTemplateRankReward{{BoardType: "total", RankFrom: 1, RankTo: 1, RewardSnapshotId: "rank-snapshot", RewardSnapshotHash: strings.Repeat("c", 64), RewardName: "Champion", RewardItems: []*activityv1.ActivityTemplateRewardItem{item}}}, }) job := rewardJobFromProto(&activityv1.ActivityTemplateRewardJob{ RewardJobId: "job-1", RewardSnapshotId: "rank-snapshot", RewardSnapshotHash: strings.Repeat("c", 64), RewardName: "Champion", RewardItems: []*activityv1.ActivityTemplateRewardItem{item}, }, nil) if len(template.Tasks) != 1 || template.Tasks[0].RewardSnapshotHash != strings.Repeat("b", 64) || len(template.Tasks[0].RewardItems) != 1 || len(template.RankRewards) != 1 || template.RankRewards[0].RewardSnapshotHash != strings.Repeat("c", 64) || len(template.RankRewards[0].RewardItems) != 1 || job.RewardSnapshotID != "rank-snapshot" || job.RewardSnapshotHash != strings.Repeat("c", 64) || len(job.RewardItems) != 1 { t.Fatalf("published reward snapshots were dropped: template=%+v job=%+v", template, job) } } func TestLeaderboardProfileFailureDoesNotHideRankFacts(t *testing.T) { gin.SetMode(gin.TestMode) owner := activityTemplateDataOwnerClient{leaderboard: &activityv1.AdminListActivityTemplateLeaderboardResponse{ Period: &activityv1.ActivityTemplateRankPeriod{TemplateId: "acttpl_1", BoardType: "total", PeriodKey: "total"}, Entries: []*activityv1.ActivityTemplateLeaderboardEntry{{RankNo: 1, UserId: 501, Score: 900}}, Total: 1, }} users := &captureActivityTemplateUsers{err: errors.New("user service unavailable")} handler := New(&owner, nil).BindUserBatchGetter(users) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/activities/templates/acttpl_1/leaderboard?version_no=1&board_type=total", nil) ctx.Params = gin.Params{{Key: "template_id", Value: "acttpl_1"}} handler.Leaderboard(ctx) if recorder.Code != http.StatusOK || users.calls != 1 || !strings.Contains(recorder.Body.String(), `"score":900`) || strings.Contains(recorder.Body.String(), `"user":`) { t.Fatalf("profile degradation hid or changed rank facts: status=%d body=%s", recorder.Code, recorder.Body.String()) } }