test(task): consumer + daily_task repo/service + like_bet ON CONFLICT 修订

Phase F.6 测试覆盖:

- mq/consumer_test.go: MQ consumer 单元测试,用 fakeDailyTaskService 模拟
  ProcessTaskEvent(newHandleTaskEvent 唯一调用点)
- repository/daily_task_repo_test.go: DailyTaskRepository CRUD 单测
  (复用 like_bet_repo_test.go 的 TestMain + dev PG 15432 sentinel 模式)
- service/daily_task_service_test.go: DailyTaskService.ProcessTaskEvent 核心
  完成判定逻辑单测
- repository/like_bet_repo_test.go: UniqueConstraint 测试语义修订 —
  BatchCreate 内部用 clause.OnConflict{DoNothing: true} 实现幂等,
  重复 (exhibition_id, like_id) 不再报错,DB 仍保留 1 条记录

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zerosaturation 2026-07-27 15:46:29 +08:00
parent a9ce281d5e
commit cc7794e0f4
4 changed files with 897 additions and 13 deletions

View File

@ -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 初始化 loggerhandler 错误路径调 logger.Logger.Errornil 会 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 接口,
// 只模拟 ProcessTaskEventnewHandleTaskEvent 唯一调用的方法)。
// 其他方法返回零值,因为 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 errspec §5 让 Asynq 重试)
_ = err // err 已经是 processErrhandler 直接透传)
}
}
// ==================== 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")
}
}

View File

@ -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_<rand>"
// 返回定义 + 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 不应被 resetreset 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)
}
}

View File

@ -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 这个 errorcleanup_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)
}
}

View File

@ -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 包里的 TestMaininit logger + setup DB
//
// 本测试连接真实 dev PG15432 / 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)
}
// 清掉之前测试的 sentinelstar_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 不依赖 userRPCClientProcessTaskEvent 不需要)
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 次:已 completedno-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=trueRPC 不报错)但 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 条defBdefA 应被 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)
}
}