package task_test import ( "context" "testing" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" taskdomain "hyapp/services/activity-service/internal/domain/task" taskservice "hyapp/services/activity-service/internal/service/task" "hyapp/services/activity-service/internal/testutil/mysqltest" ) // TestTaskEventQueryAndClaimFlow 覆盖任务定义、事件进度、App 查询、领奖和领奖幂等的主闭环。 func TestTaskEventQueryAndClaimFlow(t *testing.T) { repository := mysqltest.NewRepository(t) wallet := &fakeWalletClient{} svc := taskservice.New(repository, wallet) now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.FixedZone("CST", 8*60*60)) svc.SetClock(func() time.Time { return now }) definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{ TaskType: taskdomain.TypeDaily, Category: "gift", MetricType: "gift_spend_coin", Title: "send gift", TargetValue: 100, TargetUnit: "coin", RewardCoinAmount: 10, Status: taskdomain.StatusActive, SortOrder: 1, OperatorAdminID: 90001, }) if err != nil || !created { t.Fatalf("create task definition failed: created=%v err=%v", created, err) } result, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{ EventID: "evt-gift-1", EventType: "WalletGiftDebited", SourceService: "wallet", UserID: 10001, MetricType: "gift_spend_coin", Value: 120, OccurredAtMS: now.UnixMilli(), }) if err != nil { t.Fatalf("ConsumeTaskEvent failed: %v", err) } if result.Status != taskdomain.EventStatusConsumed || result.MatchedTaskCount != 1 { t.Fatalf("event result mismatch: %+v", result) } duplicate, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{ EventID: "evt-gift-1", EventType: "WalletGiftDebited", SourceService: "wallet", UserID: 10001, MetricType: "gift_spend_coin", Value: 120, OccurredAtMS: now.UnixMilli(), }) if err != nil || duplicate.Status != taskdomain.EventStatusConsumed { t.Fatalf("duplicate event should be acknowledged: result=%+v err=%v", duplicate, err) } list, err := svc.ListUserTasks(context.Background(), 10001) if err != nil { t.Fatalf("ListUserTasks failed: %v", err) } daily := list.Sections[0] if len(daily.Items) != 1 || daily.Items[0].TaskID != definition.TaskID || daily.Items[0].ProgressValue != 120 || daily.Items[0].UserStatus != taskdomain.StatusCompleted || !daily.Items[0].Claimable { t.Fatalf("daily task item mismatch: %+v", daily.Items) } claim, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{ UserID: 10001, TaskID: definition.TaskID, TaskType: taskdomain.TypeDaily, CycleKey: "2026-05-09", CommandID: "cmd-claim-1", }) if err != nil { t.Fatalf("ClaimTaskReward failed: %v", err) } if claim.Status != taskdomain.ClaimStatusGranted || claim.WalletTransactionID != "wtx-task-1" || wallet.calls != 1 { t.Fatalf("claim result mismatch: claim=%+v wallet_calls=%d", claim, wallet.calls) } again, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{ UserID: 10001, TaskID: definition.TaskID, TaskType: taskdomain.TypeDaily, CycleKey: "2026-05-09", CommandID: "cmd-claim-1", }) if err != nil { t.Fatalf("ClaimTaskReward retry failed: %v", err) } if again.ClaimID != claim.ClaimID || wallet.calls != 1 { t.Fatalf("claim idempotency mismatch: first=%+v second=%+v calls=%d", claim, again, wallet.calls) } } func TestTaskEventDimensionFilter(t *testing.T) { repository := mysqltest.NewRepository(t) svc := taskservice.New(repository, &fakeWalletClient{}) now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC) svc.SetClock(func() time.Time { return now }) definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{ TaskType: taskdomain.TypeDaily, Category: "gift", MetricType: taskdomain.MetricGiftSpendCoin, Title: "send specified gift", DimensionFilterJSON: `{"gift_id":"gift_1001"}`, TargetValue: 100, TargetUnit: "coin", RewardCoinAmount: 10, Status: taskdomain.StatusActive, OperatorAdminID: 90001, }) if err != nil || !created { t.Fatalf("create filtered task definition failed: created=%v err=%v", created, err) } skipped, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{ EventID: "evt-gift-filter-skip", EventType: "RoomGiftSent", SourceService: "room-service", UserID: 10001, MetricType: taskdomain.MetricGiftSpendCoin, Value: 100, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{"gift_id":"gift_2002"}`, }) if err != nil { t.Fatalf("ConsumeTaskEvent skip failed: %v", err) } if skipped.Status != taskdomain.EventStatusSkipped || skipped.MatchedTaskCount != 0 { t.Fatalf("non-matching dimensions should skip: %+v", skipped) } consumed, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{ EventID: "evt-gift-filter-hit", EventType: "RoomGiftSent", SourceService: "room-service", UserID: 10001, MetricType: taskdomain.MetricGiftSpendCoin, Value: 100, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{"gift_id":"gift_1001"}`, }) if err != nil { t.Fatalf("ConsumeTaskEvent hit failed: %v", err) } if consumed.Status != taskdomain.EventStatusConsumed || consumed.MatchedTaskCount != 1 { t.Fatalf("matching dimensions should consume: %+v", consumed) } list, err := svc.ListUserTasks(context.Background(), 10001) if err != nil { t.Fatalf("ListUserTasks failed: %v", err) } if len(list.Sections[0].Items) != 1 || list.Sections[0].Items[0].TaskID != definition.TaskID || list.Sections[0].Items[0].ProgressValue != 100 { t.Fatalf("filtered progress mismatch: %+v", list.Sections[0].Items) } } func TestHuwaaTaskRewardPolicyDefaultsNewTasksToPointAndClaimsPoint(t *testing.T) { repository := mysqltest.NewRepository(t) wallet := &fakeWalletClient{} svc := taskservice.New(repository, wallet) now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC) svc.SetClock(func() time.Time { return now }) ctx := appcode.WithContext(context.Background(), "huwaa") if err := svc.PublishTaskRewardPolicy(ctx, taskdomain.RewardPolicyCommand{ InstanceCode: "huwaa-point-live", TemplateCode: "huwaa_point_policy", TemplateVersion: "v2", Status: "active", RewardAssetType: taskdomain.RewardAssetPoint, RuleJSON: `{"tasks":{"reward_asset_type":"POINT"}}`, OperatorAdminID: 90001, PublishedAtMS: now.UnixMilli(), }); err != nil { t.Fatalf("PublishTaskRewardPolicy failed: %v", err) } definition, created, err := svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{ TaskType: taskdomain.TypeDaily, Category: "gift", MetricType: taskdomain.MetricGiftSpendCoin, Title: "send gift for point", TargetValue: 100, TargetUnit: "coin", RewardCoinAmount: 12, Status: taskdomain.StatusActive, OperatorAdminID: 90001, }) if err != nil || !created { t.Fatalf("create Huwaa task definition failed: created=%v err=%v", created, err) } if definition.RewardAssetType != taskdomain.RewardAssetPoint { t.Fatalf("policy default reward asset mismatch: %+v", definition) } if _, err := svc.ConsumeTaskEvent(ctx, taskdomain.Event{ EventID: "evt-huwaa-point-task", EventType: "RoomGiftSent", SourceService: "room-service", UserID: 41001, MetricType: taskdomain.MetricGiftSpendCoin, Value: 100, OccurredAtMS: now.UnixMilli(), }); err != nil { t.Fatalf("ConsumeTaskEvent failed: %v", err) } claim, err := svc.ClaimTaskReward(ctx, taskdomain.ClaimCommand{ UserID: 41001, TaskID: definition.TaskID, TaskType: taskdomain.TypeDaily, CycleKey: "2026-05-09", CommandID: "cmd-huwaa-point-task", }) if err != nil { t.Fatalf("ClaimTaskReward failed: %v", err) } if claim.RewardAssetType != taskdomain.RewardAssetPoint { t.Fatalf("claim reward asset mismatch: %+v", claim) } if wallet.last == nil || wallet.last.GetAppCode() != "huwaa" || wallet.last.GetRewardAssetType() != taskdomain.RewardAssetPoint || wallet.last.GetAmount() != 12 { t.Fatalf("wallet POINT reward request mismatch: %+v", wallet.last) } } func TestPointTaskRewardPolicyIsRejectedOutsideHuwaa(t *testing.T) { repository := mysqltest.NewRepository(t) svc := taskservice.New(repository, &fakeWalletClient{}) ctx := appcode.WithContext(context.Background(), "lalu") if err := svc.PublishTaskRewardPolicy(ctx, taskdomain.RewardPolicyCommand{ InstanceCode: "lalu-point-denied", TemplateCode: "huwaa_point_policy", TemplateVersion: "v2", Status: "active", RewardAssetType: taskdomain.RewardAssetPoint, RuleJSON: `{}`, OperatorAdminID: 90001, }); err == nil { t.Fatal("Lalu POINT policy publish must be rejected") } if _, _, err := svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{ TaskType: taskdomain.TypeDaily, Category: "gift", MetricType: taskdomain.MetricGiftSpendCoin, Title: "bad point task", TargetValue: 100, TargetUnit: "coin", RewardCoinAmount: 12, RewardAssetType: taskdomain.RewardAssetPoint, Status: taskdomain.StatusActive, OperatorAdminID: 90001, }); err == nil { t.Fatal("Lalu explicit POINT task definition must be rejected") } } func TestTaskRewardPointPolicyAppGateWithoutMySQL(t *testing.T) { repository := &fakeTaskPolicyRepository{activeAsset: taskdomain.RewardAssetPoint, activeFound: true} svc := taskservice.New(repository, &fakeWalletClient{}) if err := svc.PublishTaskRewardPolicy(appcode.WithContext(context.Background(), "lalu"), taskdomain.RewardPolicyCommand{ InstanceCode: "lalu-point-denied", TemplateCode: "huwaa_point_policy", TemplateVersion: "v2", Status: "active", RewardAssetType: taskdomain.RewardAssetPoint, RuleJSON: `{}`, OperatorAdminID: 90001, }); err == nil { t.Fatal("Lalu POINT task reward policy must be rejected before repository call") } if repository.publishCalls != 0 { t.Fatalf("rejected Lalu POINT policy must not call repository, got %d calls", repository.publishCalls) } if err := svc.PublishTaskRewardPolicy(appcode.WithContext(context.Background(), "huwaa"), taskdomain.RewardPolicyCommand{ InstanceCode: "huwaa-point-live", TemplateCode: "huwaa_point_policy", TemplateVersion: "v2", Status: "active", RewardAssetType: taskdomain.RewardAssetPoint, RuleJSON: `{}`, OperatorAdminID: 90001, }); err != nil { t.Fatalf("Huwaa POINT task reward policy should reach repository: %v", err) } if repository.publishCalls != 1 || repository.lastPublishApp != "huwaa" || repository.lastPublish.RewardAssetType != taskdomain.RewardAssetPoint { t.Fatalf("Huwaa POINT policy repository payload mismatch: repo=%+v", repository) } definition, created, err := svc.UpsertTaskDefinition(appcode.WithContext(context.Background(), "huwaa"), taskdomain.DefinitionCommand{ TaskType: taskdomain.TypeDaily, Category: "gift", MetricType: taskdomain.MetricGiftSpendCoin, Title: "fake point task", TargetValue: 100, TargetUnit: "coin", RewardCoinAmount: 12, Status: taskdomain.StatusActive, OperatorAdminID: 90001, }) if err != nil || !created { t.Fatalf("Huwaa policy default task definition failed: created=%v err=%v", created, err) } if definition.RewardAssetType != taskdomain.RewardAssetPoint || repository.activeLookupApp != "huwaa" || repository.lastUpsert.RewardAssetType != taskdomain.RewardAssetPoint { t.Fatalf("Huwaa policy default task definition mismatch: definition=%+v repo=%+v", definition, repository) } } type fakeTaskPolicyRepository struct { taskservice.Repository activeAsset string activeFound bool activeLookupApp string publishCalls int lastPublish taskdomain.RewardPolicyCommand lastPublishApp string lastUpsert taskdomain.DefinitionCommand } func (f *fakeTaskPolicyRepository) GetActiveTaskRewardAssetType(ctx context.Context, _ int64) (string, bool, error) { f.activeLookupApp = appcode.FromContext(ctx) return f.activeAsset, f.activeFound, nil } func (f *fakeTaskPolicyRepository) PublishTaskRewardPolicy(ctx context.Context, command taskdomain.RewardPolicyCommand, _ int64) error { f.publishCalls++ f.lastPublish = command f.lastPublishApp = appcode.FromContext(ctx) return nil } func (f *fakeTaskPolicyRepository) UpsertTaskDefinition(_ context.Context, command taskdomain.DefinitionCommand, _ int64) (taskdomain.Definition, bool, error) { f.lastUpsert = command return taskdomain.Definition{ AppCode: "huwaa", TaskID: "fake-point-task", TaskType: command.TaskType, Category: command.Category, MetricType: command.MetricType, Title: command.Title, TargetValue: command.TargetValue, TargetUnit: command.TargetUnit, RewardCoinAmount: command.RewardCoinAmount, RewardAssetType: command.RewardAssetType, Status: command.Status, }, true, nil } type fakeWalletClient struct { calls int last *walletv1.CreditTaskRewardRequest } func (f *fakeWalletClient) CreditTaskReward(_ context.Context, req *walletv1.CreditTaskRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) { f.calls++ f.last = req assetType := req.GetRewardAssetType() if assetType == "" { assetType = taskdomain.RewardAssetCoin } return &walletv1.CreditTaskRewardResponse{ TransactionId: "wtx-task-1", Amount: req.GetAmount(), GrantedAtMs: 1778292000000, Balance: &walletv1.AssetBalance{AssetType: assetType, AvailableAmount: req.GetAmount()}, }, nil }