package task_test import ( "context" "testing" "time" "google.golang.org/grpc" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" taskdomain "hyapp/services/activity-service/internal/domain/task" taskservice "hyapp/services/activity-service/internal/service/task" "hyapp/services/activity-service/internal/testutil/mysqltest" ) // TestHandleRoomEventKeepsCountForProductionZeroValueGift 回归生产 gift_id=459 的零价礼物。 // 这类事实必须确认并累计送礼次数,不能生成会被领域层拒绝的零增量 spend 事件。 func TestHandleRoomEventKeepsCountForProductionZeroValueGift(t *testing.T) { repository := newRecordingTaskRepository() svc := taskservice.New(repository, &fakeWalletClient{}) envelope := roomGiftEnvelope(t, "evt-production-free-gift-459", &roomeventsv1.RoomGiftSent{ SenderUserId: 10001, TargetUserId: 20002, GiftId: "459", GiftCount: 1, GiftValue: 0, CoinSpent: 0, GiftTypeCode: "normal", }) consumed, err := svc.HandleRoomEvent(context.Background(), envelope) if err != nil { t.Fatalf("HandleRoomEvent zero-value gift failed: %v", err) } if consumed != 1 { t.Fatalf("zero-value gift consumed=%d, want one count projection", consumed) } assertRecordedTaskEvents(t, repository.events, []expectedTaskEvent{{ EventID: "evt-production-free-gift-459:task:gift_count", MetricType: taskdomain.MetricGiftSendCount, Value: 1, }}) // RocketMQ 重投会用相同 envelope event_id 再次进入;稳定后缀让仓储幂等确认,不重复累加。 if _, err := svc.HandleRoomEvent(context.Background(), envelope); err != nil { t.Fatalf("HandleRoomEvent duplicate zero-value gift failed: %v", err) } if len(repository.events) != 1 || repository.calls != 2 { t.Fatalf("duplicate projection must keep one unique event: calls=%d events=%+v", repository.calls, repository.events) } } func TestHandleRoomEventUsesPositiveSpendCompatibilityFields(t *testing.T) { tests := []struct { name string eventID string giftValue int64 coinSpent int64 wantSpend int64 }{ {name: "legacy gift_value fallback", eventID: "evt-legacy-gift-value", giftValue: 40, coinSpent: 0, wantSpend: 40}, {name: "coin_spent preferred", eventID: "evt-coin-spent", giftValue: 0, coinSpent: 60, wantSpend: 60}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repository := newRecordingTaskRepository() svc := taskservice.New(repository, &fakeWalletClient{}) envelope := roomGiftEnvelope(t, tt.eventID, &roomeventsv1.RoomGiftSent{ SenderUserId: 10001, TargetUserId: 20002, GiftId: "gift-compat", GiftCount: 3, GiftValue: tt.giftValue, CoinSpent: tt.coinSpent, GiftTypeCode: "normal", }) if _, err := svc.HandleRoomEvent(context.Background(), envelope); err != nil { t.Fatalf("HandleRoomEvent failed: %v", err) } assertRecordedTaskEvents(t, repository.events, []expectedTaskEvent{ {EventID: envelope.GetEventId() + ":task:gift_spend", MetricType: taskdomain.MetricGiftSpendCoin, Value: tt.wantSpend}, {EventID: envelope.GetEventId() + ":task:gift_count", MetricType: taskdomain.MetricGiftSendCount, Value: 3}, }) }) } } // TestHandleRoomEventKeepsLuckyCountsForZeroValueGift 锁定幸运礼物的两层计数口径: // 免费礼物不累计金币,但仍同时属于普通送礼次数和幸运礼物次数。 func TestHandleRoomEventKeepsLuckyCountsForZeroValueGift(t *testing.T) { repository := newRecordingTaskRepository() svc := taskservice.New(repository, &fakeWalletClient{}) envelope := roomGiftEnvelope(t, "evt-free-lucky-gift", &roomeventsv1.RoomGiftSent{ SenderUserId: 10001, TargetUserId: 20002, GiftId: "free-lucky", GiftCount: 5, GiftValue: 0, CoinSpent: 0, PoolId: "lucky_pool_free", GiftTypeCode: "normal", }) if _, err := svc.HandleRoomEvent(context.Background(), envelope); err != nil { t.Fatalf("HandleRoomEvent zero-value lucky gift failed: %v", err) } assertRecordedTaskEvents(t, repository.events, []expectedTaskEvent{ {EventID: "evt-free-lucky-gift:task:gift_count", MetricType: taskdomain.MetricGiftSendCount, Value: 5}, {EventID: "evt-free-lucky-gift:task:lucky_gift_count", MetricType: taskdomain.MetricLuckyGiftSendCount, Value: 5}, }) } // TestConsumeTaskEventStillRejectsZeroValue 保留通用领域边界:零增量应在事实转换层被过滤, // 不能进入仓储的幂等表和进度表,否则会混淆“已消费”与“有有效进度”。 func TestConsumeTaskEventStillRejectsZeroValue(t *testing.T) { repository := newRecordingTaskRepository() svc := taskservice.New(repository, &fakeWalletClient{}) _, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{ EventID: "evt-zero-domain-value", EventType: "RoomGiftSent", SourceService: "room-service", UserID: 10001, MetricType: taskdomain.MetricGiftSpendCoin, Value: 0, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("zero task value error=%v, want INVALID_ARGUMENT", err) } if repository.calls != 0 || len(repository.events) != 0 { t.Fatalf("invalid zero-value event must not reach repository: calls=%d events=%+v", repository.calls, repository.events) } } // TestHandleRoomEventStillRejectsNegativeSpend 确认兼容分支只确认合法零值,不把负值上游事实降级成计数事件。 func TestHandleRoomEventStillRejectsNegativeSpend(t *testing.T) { tests := []struct { name string giftValue int64 coinSpent int64 }{ {name: "negative coin spent does not fallback", giftValue: 100, coinSpent: -1}, {name: "negative legacy gift value", giftValue: -1, coinSpent: 0}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repository := newRecordingTaskRepository() svc := taskservice.New(repository, &fakeWalletClient{}) envelope := roomGiftEnvelope(t, "evt-negative-gift-value", &roomeventsv1.RoomGiftSent{ SenderUserId: 10001, TargetUserId: 20002, GiftId: "broken-gift", GiftCount: 1, GiftValue: test.giftValue, CoinSpent: test.coinSpent, }) if _, err := svc.HandleRoomEvent(context.Background(), envelope); !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("negative spend error=%v, want INVALID_ARGUMENT", err) } if repository.calls != 0 || len(repository.events) != 0 { t.Fatalf("negative spend must fail before repository: calls=%d events=%+v", repository.calls, repository.events) } }) } } // 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 expectedTaskEvent struct { EventID string MetricType string Value int64 } // recordingTaskRepository 只实现 HandleRoomEvent 依赖的写入边界,并用 event_id 模拟 MySQL // task_event_consumption 的幂等行为;测试因此可以同时断言投影内容和 MQ 重投结果。 type recordingTaskRepository struct { taskservice.Repository calls int events []taskdomain.Event seen map[string]struct{} } func newRecordingTaskRepository() *recordingTaskRepository { return &recordingTaskRepository{seen: make(map[string]struct{})} } func (r *recordingTaskRepository) ConsumeTaskEvent(_ context.Context, event taskdomain.Event, _ string, _ int64) (taskdomain.EventResult, error) { r.calls++ if _, exists := r.seen[event.EventID]; exists { return taskdomain.EventResult{EventID: event.EventID, Status: taskdomain.EventStatusConsumed}, nil } r.seen[event.EventID] = struct{}{} r.events = append(r.events, event) return taskdomain.EventResult{EventID: event.EventID, Status: taskdomain.EventStatusConsumed, MatchedTaskCount: 1}, nil } func roomGiftEnvelope(t *testing.T, eventID string, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope { t.Helper() body, err := proto.Marshal(gift) if err != nil { t.Fatalf("marshal RoomGiftSent failed: %v", err) } return &roomeventsv1.EventEnvelope{ AppCode: "lalu", EventId: eventID, RoomId: "lalu-regression-room", EventType: "RoomGiftSent", OccurredAtMs: time.Date(2026, 7, 9, 0, 0, 0, 0, time.UTC).UnixMilli(), Body: body, } } func assertRecordedTaskEvents(t *testing.T, got []taskdomain.Event, want []expectedTaskEvent) { t.Helper() if len(got) != len(want) { t.Fatalf("projected event count=%d, want=%d: %+v", len(got), len(want), got) } for i := range want { if got[i].EventID != want[i].EventID || got[i].MetricType != want[i].MetricType || got[i].Value != want[i].Value { t.Fatalf("projected event[%d]=%+v, want=%+v", i, got[i], want[i]) } } } 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 }