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

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
}