diff --git a/backend/services/taskService/mq/consumer_test.go b/backend/services/taskService/mq/consumer_test.go new file mode 100644 index 0000000..537801e --- /dev/null +++ b/backend/services/taskService/mq/consumer_test.go @@ -0,0 +1,183 @@ +package mq + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/topfans/backend/pkg/logger" + "github.com/topfans/backend/pkg/mq/adapter" + "github.com/topfans/backend/pkg/mq/tasks" + pb "github.com/topfans/backend/pkg/proto/task" + pbCommon "github.com/topfans/backend/pkg/proto/common" + "github.com/topfans/backend/services/taskService/service" +) + +// TestMain 初始化 logger(handler 错误路径调 logger.Logger.Error,nil 会 panic) +func TestMain(m *testing.M) { + _ = logger.Init(logger.Config{ + ServiceName: "task-service-mq-test", + Environment: "test", + LogLevel: "error", + }) + os.Exit(m.Run()) +} + +// fakeDailyTaskService 实现 service.DailyTaskService 接口, +// 只模拟 ProcessTaskEvent(newHandleTaskEvent 唯一调用的方法)。 +// 其他方法返回零值,因为 handler 不调它们。 +type fakeDailyTaskService struct { + processTaskEventFn func(ctx context.Context, userID, starID int64, eventType string) (*service.TaskEventResult, error) +} + +func (f *fakeDailyTaskService) GetDailyTasks(ctx context.Context, userID, starID int64) (*pb.GetDailyTasksResponse, error) { + return &pb.GetDailyTasksResponse{Tasks: []*pb.DailyTaskItem{}}, nil +} +func (f *fakeDailyTaskService) ReportEvent(ctx context.Context, userID, starID int64, eventType string) (*pb.ReportEventResponse, error) { + return &pb.ReportEventResponse{Success: false}, nil +} +func (f *fakeDailyTaskService) ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*service.TaskEventResult, error) { + if f.processTaskEventFn != nil { + return f.processTaskEventFn(ctx, userID, starID, eventType) + } + return &service.TaskEventResult{}, nil +} +func (f *fakeDailyTaskService) ClaimDailyTask(ctx context.Context, userID, starID int64, taskKey string) (*pb.ClaimDailyTaskResponse, error) { + return &pb.ClaimDailyTaskResponse{Success: false}, nil +} +func (f *fakeDailyTaskService) ClaimAllDailyTasks(ctx context.Context, userID, starID int64) (*pb.ClaimAllDailyTasksResponse, error) { + return &pb.ClaimAllDailyTasksResponse{ClaimedCount: 0}, nil +} + +// _ 防 common 包未使用(如果接口零值字段不需要,common import 会被 go vet 标记) +var _ = pbCommon.BaseResponse{} + +// ==================== TestNewHandleTaskEvent_Success ==================== + +func TestNewHandleTaskEvent_Success(t *testing.T) { + called := false + var capturedUID, capturedSID int64 + var capturedEvent string + + fake := &fakeDailyTaskService{ + processTaskEventFn: func(ctx context.Context, userID, starID int64, eventType string) (*service.TaskEventResult, error) { + called = true + capturedUID = userID + capturedSID = starID + capturedEvent = eventType + return &service.TaskEventResult{CompletedTaskKeys: []string{"daily_mint"}}, nil + }, + } + + payload, err := tasks.MarshalToPayload(tasks.TaskEventPayload{ + UserID: 100, + StarID: 87, + EventType: tasks.EventDailyMint, + }) + if err != nil { + t.Fatalf("MarshalToPayload: %v", err) + } + + handler := newHandleTaskEvent(fake) + task := adapter.Task{ + Type: tasks.TypeTaskEvent, + Payload: payload, + } + + if err := handler(context.Background(), &task); err != nil { + t.Fatalf("handler returned error: %v", err) + } + + if !called { + t.Errorf("expected ProcessTaskEvent to be called") + } + if capturedUID != 100 { + t.Errorf("expected userID=100, got %d", capturedUID) + } + if capturedSID != 87 { + t.Errorf("expected starID=87, got %d", capturedSID) + } + if capturedEvent != "daily_mint" { + t.Errorf("expected event_type=daily_mint, got %s", capturedEvent) + } +} + +// ==================== TestNewHandleTaskEvent_ProcessError ==================== + +func TestNewHandleTaskEvent_ProcessError_ReturnsError(t *testing.T) { + // newHandleTaskEvent 应返回 error(让 Asynq 触发 MaxRetry=3 重试) + processErr := errors.New("simulated ProcessTaskEvent failure") + fake := &fakeDailyTaskService{ + processTaskEventFn: func(ctx context.Context, userID, starID int64, eventType string) (*service.TaskEventResult, error) { + return nil, processErr + }, + } + + payload, _ := tasks.MarshalToPayload(tasks.TaskEventPayload{ + UserID: 200, StarID: 88, EventType: "daily_browse_asset", + }) + + handler := newHandleTaskEvent(fake) + err := handler(context.Background(), &adapter.Task{ + Type: tasks.TypeTaskEvent, + Payload: payload, + }) + + if err == nil { + t.Fatalf("expected error from handler when ProcessTaskEvent fails") + } + if !errors.Is(err, processErr) { + // 我们没有用 errors.Is wrap,仅要求 err != nil 即可 + // handler 直接 return err(spec §5 让 Asynq 重试) + _ = err // err 已经是 processErr(handler 直接透传) + } +} + +// ==================== TestNewHandleTaskEvent_UnmarshalError ==================== + +func TestNewHandleTaskEvent_UnmarshalError_ReturnsError(t *testing.T) { + called := false + fake := &fakeDailyTaskService{ + processTaskEventFn: func(ctx context.Context, userID, starID int64, eventType string) (*service.TaskEventResult, error) { + called = true + return &service.TaskEventResult{}, nil + }, + } + + // 构造一个无法 unmarshal 成 TaskEventPayload 的 payload(类型不匹配) + badPayload := map[string]any{ + "user_id": "not-an-int64", // 类型错误 + "star_id": 87, + "event_type": "daily_mint", + } + + handler := newHandleTaskEvent(fake) + err := handler(context.Background(), &adapter.Task{ + Type: tasks.TypeTaskEvent, + Payload: badPayload, + }) + + if err == nil { + t.Fatalf("expected unmarshal error") + } + if called { + t.Errorf("ProcessTaskEvent should NOT be called when unmarshal fails") + } +} + +// ==================== TestNewHandleTaskEvent_NilPayload ==================== + +func TestNewHandleTaskEvent_NilPayload_ReturnsError(t *testing.T) { + fake := &fakeDailyTaskService{} + + handler := newHandleTaskEvent(fake) + err := handler(context.Background(), &adapter.Task{ + Type: tasks.TypeTaskEvent, + Payload: nil, + }) + + if err == nil { + t.Fatalf("expected error for nil payload") + } +} \ No newline at end of file diff --git a/backend/services/taskService/repository/daily_task_repo_test.go b/backend/services/taskService/repository/daily_task_repo_test.go new file mode 100644 index 0000000..36a05c7 --- /dev/null +++ b/backend/services/taskService/repository/daily_task_repo_test.go @@ -0,0 +1,329 @@ +package repository + +import ( + "testing" + "time" + + "github.com/topfans/backend/pkg/database" + "github.com/topfans/backend/services/taskService/model" + "gorm.io/gorm" +) + +// 注:本文件与 like_bet_repo_test.go 同包,TestMain 由 like_bet_repo_test.go 提供。 +// 本文件只添加新测试 + helper 函数;不重复定义 TestMain。 + +// setupDailyTaskDB 复用 like_bet_repo_test.go 的连接配置(dev PG 15432) +// 加 AutoMigrate 任务相关表,避免测试因 schema 不存在而 panic。 +func setupDailyTaskDB(t *testing.T) *gorm.DB { + config := database.Config{ + Host: "localhost", + Port: 15432, + User: "postgres", + Password: "123456", + DBName: "top-fans", + SSLMode: "disable", + TimeZone: "Asia/Shanghai", + } + if err := database.Init(config); err != nil { + t.Skipf("Skipping test: failed to connect to test database: %v", err) + } + db := database.GetDB() + if err := db.AutoMigrate( + &model.TaskDefinition{}, + &model.UserDailyTaskProgress{}, + ); err != nil { + t.Logf("Warning: AutoMigrate daily_task: %v", err) + } + return db +} + +// cleanupDailyTaskRecords 删除 sentinel 行(star_id = -77)避免污染线上数据 +func cleanupDailyTaskRecords(t *testing.T, db *gorm.DB) { + if err := db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}).Error; err != nil { + t.Logf("Warning: cleanup task_definitions sentinel: %v", err) + } + if err := db.Where("user_id < ?", 0).Delete(&model.UserDailyTaskProgress{}).Error; err != nil { + t.Logf("Warning: cleanup user_daily_task_progress sentinel: %v", err) + } +} + +// seedTestDefinition 插入一条 test_daily 任务定义(trigger_event="test_event_"), +// 返回定义 + cleanup 函数。 +func seedTestDefinition(t *testing.T, db *gorm.DB, triggerEvent string, targetCount int) *model.TaskDefinition { + def := &model.TaskDefinition{ + StarID: nil, // 全局默认 + TaskKey: "test_task_" + triggerEvent, + TaskType: "daily", + Name: "test daily", + CrystalReward: 10, + TriggerEvent: triggerEvent, + TargetCount: targetCount, + IsActive: true, + CreatedAt: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), + } + if err := db.Create(def).Error; err != nil { + t.Fatalf("seed task_definition: %v", err) + } + return def +} + +// ==================== TestIncrementProgress ==================== + +func TestIncrementProgress_PendingToOne(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + def := seedTestDefinition(t, db, "test_increment_pending", 3) + defer db.Delete(def) + + progress, err := repo.GetOrCreateDailyProgress(-100, -77, def.TaskKey, def) + if err != nil { + t.Fatalf("GetOrCreate: %v", err) + } + + if err := repo.IncrementProgress(progress, def); err != nil { + t.Fatalf("IncrementProgress: %v", err) + } + + if progress.Progress != 1 { + t.Errorf("expected Progress=1, got %d", progress.Progress) + } + if progress.Status != "pending" { + t.Errorf("expected Status=pending (target=3 not reached), got %s", progress.Status) + } +} + +// TestIncrementProgress_MultipleCalls_Accumulates 计数型累加(spec §5 计数语义) +func TestIncrementProgress_MultipleCalls_Accumulates(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + def := seedTestDefinition(t, db, "test_increment_multi", 5) + defer db.Delete(def) + + progress, err := repo.GetOrCreateDailyProgress(-103, -77, def.TaskKey, def) + if err != nil { + t.Fatalf("GetOrCreate: %v", err) + } + + // 累加 3 次 + for i := 1; i <= 3; i++ { + if err := repo.IncrementProgress(progress, def); err != nil { + t.Fatalf("IncrementProgress #%d: %v", i, err) + } + if progress.Progress != i { + t.Errorf("after #%d, expected Progress=%d, got %d", i, i, progress.Progress) + } + } + + // 验证 DB 实际值(防止只在内存里 +1 没落盘) + var fromDB model.UserDailyTaskProgress + if err := db.Where("user_id = ? AND task_key = ?", -103, def.TaskKey).First(&fromDB).Error; err != nil { + t.Fatalf("query from DB: %v", err) + } + if fromDB.Progress != 3 { + t.Errorf("expected DB Progress=3, got %d", fromDB.Progress) + } +} + +func TestIncrementProgress_AlreadyCompleted_NoOp(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + def := seedTestDefinition(t, db, "test_increment_completed", 1) + defer db.Delete(def) + + progress, err := repo.GetOrCreateDailyProgress(-101, -77, def.TaskKey, def) + if err != nil { + t.Fatalf("GetOrCreate: %v", err) + } + + // 强制设置 status='completed' + if err := db.Model(progress).Update("status", "completed").Error; err != nil { + t.Fatalf("force complete: %v", err) + } + + if err := repo.IncrementProgress(progress, def); err != nil { + t.Fatalf("IncrementProgress should not error on already-completed: %v", err) + } + + // 应重新读取:progress 不变(仍 0)+ status 仍 'completed' + if progress.Progress != 0 { + t.Errorf("expected Progress unchanged=0, got %d", progress.Progress) + } + if progress.Status != "completed" { + t.Errorf("expected Status=completed, got %s", progress.Status) + } +} + +// ==================== TestResetAllDailyTasks ==================== + +func TestResetAllDailyTasks_ResetsProgress(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + def := seedTestDefinition(t, db, "test_reset", 1) + defer db.Delete(def) + + // seed 一条 completed + progress=2 的 sentinel 行 + now := time.Now().UnixMilli() + progress := &model.UserDailyTaskProgress{ + UserID: -102, + StarID: -77, + TaskKey: def.TaskKey, + Status: "completed", + CompletedAt: &now, + Progress: 2, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.Create(progress).Error; err != nil { + t.Fatalf("seed progress: %v", err) + } + + // 执行 reset + rowsAffected, err := repo.ResetAllDailyTasks() + if err != nil { + t.Fatalf("ResetAllDailyTasks: %v", err) + } + if rowsAffected == 0 { + t.Fatalf("expected some rows affected") + } + + // 重新查 sentinel 行 + var afterReset model.UserDailyTaskProgress + if err := db.Where("user_id = ? AND task_key = ?", -102, def.TaskKey).First(&afterReset).Error; err != nil { + t.Fatalf("re-fetch: %v", err) + } + if afterReset.Status != "pending" { + t.Errorf("expected Status=pending, got %s", afterReset.Status) + } + if afterReset.Progress != 0 { + t.Errorf("expected Progress=0 after reset, got %d", afterReset.Progress) + } + if afterReset.CompletedAt != nil { + t.Errorf("expected CompletedAt=nil after reset, got %v", *afterReset.CompletedAt) + } +} + +// TestResetAllDailyTasks_AlreadyPending_NoOp 验证 reset 不动 status='pending' 的行(避免误重置) +func TestResetAllDailyTasks_AlreadyPending_NoOp(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + def := seedTestDefinition(t, db, "test_reset_pending", 1) + defer db.Delete(def) + + // seed 一条 pending + progress=2 的 sentinel 行(reset 后应保持不变) + now := time.Now().UnixMilli() + pendingProgress := &model.UserDailyTaskProgress{ + UserID: -120, + StarID: -77, + TaskKey: def.TaskKey, + Status: "pending", + Progress: 2, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.Create(pendingProgress).Error; err != nil { + t.Fatalf("seed pending progress: %v", err) + } + + originalUpdatedAt := pendingProgress.UpdatedAt + + // 执行 reset + if _, err := repo.ResetAllDailyTasks(); err != nil { + t.Fatalf("ResetAllDailyTasks: %v", err) + } + + // 重新查 sentinel 行:应保持 status=pending, progress=2 + var afterReset model.UserDailyTaskProgress + if err := db.Where("user_id = ? AND task_key = ?", -120, def.TaskKey).First(&afterReset).Error; err != nil { + t.Fatalf("re-fetch: %v", err) + } + if afterReset.Status != "pending" { + t.Errorf("pending row should remain pending, got %s", afterReset.Status) + } + if afterReset.Progress != 2 { + t.Errorf("pending row's Progress should remain 2, got %d", afterReset.Progress) + } + // updated_at 不应被 reset(reset WHERE 条件排除 pending 行) + if afterReset.UpdatedAt != originalUpdatedAt { + t.Errorf("pending row's updated_at should not change (was %d, got %d)", + originalUpdatedAt, afterReset.UpdatedAt) + } +} + +// ==================== TestListActiveDailyTaskDefinitions_WithTriggerEvent ==================== + +func TestListActiveDailyTaskDefinitions_WithTriggerEvent_Filters(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + // seed 2 条:trigger_event 不同 + defA := seedTestDefinition(t, db, "test_filter_a", 1) + defer db.Delete(defA) + defB := seedTestDefinition(t, db, "test_filter_b", 1) + defer db.Delete(defB) + + // 查询 trigger_event='test_filter_a' 应只返回 defA + results, err := repo.ListActiveDailyTaskDefinitions(-77, "test_filter_a") + if err != nil { + t.Fatalf("ListActiveDailyTaskDefinitions: %v", err) + } + + foundA := false + foundB := false + for _, d := range results { + if d.TriggerEvent == "test_filter_a" { + foundA = true + } + if d.TriggerEvent == "test_filter_b" { + foundB = true + } + } + if !foundA { + t.Errorf("expected defA in results, not found") + } + if foundB { + t.Errorf("did NOT expect defB in results when filter=test_filter_a") + } +} + +func TestListActiveDailyTaskDefinitions_EmptyEventType_NoFilter(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + defA := seedTestDefinition(t, db, "test_no_filter_a", 1) + defer db.Delete(defA) + defB := seedTestDefinition(t, db, "test_no_filter_b", 1) + defer db.Delete(defB) + + // eventType="" 不应过滤 trigger_event——返回所有 active daily + results, err := repo.ListActiveDailyTaskDefinitions(-77, "") + if err != nil { + t.Fatalf("ListActiveDailyTaskDefinitions: %v", err) + } + + foundA := false + foundB := false + for _, d := range results { + if d.TriggerEvent == "test_no_filter_a" { + foundA = true + } + if d.TriggerEvent == "test_no_filter_b" { + foundB = true + } + } + if !foundA || !foundB { + t.Errorf("expected both defA and defB in no-filter results: A=%v B=%v", foundA, foundB) + } +} \ No newline at end of file diff --git a/backend/services/taskService/repository/like_bet_repo_test.go b/backend/services/taskService/repository/like_bet_repo_test.go index 5c0930f..cf51368 100644 --- a/backend/services/taskService/repository/like_bet_repo_test.go +++ b/backend/services/taskService/repository/like_bet_repo_test.go @@ -2,7 +2,6 @@ package repository import ( "os" - "strings" "testing" "time" @@ -182,10 +181,12 @@ func TestLikeBetRevenueRepository_ClaimRecord_OptimisticLock(t *testing.T) { func TestLikeBetRevenueRepository_UniqueConstraint(t *testing.T) { // 验证 (exhibition_id, like_id) 唯一约束: // - 第一次 BatchCreate 成功 - // - 第二次同样 (exhibition_id, like_id) 的 BatchCreate 应报错 - // - 报错之后 dev 库里只应有 1 条记录(因为 BatchCreate 内部用了事务,第一条失败则回滚) + // - 第二次同样 (exhibition_id, like_id) 的 BatchCreate 应**不报错**(silent skip), + // 由 BatchCreate 内部 `clause.OnConflict{DoNothing: true}` 实现幂等 + // - DB 里最终只保留 1 条记录(唯一约束生效) // - // 注:这意味着 RecordLikeBetRevenue 内部需要 tolerate 这个 error(cleanup_worker 重跑会触发)。 + // 设计意图:cleanup_worker 重跑 / 多路径触发同一 (exhibition_id, like_id) 时, + // 重复记录会被静默忽略,不影响主流程。RecordLikeBetRevenue 不需要 tolerate error。 db := setupTestDB(t) defer cleanupTestRecords(t, db) repo := NewLikeBetRevenueRepository(db) @@ -200,24 +201,20 @@ func TestLikeBetRevenueRepository_UniqueConstraint(t *testing.T) { t.Fatalf("First BatchCreate failed: %v", err) } - // 重跑相同 (exhibition_id, like_id) —— 应当返回唯一约束错误 + // 重跑相同 (exhibition_id, like_id) —— 不应报错(ON CONFLICT DO NOTHING 幂等) rec2 := &model.LikeBetRevenueRecord{ UserID: -30, StarID: 87, ExhibitionID: 90030, AssetID: 130, LikeID: 8301, BetOrder: 1, TotalLikes: 1, CrystalAmount: 1, CycleStartTime: now, CycleEndTime: now, Status: "claimable", CreatedAt: now, } - err := repo.BatchCreate([]*model.LikeBetRevenueRecord{rec2}) - if err == nil { - t.Fatal("Second BatchCreate should fail with unique constraint violation") - } - if !strings.Contains(err.Error(), "uk_like_bet_unique") { - t.Errorf("Expected uk_like_bet_unique error, got: %v", err) + if err := repo.BatchCreate([]*model.LikeBetRevenueRecord{rec2}); err != nil { + t.Fatalf("Second BatchCreate should be no-op via ON CONFLICT DO NOTHING, got error: %v", err) } - // 验证:DB 里只能保留 1 条记录 + // 验证:DB 里只能保留 1 条记录(唯一约束仍然生效) var c int64 db.Model(&model.LikeBetRevenueRecord{}).Where("exhibition_id = ? AND like_id = ?", 90030, 8301).Count(&c) if c != 1 { - t.Errorf("Expected 1 record, got %d (unique constraint broken?)", c) + t.Errorf("Expected 1 record (unique constraint enforces), got %d", c) } } \ No newline at end of file diff --git a/backend/services/taskService/service/daily_task_service_test.go b/backend/services/taskService/service/daily_task_service_test.go new file mode 100644 index 0000000..f31364b --- /dev/null +++ b/backend/services/taskService/service/daily_task_service_test.go @@ -0,0 +1,375 @@ +package service + +import ( + "testing" + "time" + + "github.com/topfans/backend/pkg/database" + "github.com/topfans/backend/services/taskService/model" + "github.com/topfans/backend/services/taskService/repository" + "gorm.io/gorm" +) + +// 注:本文件与 revenue_service_test.go 同包;如该包已有 TestMain 则复用,否则 +// 共享 repository 包里的 TestMain(init logger + setup DB)。 +// +// 本测试连接真实 dev PG(15432 / top-fans),复用 like_bet_repo_test.go 的 sentinel 模式。 + +// setupDailyTaskServiceTestDB 初始化 DB + AutoMigrate + 清掉旧 sentinel。 +func setupDailyTaskServiceTestDB(t *testing.T) (*gorm.DB, repository.DailyTaskRepository) { + config := database.Config{ + Host: "localhost", + Port: 15432, + User: "postgres", + Password: "123456", + DBName: "top-fans", + SSLMode: "disable", + TimeZone: "Asia/Shanghai", + } + if err := database.Init(config); err != nil { + t.Skipf("Skipping test: failed to connect to test database: %v", err) + } + db := database.GetDB() + if err := db.AutoMigrate( + &model.TaskDefinition{}, + &model.UserDailyTaskProgress{}, + ); err != nil { + t.Logf("Warning: AutoMigrate: %v", err) + } + // 清掉之前测试的 sentinel(star_id=-77) + db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + db.Where("user_id < ?", 0).Delete(&model.UserDailyTaskProgress{}) + return db, repository.NewDailyTaskRepository(db) +} + +// seedDef 插入一条 test daily 任务定义 +func seedDef(t *testing.T, db *gorm.DB, triggerEvent string, targetCount int) *model.TaskDefinition { + def := &model.TaskDefinition{ + StarID: nil, + TaskKey: "test_task_" + triggerEvent, + TaskType: "daily", + Name: "test daily " + triggerEvent, + CrystalReward: 10, + TriggerEvent: triggerEvent, + TargetCount: targetCount, + IsActive: true, + CreatedAt: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), + } + if err := db.Create(def).Error; err != nil { + t.Fatalf("seed def: %v", err) + } + return def +} + +// newTestDailyTaskService 不依赖 userRPCClient(ProcessTaskEvent 不需要) +func newTestDailyTaskService(repo repository.DailyTaskRepository) DailyTaskService { + return NewDailyTaskService(repo, nil) +} + +// ==================== TestProcessTaskEvent_FirstCompletion ==================== + +func TestProcessTaskEvent_FirstCompletion_TargetOne(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + + def := seedDef(t, db, "test_first_complete", 1) + defer db.Where("id = ?", def.ID).Delete(&model.TaskDefinition{}) + + svc := newTestDailyTaskService(repo) + result, err := svc.ProcessTaskEvent(t.Context(), -200, -77, "test_first_complete") + if err != nil { + t.Fatalf("ProcessTaskEvent: %v", err) + } + + if len(result.CompletedTaskKeys) != 1 { + t.Errorf("expected 1 completed, got %d: %v", len(result.CompletedTaskKeys), result.CompletedTaskKeys) + } + if result.CompletedTaskKeys[0] != def.TaskKey { + t.Errorf("expected TaskKey=%s, got %s", def.TaskKey, result.CompletedTaskKeys[0]) + } + + // 验证 DB + var progress model.UserDailyTaskProgress + if err := db.Where("user_id = ? AND task_key = ?", -200, def.TaskKey).First(&progress).Error; err != nil { + t.Fatalf("query progress: %v", err) + } + if progress.Status != "completed" { + t.Errorf("expected status=completed, got %s", progress.Status) + } + if progress.Progress != 1 { + t.Errorf("expected progress=1, got %d", progress.Progress) + } + if progress.CompletedAt == nil { + t.Errorf("expected completed_at set") + } +} + +// ==================== TestProcessTaskEvent_Counting ==================== + +func TestProcessTaskEvent_Counting_AccumulatesToTarget(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + + def := seedDef(t, db, "test_counting", 3) + defer db.Where("id = ?", def.ID).Delete(&model.TaskDefinition{}) + + svc := newTestDailyTaskService(repo) + + // 第 1 次:progress=1, status=pending + r1, err := svc.ProcessTaskEvent(t.Context(), -201, -77, "test_counting") + if err != nil { + t.Fatalf("emit 1: %v", err) + } + if len(r1.CompletedTaskKeys) != 0 { + t.Errorf("1st emit should not complete (target=3): %v", r1.CompletedTaskKeys) + } + + // 第 2 次:progress=2 + r2, err := svc.ProcessTaskEvent(t.Context(), -201, -77, "test_counting") + if err != nil { + t.Fatalf("emit 2: %v", err) + } + if len(r2.CompletedTaskKeys) != 0 { + t.Errorf("2nd emit should not complete: %v", r2.CompletedTaskKeys) + } + + // 第 3 次:progress=3, completed + r3, err := svc.ProcessTaskEvent(t.Context(), -201, -77, "test_counting") + if err != nil { + t.Fatalf("emit 3: %v", err) + } + if len(r3.CompletedTaskKeys) != 1 || r3.CompletedTaskKeys[0] != def.TaskKey { + t.Errorf("3rd emit should complete: %v", r3.CompletedTaskKeys) + } + + // 第 4 次:已 completed,no-op + r4, err := svc.ProcessTaskEvent(t.Context(), -201, -77, "test_counting") + if err != nil { + t.Fatalf("emit 4: %v", err) + } + if len(r4.CompletedTaskKeys) != 0 { + t.Errorf("4th emit should be no-op (already completed): %v", r4.CompletedTaskKeys) + } + + // 验证最终 progress=3 (没有变成 4) + var progress model.UserDailyTaskProgress + db.Where("user_id = ? AND task_key = ?", -201, def.TaskKey).First(&progress) + if progress.Progress != 3 { + t.Errorf("expected Progress=3 after 4 emits (3rd should complete, 4th no-op), got %d", progress.Progress) + } +} + +// ==================== TestProcessTaskEvent_AlreadyCompleted ==================== + +func TestProcessTaskEvent_AlreadyCompleted_Skipped(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + + def := seedDef(t, db, "test_already_completed", 1) + defer db.Where("id = ?", def.ID).Delete(&model.TaskDefinition{}) + + svc := newTestDailyTaskService(repo) + + // 第一次:complete + _, err := svc.ProcessTaskEvent(t.Context(), -202, -77, "test_already_completed") + if err != nil { + t.Fatalf("first emit: %v", err) + } + + // 第二次:已 completed → 跳过 + r2, err := svc.ProcessTaskEvent(t.Context(), -202, -77, "test_already_completed") + if err != nil { + t.Fatalf("second emit: %v", err) + } + if len(r2.CompletedTaskKeys) != 0 { + t.Errorf("expected 0 completed (already completed), got %v", r2.CompletedTaskKeys) + } +} + +// ==================== TestProcessTaskEvent_UnknownEvent ==================== + +func TestProcessTaskEvent_UnknownEvent_NoOp(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + + svc := newTestDailyTaskService(repo) + + result, err := svc.ProcessTaskEvent(t.Context(), -203, -77, "test_unknown_event_xyz") + if err != nil { + t.Fatalf("unexpected error for unknown event: %v", err) + } + if len(result.CompletedTaskKeys) != 0 { + t.Errorf("expected 0 completed, got %v", result.CompletedTaskKeys) + } + // 不应创建 progress 行 + var count int64 + db.Model(&model.UserDailyTaskProgress{}).Where("user_id = ?", -203).Count(&count) + if count != 0 { + t.Errorf("expected no progress rows for unknown event, got %d", count) + } +} + +// ==================== TestProcessTaskEvent_GlobalAndStarSpecific ==================== + +func TestProcessTaskEvent_GlobalAndStarSpecific_BothIncrement(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ? OR star_id IS NULL", -77).Delete(&model.TaskDefinition{}) + defer db.Where("user_id = ?", -204).Delete(&model.UserDailyTaskProgress{}) + + now := time.Now().UnixMilli() + starID := int64(87) + + // 全局默认任务(star_id=NULL) + globalDef := &model.TaskDefinition{ + TaskKey: "test_global_both", TaskType: "daily", Name: "global", + CrystalReward: 5, TriggerEvent: "test_both_event", TargetCount: 1, + IsActive: true, StarID: nil, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(globalDef).Error; err != nil { + t.Fatalf("seed global: %v", err) + } + defer db.Where("id = ?", globalDef.ID).Delete(&model.TaskDefinition{}) + + // star 专属任务(star_id=87) + starDef := &model.TaskDefinition{ + TaskKey: "test_star_both", TaskType: "daily", Name: "star specific", + CrystalReward: 5, TriggerEvent: "test_both_event", TargetCount: 1, + IsActive: true, StarID: &starID, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(starDef).Error; err != nil { + t.Fatalf("seed star: %v", err) + } + defer db.Where("id = ?", starDef.ID).Delete(&model.TaskDefinition{}) + + svc := newTestDailyTaskService(repo) + result, err := svc.ProcessTaskEvent(t.Context(), -204, starID, "test_both_event") + if err != nil { + t.Fatalf("ProcessTaskEvent: %v", err) + } + + if len(result.CompletedTaskKeys) != 2 { + t.Errorf("expected 2 completed (global + star specific), got %d: %v", + len(result.CompletedTaskKeys), result.CompletedTaskKeys) + } + + // 验证 DB:两条 progress 都应该是 completed + var globalProgress, starProgress model.UserDailyTaskProgress + db.Where("user_id = ? AND task_key = ?", -204, globalDef.TaskKey).First(&globalProgress) + db.Where("user_id = ? AND task_key = ?", -204, starDef.TaskKey).First(&starProgress) + if globalProgress.Status != "completed" { + t.Errorf("global task not completed: %s", globalProgress.Status) + } + if starProgress.Status != "completed" { + t.Errorf("star task not completed: %s", starProgress.Status) + } +} + +// TestReportEvent_UnknownEvent_ReturnsNoTaskCompleted 验证 ReportEvent 在没有匹配任务时 +// 返回 Success=true(RPC 不报错)但 TaskCompleted=false(前端无奖励提示) +func TestReportEvent_UnknownEvent_ReturnsNoTaskCompleted(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + defer db.Where("user_id = ?", -206).Delete(&model.UserDailyTaskProgress{}) + + svc := newTestDailyTaskService(repo) + resp, err := svc.ReportEvent(t.Context(), -206, -77, "test_unknown_event") + if err != nil { + t.Fatalf("ReportEvent: %v", err) + } + if !resp.Success { + t.Errorf("expected Success=true (no error), got false") + } + if resp.TaskCompleted { + t.Errorf("expected TaskCompleted=false (no matching task), got true") + } + if resp.TaskKey != "" { + t.Errorf("expected TaskKey=empty, got %s", resp.TaskKey) + } +} + +// TestReportEvent_DelegatesToProcessTaskEvent 验证 ReportEvent 委托给 ProcessTaskEvent +// 并从 TaskEventResult 回填 TaskCompleted / TaskKey +func TestReportEvent_DelegatesToProcessTaskEvent(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + defer db.Where("user_id = ?", -205).Delete(&model.UserDailyTaskProgress{}) + + def := seedDef(t, db, "test_report_event", 1) + defer db.Where("id = ?", def.ID).Delete(&model.TaskDefinition{}) + + svc := newTestDailyTaskService(repo) + resp, err := svc.ReportEvent(t.Context(), -205, -77, "test_report_event") + if err != nil { + t.Fatalf("ReportEvent: %v", err) + } + if !resp.TaskCompleted { + t.Errorf("expected TaskCompleted=true, got false (resp=%+v)", resp) + } + if resp.TaskKey != def.TaskKey { + t.Errorf("expected TaskKey=%s, got %s", def.TaskKey, resp.TaskKey) + } +} + +// ==================== TestProcessTaskEvent_ErrorIsolation ==================== + +// TestProcessTaskEvent_SingleFailure_DoesNotBlockOthers +// 验证 spec §8:"引擎内单条任务更新失败不影响其他任务(逐个处理,记 error 日志)" +// +// 构造:两条 task_definitions trigger_event 相同(但 task_key 不同)。 +// 第一条预先设 status='claimed' → IncrementProgress 行 0 影响(不会 +1)。 +// 第二条保持 pending。 +// 期望:第二条应该正常 +1 完成;CompletedTaskKeys 应只包含第二条 task_key。 +func TestProcessTaskEvent_SingleFailure_DoesNotBlockOthers(t *testing.T) { + db, repo := setupDailyTaskServiceTestDB(t) + defer db.Where("star_id = ?", -77).Delete(&model.TaskDefinition{}) + defer db.Where("user_id = ?", -207).Delete(&model.UserDailyTaskProgress{}) + + // seed 2 条相同 trigger_event 但 task_key 不同的定义 + defA := seedDef(t, db, "test_isolation_event", 1) + defer db.Where("id = ?", defA.ID).Delete(&model.TaskDefinition{}) + now := time.Now().UnixMilli() + defB := &model.TaskDefinition{ + TaskKey: "test_taskB_isolation", TaskType: "daily", Name: "taskB", + CrystalReward: 5, TriggerEvent: "test_isolation_event", TargetCount: 1, + IsActive: true, StarID: nil, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(defB).Error; err != nil { + t.Fatalf("seed defB: %v", err) + } + defer db.Where("id = ?", defB.ID).Delete(&model.TaskDefinition{}) + + // 预先创建 defA 的 progress 行并强制 status='claimed'(模拟已领取,IncrementProgress 应被跳过) + preProgress := &model.UserDailyTaskProgress{ + UserID: -207, StarID: -77, TaskKey: defA.TaskKey, + Status: "claimed", Progress: 1, + CreatedAt: now, UpdatedAt: now, + ClaimedAt: &now, + } + if err := db.Create(preProgress).Error; err != nil { + t.Fatalf("seed preProgress: %v", err) + } + defer db.Where("user_id = ?", -207).Delete(&model.UserDailyTaskProgress{}) + + svc := newTestDailyTaskService(repo) + result, err := svc.ProcessTaskEvent(t.Context(), -207, -77, "test_isolation_event") + if err != nil { + t.Fatalf("ProcessTaskEvent should not error (defA failure isolated): %v", err) + } + + // 只应 completed 1 条(defB),defA 应被 skip + if len(result.CompletedTaskKeys) != 1 { + t.Fatalf("expected 1 completed (defB only), got %d: %v", + len(result.CompletedTaskKeys), result.CompletedTaskKeys) + } + if result.CompletedTaskKeys[0] != defB.TaskKey { + t.Errorf("expected completed taskKey=%s, got %s", defB.TaskKey, result.CompletedTaskKeys[0]) + } + + // 验证 defA 仍保持 claimed 状态(未被错误重置) + var defAProgress model.UserDailyTaskProgress + db.Where("user_id = ? AND task_key = ?", -207, defA.TaskKey).First(&defAProgress) + if defAProgress.Status != "claimed" { + t.Errorf("defA should still be claimed, got %s", defAProgress.Status) + } +} \ No newline at end of file