387 lines
14 KiB
Go

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
}