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

413 lines
21 KiB
Go

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&region_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())
}
}