topfans/backend/services/taskService/service/daily_task_service_test.go
zerosaturation cc7794e0f4 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>
2026-07-27 15:46:29 +08:00

375 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}