package achievement_test import ( "context" "fmt" "testing" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" domain "hyapp/services/activity-service/internal/domain/achievement" achievementservice "hyapp/services/activity-service/internal/service/achievement" "hyapp/services/activity-service/internal/testutil/mysqltest" ) func TestAchievementUnlockBadgeAndRewardFlow(t *testing.T) { svc, wallet := newAchievementService(t) ctx := appcode.WithContext(context.Background(), "lalu") definition, created, err := svc.UpsertAchievementDefinition(ctx, domain.DefinitionCommand{ CollectionID: domain.CollectionOrdinary, CollectionType: domain.CollectionOrdinary, AchievementType: domain.TypeOrdinary, Title: "任务达人", Status: domain.StatusActive, PrimaryBadgeResourceID: 7001, RewardResourceGroupID: 9001, AutoPinPolicy: domain.AutoPinIfEmpty, HonorVisibility: true, OperatorAdminID: 90001, Conditions: []domain.ConditionCommand{ {MetricType: "task_completed_count", TargetValue: 2, TargetUnit: "count"}, }, }) if err != nil || !created { t.Fatalf("UpsertAchievementDefinition failed: created=%v err=%v", created, err) } first, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ EventID: "ach-event-1", EventType: "TaskCompleted", SourceService: "activity-service", UserID: 10001, MetricType: "task_completed_count", Value: 1, OccurredAtMS: fixedAchievementNow().UnixMilli(), }) if err != nil { t.Fatalf("ConsumeAchievementEvent first failed: %v", err) } if first.MatchedAchievementCount != 1 || first.UnlockedAchievementCount != 0 { t.Fatalf("first event mismatch: %+v", first) } second, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ EventID: "ach-event-2", EventType: "TaskCompleted", SourceService: "activity-service", UserID: 10001, MetricType: "task_completed_count", Value: 1, OccurredAtMS: fixedAchievementNow().UnixMilli(), }) if err != nil { t.Fatalf("ConsumeAchievementEvent second failed: %v", err) } if second.UnlockedAchievementCount != 1 || second.RewardJobCount != 1 { t.Fatalf("second event mismatch: %+v", second) } duplicate, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ EventID: "ach-event-2", EventType: "TaskCompleted", SourceService: "activity-service", UserID: 10001, MetricType: "task_completed_count", Value: 1, OccurredAtMS: fixedAchievementNow().UnixMilli(), }) if err != nil || duplicate.Status != domain.EventStatusConsumed { t.Fatalf("duplicate event should return existing consumed status: result=%+v err=%v", duplicate, err) } items, total, err := svc.ListAchievements(ctx, domain.ListQuery{UserID: 10001, CollectionType: domain.CollectionOrdinary}) if err != nil { t.Fatalf("ListAchievements failed: %v", err) } if total != 1 || len(items) != 1 || items[0].AchievementID != definition.AchievementID || items[0].UserStatus != domain.UnlockStatusUnlocked || items[0].ProgressValue != 2 { t.Fatalf("achievement read mismatch: total=%d items=%+v", total, items) } profile, err := svc.ListBadgeProfile(ctx, 10001) if err != nil { t.Fatalf("ListBadgeProfile failed: %v", err) } if len(profile.HonorBadges) != 1 || profile.HonorBadges[0].ResourceID != 7001 { t.Fatalf("honor badge mismatch: %+v", profile) } if len(profile.ProfileTileBadges) != 1 || profile.ProfileTileBadges[0].ResourceID != 7001 { t.Fatalf("profile tile badge mismatch: %+v", profile) } if _, err := svc.SetBadgeDisplay(ctx, domain.DisplayCommand{ UserID: 10001, Slot: domain.BadgeSlotProfileTile, CommandID: "cmd-badge-display-1", Items: []domain.BadgeDisplayItem{ {ResourceID: 7001}, }, }); err != nil { t.Fatalf("SetBadgeDisplay failed: %v", err) } claimed, processed, success, failure, hasMore, err := svc.ProcessAchievementRewardBatch(ctx, "run-1", "worker-1", 10, time.Second) if err != nil { t.Fatalf("ProcessAchievementRewardBatch failed: %v", err) } if claimed != 1 || processed != 1 || success != 1 || failure != 0 || hasMore || len(wallet.grants) != 1 { t.Fatalf("reward batch mismatch: claimed=%d processed=%d success=%d failure=%d has_more=%v grants=%d", claimed, processed, success, failure, hasMore, len(wallet.grants)) } } func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) { svc, _ := newAchievementService(t) ctx := appcode.WithContext(context.Background(), "lalu") result, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ EventID: "wallet-badge-event-1", EventType: "ResourceGranted", SourceService: "wallet-service", UserID: 10001, MetricType: "wallet_badge_grant", Value: 1, OccurredAtMS: fixedAchievementNow().UnixMilli(), DimensionsJSON: `{"grant_id":"grant-badge-1","grant_source":"admin","items":[{"resource_id":8001,"resource_type":"badge","entitlement_id":"ent-badge-1","metadata_json":"{\"badge_form\":\"tile\"}"},{"resource_id":8002,"resource_type":"badge","entitlement_id":"ent-badge-2","metadata_json":"{\"badge_form\":\"strip\",\"default_slot\":\"profile_strip\"}"}]}`, }) if err != nil { t.Fatalf("ConsumeAchievementEvent wallet badge failed: %v", err) } if result.Status != domain.EventStatusConsumed { t.Fatalf("wallet badge event should be consumed, got %+v", result) } profile, err := svc.ListBadgeProfile(ctx, 10001) if err != nil { t.Fatalf("ListBadgeProfile failed: %v", err) } if len(profile.HonorBadges) != 1 || profile.HonorBadges[0].ResourceID != 8001 || profile.HonorBadges[0].SourceType != domain.SourceSystemGrant { t.Fatalf("honor badge projection mismatch: %+v", profile) } if len(profile.ProfileTileBadges) != 1 || profile.ProfileTileBadges[0].ResourceID != 8001 { t.Fatalf("profile tile projection mismatch: %+v", profile) } if len(profile.StripBadges) != 1 || profile.StripBadges[0].ResourceID != 8002 || profile.StripBadges[0].BadgeForm != domain.BadgeFormStrip { t.Fatalf("strip badge projection mismatch: %+v", profile) } duplicate, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ EventID: "wallet-badge-event-1", EventType: "ResourceGranted", SourceService: "wallet-service", UserID: 10001, MetricType: "wallet_badge_grant", Value: 1, OccurredAtMS: fixedAchievementNow().UnixMilli(), DimensionsJSON: `{"grant_id":"grant-badge-1","grant_source":"admin","items":[]}`, }) if err != nil || duplicate.Status != domain.EventStatusConsumed { t.Fatalf("duplicate wallet badge event should return existing status: result=%+v err=%v", duplicate, err) } } func TestDirectBadgeAchievementWithoutConditions(t *testing.T) { svc, _ := newAchievementService(t) ctx := appcode.WithContext(context.Background(), "lalu") if _, _, err := svc.UpsertAchievementDefinition(ctx, domain.DefinitionCommand{ CollectionType: domain.CollectionEvent, AchievementType: domain.TypeActivityLimited, Title: "活动徽章", Description: "活动达成后展示", Status: domain.StatusActive, PrimaryBadgeResourceID: 8101, OperatorAdminID: 90001, }); err != nil { t.Fatalf("UpsertAchievementDefinition without conditions failed: %v", err) } if _, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ EventID: "wallet-badge-event-direct", EventType: "ResourceGranted", SourceService: "wallet-service", UserID: 10002, MetricType: "wallet_badge_grant", Value: 1, OccurredAtMS: fixedAchievementNow().UnixMilli(), DimensionsJSON: `{"grant_id":"grant-badge-direct","grant_source":"admin","items":[{"resource_id":8101,"resource_type":"badge","entitlement_id":"ent-badge-direct","metadata_json":"{\"badge_form\":\"tile\"}"}]}`, }); err != nil { t.Fatalf("project badge resource failed: %v", err) } items, total, err := svc.ListAchievements(ctx, domain.ListQuery{UserID: 10002, CollectionType: domain.CollectionEvent}) if err != nil { t.Fatalf("ListAchievements direct badge failed: %v", err) } if total != 1 || len(items) != 1 || items[0].UserStatus != domain.UnlockStatusUnlocked || items[0].ProgressValue != 1 || items[0].TargetValue != 1 { t.Fatalf("direct badge achievement read mismatch: total=%d items=%+v", total, items) } deleted, err := svc.DeleteAchievementDefinition(ctx, items[0].AchievementID, 90001) if err != nil || deleted.Status != domain.StatusArchived { t.Fatalf("DeleteAchievementDefinition failed: item=%+v err=%v", deleted, err) } adminItems, adminTotal, err := svc.ListAchievementDefinitions(ctx, domain.ListQuery{CollectionType: domain.CollectionEvent}) if err != nil { t.Fatalf("ListAchievementDefinitions after delete failed: %v", err) } if adminTotal != 0 || len(adminItems) != 0 { t.Fatalf("archived achievement should be hidden by default: total=%d items=%+v", adminTotal, adminItems) } } func TestAchievementListsReturnHighSortOrderFirst(t *testing.T) { svc, _ := newAchievementService(t) ctx := appcode.WithContext(context.Background(), "lalu") definitions := []domain.DefinitionCommand{ {Title: "低优先级徽章", SortOrder: 10, PrimaryBadgeResourceID: 8201}, {Title: "高优先级徽章", SortOrder: 30, PrimaryBadgeResourceID: 8202}, {Title: "中优先级徽章", SortOrder: 20, PrimaryBadgeResourceID: 8203}, } for _, definition := range definitions { definition.CollectionType = domain.CollectionOrdinary definition.AchievementType = domain.TypeOrdinary definition.Status = domain.StatusActive definition.OperatorAdminID = 90001 if _, _, err := svc.UpsertAchievementDefinition(ctx, definition); err != nil { t.Fatalf("upsert sorted achievement failed: %v", err) } } adminItems, adminTotal, err := svc.ListAchievementDefinitions(ctx, domain.ListQuery{CollectionType: domain.CollectionOrdinary}) if err != nil { t.Fatalf("ListAchievementDefinitions sorted failed: %v", err) } if adminTotal != 3 || len(adminItems) != 3 { t.Fatalf("admin sorted achievement count mismatch: total=%d items=%+v", adminTotal, adminItems) } if adminItems[0].SortOrder != 30 || adminItems[1].SortOrder != 20 || adminItems[2].SortOrder != 10 { t.Fatalf("admin achievements should be sorted by sort_order desc: %+v", adminItems) } userItems, userTotal, err := svc.ListAchievements(ctx, domain.ListQuery{UserID: 10003, CollectionType: domain.CollectionOrdinary}) if err != nil { t.Fatalf("ListAchievements sorted failed: %v", err) } if userTotal != 3 || len(userItems) != 3 { t.Fatalf("user sorted achievement count mismatch: total=%d items=%+v", userTotal, userItems) } if userItems[0].SortOrder != 30 || userItems[1].SortOrder != 20 || userItems[2].SortOrder != 10 { t.Fatalf("user achievements should be sorted by sort_order desc: %+v", userItems) } } func newAchievementService(t *testing.T) (*achievementservice.Service, *fakeAchievementWallet) { t.Helper() repository := mysqltest.NewRepository(t) wallet := &fakeAchievementWallet{} svc := achievementservice.New(repository, wallet) svc.SetClock(fixedAchievementNow) return svc, wallet } func fixedAchievementNow() time.Time { return time.Date(2026, 5, 14, 4, 0, 0, 0, time.UTC) } type fakeAchievementWallet struct { grants []*walletv1.GrantResourceGroupRequest } func (f *fakeAchievementWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { f.grants = append(f.grants, req) return &walletv1.ResourceGrantResponse{ Grant: &walletv1.ResourceGrant{ GrantId: fmt.Sprintf("achievement-grant-%d", len(f.grants)), Status: "succeeded", }, }, nil }