588 lines
22 KiB
Go

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) {
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: -1,
CoinSpent: 0,
})
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
}