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 }