525 lines
20 KiB
Go
525 lines
20 KiB
Go
package activitytemplate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"testing"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/protobuf/proto"
|
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/xerr"
|
|
domain "hyapp/services/activity-service/internal/domain/activitytemplate"
|
|
)
|
|
|
|
type fakeRepository struct {
|
|
current domain.Template
|
|
versions map[int64]domain.Version
|
|
listedVersions []domain.Version
|
|
updateCalls int
|
|
statusCalls int
|
|
createCalls int
|
|
}
|
|
|
|
func (f *fakeRepository) ListActivityTemplates(context.Context, domain.ListQuery) ([]domain.Summary, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (f *fakeRepository) GetActivityTemplate(_ context.Context, templateID string) (domain.Template, error) {
|
|
if f.current.TemplateID != templateID {
|
|
return domain.Template{}, xerr.New(xerr.NotFound, "activity template not found")
|
|
}
|
|
return f.current, nil
|
|
}
|
|
|
|
func (f *fakeRepository) CreateActivityTemplate(_ context.Context, item domain.Template, operatorAdminID int64, nowMS int64) (domain.Template, error) {
|
|
f.createCalls++
|
|
item.TemplateID = "acttpl_created"
|
|
item.Status = domain.StatusDraft
|
|
item.Revision = 1
|
|
item.CreatedByAdminID = operatorAdminID
|
|
item.UpdatedByAdminID = operatorAdminID
|
|
item.CreatedAtMS = nowMS
|
|
item.UpdatedAtMS = nowMS
|
|
f.current = item
|
|
return item, nil
|
|
}
|
|
|
|
func (f *fakeRepository) UpdateActivityTemplate(_ context.Context, command domain.UpdateCommand, nowMS int64) (domain.Template, error) {
|
|
f.updateCalls++
|
|
item := command.Template
|
|
item.Revision = command.ExpectedRevision + 1
|
|
item.UpdatedByAdminID = command.OperatorAdminID
|
|
item.UpdatedAtMS = nowMS
|
|
f.current = item
|
|
return item, nil
|
|
}
|
|
|
|
func (f *fakeRepository) SetActivityTemplateStatus(_ context.Context, command domain.StatusCommand, nowMS int64) (domain.Template, error) {
|
|
f.statusCalls++
|
|
item := f.current
|
|
item.Status = command.Status
|
|
item.Revision++
|
|
item.UpdatedByAdminID = command.OperatorAdminID
|
|
item.UpdatedAtMS = nowMS
|
|
if command.Status == domain.StatusPublished {
|
|
item.PublishedVersion++
|
|
item.PublishedByAdminID = command.OperatorAdminID
|
|
item.PublishedAtMS = nowMS
|
|
}
|
|
f.current = item
|
|
return item, nil
|
|
}
|
|
|
|
func (f *fakeRepository) ListActivityTemplateVersions(context.Context, string, int32, int32) ([]domain.Version, int64, error) {
|
|
return append([]domain.Version(nil), f.listedVersions...), int64(len(f.listedVersions)), nil
|
|
}
|
|
|
|
func (f *fakeRepository) GetActivityTemplateVersion(_ context.Context, templateID string, versionNo int64) (domain.Version, error) {
|
|
item, ok := f.versions[versionNo]
|
|
if !ok || item.TemplateID != templateID {
|
|
return domain.Version{}, xerr.New(xerr.NotFound, "activity template version not found")
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
type fakeCatalog struct {
|
|
gift *walletv1.GiftConfig
|
|
groups map[int64]*walletv1.ResourceGroup
|
|
lastGift *walletv1.ListGiftConfigsRequest
|
|
pins []*walletv1.PinResourceGroupSnapshotRequest
|
|
}
|
|
|
|
type fakeRegionSource struct {
|
|
regionIDs []int64
|
|
err error
|
|
}
|
|
|
|
func (f fakeRegionSource) ListActiveRegionIDs(context.Context) ([]int64, error) {
|
|
return append([]int64(nil), f.regionIDs...), f.err
|
|
}
|
|
|
|
func (f *fakeCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest, _ ...grpc.CallOption) (*walletv1.ListGiftConfigsResponse, error) {
|
|
f.lastGift = req
|
|
if f.gift == nil || f.gift.GetGiftId() != req.GetKeyword() {
|
|
return &walletv1.ListGiftConfigsResponse{}, nil
|
|
}
|
|
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{f.gift}, Total: 1}, nil
|
|
}
|
|
|
|
func (f *fakeCatalog) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error) {
|
|
group := f.groups[req.GetGroupId()]
|
|
if group == nil {
|
|
return nil, xerr.New(xerr.NotFound, "resource group not found")
|
|
}
|
|
return &walletv1.GetResourceGroupResponse{Group: group, SourceContentHash: fakeResourceGroupHash(group)}, nil
|
|
}
|
|
|
|
func (f *fakeCatalog) PinResourceGroupSnapshot(_ context.Context, req *walletv1.PinResourceGroupSnapshotRequest, _ ...grpc.CallOption) (*walletv1.PinResourceGroupSnapshotResponse, error) {
|
|
f.pins = append(f.pins, req)
|
|
group := f.groups[req.GetGroupId()]
|
|
if group == nil {
|
|
return nil, xerr.New(xerr.NotFound, "resource group not found")
|
|
}
|
|
hash := fakeResourceGroupHash(group)
|
|
if req.GetExpectedSourceContentHash() != hash || req.GetExpectedGroupUpdatedAtMs() != group.GetUpdatedAtMs() {
|
|
return nil, xerr.New(xerr.Conflict, "resource group content changed")
|
|
}
|
|
return &walletv1.PinResourceGroupSnapshotResponse{Snapshot: &walletv1.ResourceGroupSnapshot{
|
|
AppCode: req.GetAppCode(), SnapshotId: "rgs_" + hash[:24], PinKey: req.GetPinKey(), SourceGroupId: group.GetGroupId(),
|
|
VersionNo: 1, SnapshotHash: hash, Group: group, CreatedByUserId: req.GetOperatorUserId(), CreatedAtMs: 1,
|
|
SourceGroupUpdatedAtMs: group.GetUpdatedAtMs(), SourceContentHash: hash,
|
|
}}, nil
|
|
}
|
|
|
|
func fakeResourceGroupHash(group *walletv1.ResourceGroup) string {
|
|
body, _ := proto.MarshalOptions{Deterministic: true}.Marshal(group)
|
|
sum := sha256.Sum256(body)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func TestLifecycleStatusUsesUTCLeftClosedRightOpenBoundary(t *testing.T) {
|
|
const startMS = int64(10_000)
|
|
const endMS = int64(20_000)
|
|
tests := []struct {
|
|
name string
|
|
now int64
|
|
want string
|
|
}{
|
|
{name: "before start", now: startMS - 1, want: domain.LifecycleScheduled},
|
|
{name: "at start", now: startMS, want: domain.LifecycleOngoing},
|
|
{name: "before end", now: endMS - 1, want: domain.LifecycleOngoing},
|
|
{name: "at end", now: endMS, want: domain.LifecycleEnded},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := lifecycleStatus(domain.StatusPublished, startMS, endMS, tt.now); got != tt.want {
|
|
t.Fatalf("lifecycleStatus() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListVersionsUsesActualRuntimeWindowAfterDisableAndRepublish(t *testing.T) {
|
|
const (
|
|
startMS = int64(100)
|
|
endMS = int64(1_000)
|
|
nowMS = int64(700)
|
|
)
|
|
snapshot := domain.Template{
|
|
TemplateID: "acttpl_republished", TemplateCode: "summer", Status: domain.StatusPublished,
|
|
StartMS: startMS, EndMS: endMS,
|
|
}
|
|
repository := &fakeRepository{listedVersions: []domain.Version{
|
|
{TemplateID: snapshot.TemplateID, VersionNo: 2, Snapshot: snapshot, RuntimeFromMS: 600},
|
|
{TemplateID: snapshot.TemplateID, VersionNo: 1, Snapshot: snapshot, RuntimeFromMS: startMS, RuntimeToMS: 500},
|
|
}}
|
|
svc := New(repository, nil, nil)
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
versions, total, err := svc.ListVersions(context.Background(), snapshot.TemplateID, 1, 20)
|
|
if err != nil {
|
|
t.Fatalf("ListVersions() error = %v", err)
|
|
}
|
|
if total != 2 || len(versions) != 2 {
|
|
t.Fatalf("ListVersions() total/items = %d/%d", total, len(versions))
|
|
}
|
|
if versions[0].VersionNo != 2 || versions[0].Snapshot.LifecycleStatus != domain.LifecycleOngoing || versions[0].RuntimeFromMS != 600 || versions[0].RuntimeToMS != 0 {
|
|
t.Fatalf("republished v2 lifecycle/window = %+v", versions[0])
|
|
}
|
|
if versions[1].VersionNo != 1 || versions[1].Snapshot.LifecycleStatus != domain.LifecycleDisabled || versions[1].RuntimeFromMS != startMS || versions[1].RuntimeToMS != 500 {
|
|
t.Fatalf("disabled v1 lifecycle/window = %+v", versions[1])
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLifecycleStatusUsesActualWindowBoundaries(t *testing.T) {
|
|
item := domain.Template{Status: domain.StatusPublished, StartMS: 100, EndMS: 1_000}
|
|
tests := []struct {
|
|
name string
|
|
runtimeFromMS int64
|
|
runtimeToMS int64
|
|
nowMS int64
|
|
want string
|
|
}{
|
|
{name: "scheduled", runtimeFromMS: 200, nowMS: 199, want: domain.LifecycleScheduled},
|
|
{name: "ongoing at runtime start", runtimeFromMS: 200, nowMS: 200, want: domain.LifecycleOngoing},
|
|
{name: "disabled at exclusive runtime end", runtimeFromMS: 200, runtimeToMS: 500, nowMS: 500, want: domain.LifecycleDisabled},
|
|
{name: "disabled remains historical state", runtimeFromMS: 200, runtimeToMS: 500, nowMS: 1_500, want: domain.LifecycleDisabled},
|
|
{name: "naturally ended", runtimeFromMS: 200, nowMS: 1_000, want: domain.LifecycleEnded},
|
|
{name: "disable after planned end stays ended", runtimeFromMS: 200, runtimeToMS: 1_200, nowMS: 1_200, want: domain.LifecycleEnded},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := RuntimeLifecycleStatus(item, tt.runtimeFromMS, tt.runtimeToMS, tt.nowMS); got != tt.want {
|
|
t.Fatalf("RuntimeLifecycleStatus() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListRejectsNegativeTimeFilter(t *testing.T) {
|
|
svc := New(&fakeRepository{}, nil, nil)
|
|
_, _, err := svc.List(context.Background(), domain.ListQuery{StartMS: -1})
|
|
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
|
t.Fatalf("List() error = %v, want invalid argument", err)
|
|
}
|
|
}
|
|
|
|
func TestValidatePublishRejectsOverlappingRankRangesAndNonObjectDisplayConfig(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
item := publishableTemplate(nowMS)
|
|
item.RankRewards = append(item.RankRewards, domain.RankReward{
|
|
BoardType: domain.BoardTypeDaily, RankFrom: 3, RankTo: 5, ResourceGroupID: 102, SortOrder: 2,
|
|
})
|
|
item.DisplayConfigJSON = "null"
|
|
svc := New(&fakeRepository{}, nil, nil)
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
issues, err := svc.Validate(context.Background(), item, true)
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
if !hasIssue(issues, "rank_rewards", "overlap") {
|
|
t.Fatalf("expected rank overlap issue, got %+v", issues)
|
|
}
|
|
if !hasIssue(issues, "display_config_json", "invalid_json") {
|
|
t.Fatalf("expected JSON object issue, got %+v", issues)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsMultipleDailyVisitTasks(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
item := publishableTemplate(nowMS)
|
|
item.Tasks = append(item.Tasks, domain.Task{
|
|
TaskKey: "daily_visit_bonus", TaskType: domain.TaskTypeDailyVisit,
|
|
TargetValue: 1, RewardResourceGroupID: 101, SortOrder: 2,
|
|
})
|
|
svc := New(&fakeRepository{}, nil, nil)
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
issues, err := svc.Validate(context.Background(), item, false)
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
if !hasIssue(issues, "tasks", "duplicate_daily_visit") {
|
|
t.Fatalf("expected duplicate daily_visit issue, got %+v", issues)
|
|
}
|
|
}
|
|
|
|
func TestSetStatusPublishesOnlyAfterOwnerReferencesValidate(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
item := publishableTemplate(nowMS)
|
|
repository := &fakeRepository{current: item}
|
|
catalog := publishableCatalog(item)
|
|
svc := New(repository, catalog, fakeRegionSource{regionIDs: []int64{1}})
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
ctx := appcode.WithContext(context.Background(), "hyapp")
|
|
|
|
got, err := svc.SetStatus(ctx, domain.StatusCommand{
|
|
TemplateID: item.TemplateID, Status: domain.StatusPublished,
|
|
ExpectedRevision: item.Revision, OperatorAdminID: 90001,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SetStatus() error = %v", err)
|
|
}
|
|
if got.Status != domain.StatusPublished || got.PublishedVersion != 1 || got.Revision != item.Revision+1 {
|
|
t.Fatalf("published template metadata = %+v", got)
|
|
}
|
|
if repository.statusCalls != 1 {
|
|
t.Fatalf("status repository calls = %d, want 1", repository.statusCalls)
|
|
}
|
|
if catalog.lastGift == nil || catalog.lastGift.GetStatus() != "active" || catalog.lastGift.GetActiveOnly() {
|
|
t.Fatalf("gift lookup must load active owner config without current-time-only filtering: %+v", catalog.lastGift)
|
|
}
|
|
if len(catalog.pins) != 3 {
|
|
t.Fatalf("publish pin calls = %d, want 3", len(catalog.pins))
|
|
}
|
|
for _, pin := range catalog.pins {
|
|
if pin.GetRequiredAllRegions() || len(pin.GetRequiredRegionIds()) != 1 || pin.GetRequiredRegionIds()[0] != 1 {
|
|
t.Fatalf("targeted activity pin omitted required region coverage: %+v", pin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetStatusAllRegionsRequiresGlobalRewardGiftCoverageAtWalletPin(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
item := publishableTemplate(nowMS)
|
|
item.AllRegions = true
|
|
item.RegionIDs = nil
|
|
repository := &fakeRepository{current: item}
|
|
catalog := publishableCatalog(item)
|
|
svc := New(repository, catalog, fakeRegionSource{regionIDs: []int64{1, 2}})
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
if _, err := svc.SetStatus(appcode.WithContext(context.Background(), "hyapp"), domain.StatusCommand{
|
|
TemplateID: item.TemplateID, Status: domain.StatusPublished,
|
|
ExpectedRevision: item.Revision, OperatorAdminID: 90001,
|
|
}); err != nil {
|
|
t.Fatalf("publish all-regions template: %v", err)
|
|
}
|
|
if len(catalog.pins) != 3 {
|
|
t.Fatalf("all-regions publish pin calls = %d, want 3", len(catalog.pins))
|
|
}
|
|
for _, pin := range catalog.pins {
|
|
if !pin.GetRequiredAllRegions() || len(pin.GetRequiredRegionIds()) != 0 {
|
|
t.Fatalf("all-regions activity must require global reward gift coverage: %+v", pin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidatePublishRejectsGiftThatExpiresDuringActivity(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
item := publishableTemplate(nowMS)
|
|
catalog := publishableCatalog(item)
|
|
catalog.gift.EffectiveToMs = item.EndMS - 1
|
|
svc := New(&fakeRepository{}, catalog, fakeRegionSource{regionIDs: []int64{1}})
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
issues, err := svc.Validate(context.Background(), item, true)
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
if !hasIssue(issues, "gifts", "gift_period_mismatch") {
|
|
t.Fatalf("expected gift period issue, got %+v", issues)
|
|
}
|
|
}
|
|
|
|
func TestValidatePublishRejectsInactiveTargetRegion(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
item := publishableTemplate(nowMS)
|
|
svc := New(&fakeRepository{}, publishableCatalog(item), fakeRegionSource{regionIDs: []int64{2}})
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
issues, err := svc.Validate(context.Background(), item, true)
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
if !hasIssue(issues, "region_ids", "region_not_active") {
|
|
t.Fatalf("expected inactive region issue, got %+v", issues)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRejectsStaleRevisionBeforeWriting(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
current := publishableTemplate(nowMS)
|
|
current.Status = domain.StatusDisabled
|
|
repository := &fakeRepository{current: current}
|
|
svc := New(repository, nil, nil)
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
_, err := svc.Update(context.Background(), domain.UpdateCommand{
|
|
Template: current, ExpectedRevision: current.Revision - 1, OperatorAdminID: 90001,
|
|
})
|
|
if !xerr.IsCode(err, xerr.Conflict) {
|
|
t.Fatalf("Update() error = %v, want conflict", err)
|
|
}
|
|
if repository.updateCalls != 0 {
|
|
t.Fatalf("stale update wrote repository %d times", repository.updateCalls)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRejectsTemplateCodeChangeAfterCreate(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
current := publishableTemplate(nowMS)
|
|
current.Status = domain.StatusDisabled
|
|
repository := &fakeRepository{current: current}
|
|
svc := New(repository, nil, nil)
|
|
changed := current
|
|
changed.TemplateCode = "replacement_code"
|
|
|
|
_, err := svc.Update(context.Background(), domain.UpdateCommand{
|
|
Template: changed, ExpectedRevision: current.Revision, OperatorAdminID: 90001,
|
|
})
|
|
if !xerr.IsCode(err, xerr.Conflict) || repository.updateCalls != 0 {
|
|
t.Fatalf("code mutation error=%v update_calls=%d", err, repository.updateCalls)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRequiresScheduledOrOngoingPublishedTemplateToBeDisabledFirst(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
current := publishableTemplate(nowMS)
|
|
current.Status = domain.StatusPublished
|
|
repository := &fakeRepository{current: current}
|
|
svc := New(repository, nil, nil)
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
_, _, err := svc.Delete(context.Background(), domain.StatusCommand{
|
|
TemplateID: current.TemplateID, ExpectedRevision: current.Revision, OperatorAdminID: 90001,
|
|
})
|
|
if !xerr.IsCode(err, xerr.Conflict) {
|
|
t.Fatalf("Delete() error = %v, want conflict", err)
|
|
}
|
|
if repository.statusCalls != 0 {
|
|
t.Fatalf("scheduled template was archived %d times", repository.statusCalls)
|
|
}
|
|
}
|
|
|
|
func TestDeleteAllowsEndedPublishedTemplateToArchive(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
current := publishableTemplate(nowMS)
|
|
current.Status = domain.StatusPublished
|
|
current.StartMS = nowMS - 2_000
|
|
current.EndMS = nowMS - 1_000
|
|
repository := &fakeRepository{current: current}
|
|
svc := New(repository, nil, nil)
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
got, archived, err := svc.Delete(context.Background(), domain.StatusCommand{
|
|
TemplateID: current.TemplateID, ExpectedRevision: current.Revision, OperatorAdminID: 90001,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Delete() error = %v", err)
|
|
}
|
|
if !archived || got.Status != domain.StatusArchived || got.LifecycleStatus != domain.LifecycleArchived {
|
|
t.Fatalf("Delete() = archived:%v template:%+v", archived, got)
|
|
}
|
|
}
|
|
|
|
func TestClonePreservesConfigurationButClearsRuntimeIdentityAndSchedule(t *testing.T) {
|
|
nowMS := int64(1_800_000_000_000)
|
|
source := publishableTemplate(nowMS)
|
|
source.Status = domain.StatusDisabled
|
|
source.PublishedVersion = 3
|
|
source.PublishedAtMS = nowMS - 10_000
|
|
repository := &fakeRepository{current: source}
|
|
svc := New(repository, nil, nil)
|
|
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
|
|
|
|
got, err := svc.Clone(context.Background(), domain.CloneCommand{
|
|
SourceTemplateID: source.TemplateID, TemplateCode: "gift_challenge_copy", Name: "Gift Challenge Copy", OperatorAdminID: 90002,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Clone() error = %v", err)
|
|
}
|
|
if got.TemplateID == source.TemplateID || got.TemplateCode != "gift_challenge_copy" || got.Status != domain.StatusDraft {
|
|
t.Fatalf("clone identity/status = %+v", got)
|
|
}
|
|
if got.StartMS != 0 || got.EndMS != 0 || got.PublishedVersion != 0 || got.PublishedAtMS != 0 {
|
|
t.Fatalf("clone leaked runtime metadata = %+v", got)
|
|
}
|
|
if len(got.RegionIDs) != 1 || got.RegionIDs[0] != source.RegionIDs[0] ||
|
|
len(got.Tasks) != 1 || got.Tasks[0].RewardResourceGroupID != source.Tasks[0].RewardResourceGroupID ||
|
|
len(got.RankRewards) != len(source.RankRewards) {
|
|
t.Fatalf("clone did not preserve configuration: %+v", got)
|
|
}
|
|
}
|
|
|
|
func publishableTemplate(nowMS int64) domain.Template {
|
|
return domain.Template{
|
|
TemplateID: "acttpl_source", TemplateCode: "gift_challenge_2026", Name: "Gift Challenge 2026",
|
|
ActivityType: domain.ActivityTypeGiftChallenge, Status: domain.StatusDraft, Revision: 7,
|
|
StartMS: nowMS + 60_000, EndMS: nowMS + int64((7*24*time.Hour)/time.Millisecond),
|
|
AllRegions: false, RegionIDs: []int64{1},
|
|
Locales: []domain.Locale{{Locale: "en", Title: "Gift Challenge", Rules: "Send gifts and complete tasks."}},
|
|
Gifts: []domain.Gift{{
|
|
GiftID: "gift_rose", SortOrder: 1, Name: "Rose", IconURL: "https://cdn.example.com/gifts/rose.png", CoinPrice: 100, PriceVersion: "v3",
|
|
}},
|
|
Tasks: []domain.Task{{
|
|
TaskKey: "daily_visit", TaskType: domain.TaskTypeDailyVisit, TargetValue: 1, RewardResourceGroupID: 101, SortOrder: 1,
|
|
}},
|
|
DailyLeaderboardSize: 100,
|
|
TotalLeaderboardSize: 100,
|
|
RankRewards: []domain.RankReward{
|
|
{BoardType: domain.BoardTypeDaily, RankFrom: 1, RankTo: 3, ResourceGroupID: 102, SortOrder: 1},
|
|
{BoardType: domain.BoardTypeTotal, RankFrom: 1, RankTo: 3, ResourceGroupID: 103, SortOrder: 1},
|
|
},
|
|
DisplayConfigJSON: `{}`,
|
|
}
|
|
}
|
|
|
|
func publishableCatalog(item domain.Template) *fakeCatalog {
|
|
groups := make(map[int64]*walletv1.ResourceGroup)
|
|
for _, groupID := range []int64{101, 102, 103} {
|
|
groups[groupID] = &walletv1.ResourceGroup{
|
|
GroupId: groupID, Status: "active", Name: "Reward", UpdatedAtMs: 1_800_000_000_001,
|
|
Items: []*walletv1.ResourceGroupItem{{
|
|
GroupId: groupID, ResourceId: groupID + 1000, Quantity: 1, ItemType: "resource",
|
|
Resource: &walletv1.Resource{ResourceId: groupID + 1000, ResourceType: "badge", Name: "Badge", Status: "active", Grantable: true, GrantStrategy: "new_entitlement"},
|
|
}},
|
|
}
|
|
}
|
|
return &fakeCatalog{
|
|
gift: &walletv1.GiftConfig{
|
|
GiftId: item.Gifts[0].GiftID, Status: "active", Name: item.Gifts[0].Name,
|
|
CoinPrice: item.Gifts[0].CoinPrice, PriceVersion: item.Gifts[0].PriceVersion,
|
|
RegionIds: []int64{0},
|
|
Resource: &walletv1.Resource{
|
|
Status: "active", PreviewUrl: item.Gifts[0].IconURL, AssetUrl: "https://cdn.example.com/gifts/rose-animation.svga",
|
|
},
|
|
},
|
|
groups: groups,
|
|
}
|
|
}
|
|
|
|
func hasIssue(issues []domain.ValidationIssue, field string, code string) bool {
|
|
for _, issue := range issues {
|
|
if issue.Field == field && issue.Code == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|