From 5e8ed8883217dc4d6b10255572003c9844f448a2 Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Sat, 13 Sep 2025 17:38:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0UpdatePlan=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/infra/repository/plan_repository.go | 14 +- .../infra/repository/plan_repository_test.go | 397 ++++++++++++++++++ 2 files changed, 408 insertions(+), 3 deletions(-) diff --git a/internal/infra/repository/plan_repository.go b/internal/infra/repository/plan_repository.go index 2de5b1b..8667338 100644 --- a/internal/infra/repository/plan_repository.go +++ b/internal/infra/repository/plan_repository.go @@ -1,12 +1,20 @@ package repository import ( + "errors" "fmt" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "gorm.io/gorm" ) +// 定义仓库层可导出的公共错误 +var ( + ErrUpdateWithInvalidRoot = errors.New("更新操作的目标根计划无效或ID为0") + ErrNewSubPlanInUpdate = errors.New("计划树中包含一个ID为0的新子计划,更新操作只允许关联已存在的计划") + ErrNodeDoesNotExist = errors.New("计划树中包含一个或多个在数据库中不存在的计划") +) + // PlanRepository 定义了与计划模型相关的数据库操作接口 // 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现 type PlanRepository interface { @@ -118,7 +126,7 @@ func (r *gormPlanRepository) updatePlanTx(tx *gorm.DB, plan *models.Plan) error func (r *gormPlanRepository) validatePlanTree(tx *gorm.DB, plan *models.Plan) error { // 1. 检查根节点 if plan == nil || plan.ID == 0 { - return fmt.Errorf("更新操作的目标计划无效或ID为0") + return ErrUpdateWithInvalidRoot } if len(plan.Tasks) > 0 && len(plan.SubPlans) > 0 { return fmt.Errorf("计划 (ID: %d) 不能同时包含任务和子计划", plan.ID) @@ -143,7 +151,7 @@ func (r *gormPlanRepository) validatePlanTree(tx *gorm.DB, plan *models.Plan) er return fmt.Errorf("检查计划存在性时出错: %w", err) } if int(count) != len(idsToCheck) { - return fmt.Errorf("计划树中包含一个或多个在数据库中不存在的计划") + return ErrNodeDoesNotExist } } @@ -156,7 +164,7 @@ func validateNodeAndDetectCycles(plan *models.Plan, allIDs, recursionStack map[u return nil } if plan.ID == 0 { - return fmt.Errorf("错误:计划树中包含一个ID为0的新计划,更新操作只允许操作已存在的计划") + return ErrNewSubPlanInUpdate } if recursionStack[plan.ID] { return fmt.Errorf("检测到循环引用:计划 (ID: %d) 是其自身的祖先", plan.ID) diff --git a/internal/infra/repository/plan_repository_test.go b/internal/infra/repository/plan_repository_test.go index 91c6751..c846462 100644 --- a/internal/infra/repository/plan_repository_test.go +++ b/internal/infra/repository/plan_repository_test.go @@ -398,3 +398,400 @@ func cleanPlanForComparison(p *models.Plan) { cleanPlanForComparison(p.SubPlans[i].ChildPlan) } } + +// TestUpdatePlan_Validation 专注于测试 UpdatePlan 中前置检查逻辑的各种失败和成功场景。 +func TestUpdatePlan_Validation(t *testing.T) { + // 定义Go测试中使用的计划实体 + planA := &models.Plan{Model: gorm.Model{ID: 1}, Name: "Plan A", ContentType: models.PlanContentTypeSubPlans} + planB := &models.Plan{Model: gorm.Model{ID: 2}, Name: "Plan B", ContentType: models.PlanContentTypeSubPlans} + planC := &models.Plan{Model: gorm.Model{ID: 3}, Name: "Plan C", ContentType: models.PlanContentTypeSubPlans} + planD := &models.Plan{Model: gorm.Model{ID: 4}, Name: "Plan D", ContentType: models.PlanContentTypeTasks} + planNew := &models.Plan{Model: gorm.Model{ID: 0}, Name: "New Plan"} // ID为0的新计划 + + type testCase struct { + name string + setupDB func(db *gorm.DB) + buildInput func() *models.Plan // 修改为构建函数 + expectedError string // 保持 string 类型 + } + + testCases := []testCase{ + { + name: "成功-合法的菱形依赖树", + setupDB: func(db *gorm.DB) { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 3}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 4}}) + }, + buildInput: func() *models.Plan { + planD.ContentType = models.PlanContentTypeTasks + planB.ContentType = models.PlanContentTypeSubPlans + planB.SubPlans = []models.SubPlan{{ChildPlanID: 4, ChildPlan: planD}} + planC.ContentType = models.PlanContentTypeSubPlans + planC.SubPlans = []models.SubPlan{{ChildPlanID: 4, ChildPlan: planD}} + planA.ContentType = models.PlanContentTypeSubPlans + planA.SubPlans = []models.SubPlan{ + {ChildPlanID: 2, ChildPlan: planB}, + {ChildPlanID: 3, ChildPlan: planC}, + } + return planA + }, + expectedError: "", // 期望没有错误 + }, + { + name: "错误-根节点ID为零", + setupDB: func(db *gorm.DB) {}, + buildInput: func() *models.Plan { return planNew }, + expectedError: repository.ErrUpdateWithInvalidRoot.Error(), + }, + { + name: "错误-子计划ID为零", + setupDB: func(db *gorm.DB) { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}}) + }, + buildInput: func() *models.Plan { + planA.ContentType = models.PlanContentTypeSubPlans + planA.SubPlans = []models.SubPlan{{ChildPlan: planNew}} + return planA + }, + expectedError: repository.ErrNewSubPlanInUpdate.Error(), + }, + { + name: "错误-节点在数据库中不存在", + setupDB: func(db *gorm.DB) { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}}) + }, + buildInput: func() *models.Plan { + planA.ContentType = models.PlanContentTypeSubPlans + planA.SubPlans = []models.SubPlan{ + {ChildPlanID: 2, ChildPlan: planB}, + {ChildPlanID: 3, ChildPlan: planC}, // C 不存在 + } + return planA + }, + expectedError: repository.ErrNodeDoesNotExist.Error(), + }, + { + name: "错误-简单循环引用(A->B->A)", + setupDB: func(db *gorm.DB) { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}}) + }, + buildInput: func() *models.Plan { + planA.ContentType = models.PlanContentTypeSubPlans + planA.SubPlans = []models.SubPlan{{ChildPlanID: 2, ChildPlan: planB}} + planB.ContentType = models.PlanContentTypeSubPlans + planB.SubPlans = []models.SubPlan{{ChildPlanID: 1, ChildPlan: planA}} + return planA + }, + expectedError: "检测到循环引用:计划 (ID: 1)", + }, + { + name: "错误-复杂循环引用(A->B->C->A)", + setupDB: func(db *gorm.DB) { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 3}}) + }, + buildInput: func() *models.Plan { + planA.ContentType = models.PlanContentTypeSubPlans + planA.SubPlans = []models.SubPlan{{ChildPlanID: 2, ChildPlan: planB}} + planB.ContentType = models.PlanContentTypeSubPlans + planB.SubPlans = []models.SubPlan{{ChildPlanID: 3, ChildPlan: planC}} + planC.ContentType = models.PlanContentTypeSubPlans + planC.SubPlans = []models.SubPlan{{ChildPlanID: 1, ChildPlan: planA}} + return planA + }, + expectedError: "检测到循环引用:计划 (ID: 1)", + }, + { + name: "错误-自引用(A->A)", + setupDB: func(db *gorm.DB) { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}}) + }, + buildInput: func() *models.Plan { + planA.ContentType = models.PlanContentTypeSubPlans + planA.SubPlans = []models.SubPlan{{ChildPlanID: 1, ChildPlan: planA}} + return planA + }, + expectedError: "检测到循环引用:计划 (ID: 1)", + }, + { + name: "错误-根节点内容混合", + setupDB: func(db *gorm.DB) { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}}) + }, + buildInput: func() *models.Plan { + planA.ContentType = models.PlanContentTypeSubPlans + planA.SubPlans = []models.SubPlan{{ChildPlanID: 2, ChildPlan: planB}} + planA.Tasks = []models.Task{{Name: "A's Task"}} + return planA + }, + expectedError: "不能同时包含任务和子计划", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // 1. 为每个测试用例重置基础对象的状态 + *planA = models.Plan{Model: gorm.Model{ID: 1}, Name: "Plan A"} + *planB = models.Plan{Model: gorm.Model{ID: 2}, Name: "Plan B"} + *planC = models.Plan{Model: gorm.Model{ID: 3}, Name: "Plan C"} + *planD = models.Plan{Model: gorm.Model{ID: 4}, Name: "Plan D"} + *planNew = models.Plan{Model: gorm.Model{ID: 0}, Name: "New Plan"} + + // 2. 设置数据库 + db := setupTestDB(t) + sqlDB, _ := db.DB() + defer sqlDB.Close() + tc.setupDB(db) + + // 3. 在对象重置后,构建本次测试需要的输入结构 + input := tc.buildInput() + + // 4. 执行测试 + repo := repository.NewGormPlanRepository(db) + err := repo.UpdatePlan(input) + + // 5. 断言结果 + if tc.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestUpdatePlan_Reconciliation 专注于测试 UpdatePlan 成功执行后,数据库状态是否与预期一致。 +func TestUpdatePlan_Reconciliation(t *testing.T) { + type testCase struct { + name string + setupDB func(db *gorm.DB) (rootPlanID uint) + buildInput func(db *gorm.DB) *models.Plan + verifyDB func(t *testing.T, db *gorm.DB, rootPlanID uint) + } + + testCases := []testCase{ + { + name: "任务协调-新增一个任务", + setupDB: func(db *gorm.DB) uint { + plan := models.Plan{Model: gorm.Model{ID: 1}, Name: "Plan With Tasks", ContentType: models.PlanContentTypeTasks} + db.Create(&plan) + db.Create(&models.Task{PlanID: 1, Name: "Task 1", ExecutionOrder: 1}) + return 1 + }, + buildInput: func(db *gorm.DB) *models.Plan { + return &models.Plan{ + Model: gorm.Model{ID: 1}, + Name: "Plan With Tasks", + ContentType: models.PlanContentTypeTasks, + Tasks: []models.Task{ + {Model: gorm.Model{ID: 1}, PlanID: 1, Name: "Task 1", ExecutionOrder: 1}, + {Name: "New Task 2", ExecutionOrder: 2}, + }, + } + }, + verifyDB: func(t *testing.T, db *gorm.DB, rootPlanID uint) { + var finalPlan models.Plan + db.Preload("Tasks").First(&finalPlan, rootPlanID) + assert.Len(t, finalPlan.Tasks, 2) + assert.Equal(t, "New Task 2", finalPlan.Tasks[1].Name) + }, + }, + { + name: "任务协调-删除一个任务", + setupDB: func(db *gorm.DB) uint { + plan := models.Plan{Model: gorm.Model{ID: 1}, Name: "Plan With Tasks", ContentType: models.PlanContentTypeTasks} + db.Create(&plan) + db.Create(&models.Task{Model: gorm.Model{ID: 10}, PlanID: 1, Name: "Task 1", ExecutionOrder: 1}) + db.Create(&models.Task{Model: gorm.Model{ID: 11}, PlanID: 1, Name: "Task to Delete", ExecutionOrder: 2}) + return 1 + }, + buildInput: func(db *gorm.DB) *models.Plan { + return &models.Plan{ + Model: gorm.Model{ID: 1}, + Name: "Plan With Tasks", + ContentType: models.PlanContentTypeTasks, + Tasks: []models.Task{ + {Model: gorm.Model{ID: 10}, PlanID: 1, Name: "Task 1", ExecutionOrder: 1}, + }, + } + }, + verifyDB: func(t *testing.T, db *gorm.DB, rootPlanID uint) { + var tasks []models.Task + db.Where("plan_id = ?", rootPlanID).Find(&tasks) + assert.Len(t, tasks, 1) + var count int64 + db.Model(&models.Task{}).Where("id = ?", 11).Count(&count) + assert.Equal(t, int64(0), count, "被删除的任务不应再存在") + }, + }, + { + name: "任务协调-更新并重排序任务", + setupDB: func(db *gorm.DB) uint { + plan := models.Plan{Model: gorm.Model{ID: 1}, Name: "Plan", ContentType: models.PlanContentTypeTasks} + db.Create(&plan) + db.Create(&models.Task{Model: gorm.Model{ID: 10}, PlanID: 1, Name: "A", ExecutionOrder: 1}) + db.Create(&models.Task{Model: gorm.Model{ID: 11}, PlanID: 1, Name: "B", ExecutionOrder: 2}) + return 1 + }, + buildInput: func(db *gorm.DB) *models.Plan { + return &models.Plan{ + Model: gorm.Model{ID: 1}, + Name: "Plan", + ContentType: models.PlanContentTypeTasks, + Tasks: []models.Task{ + {Model: gorm.Model{ID: 11}, PlanID: 1, Name: "B Updated", ExecutionOrder: 1}, + {Model: gorm.Model{ID: 10}, PlanID: 1, Name: "A", ExecutionOrder: 2}, + }, + } + }, + verifyDB: func(t *testing.T, db *gorm.DB, rootPlanID uint) { + var finalPlan models.Plan + db.Preload("Tasks", func(db *gorm.DB) *gorm.DB { + return db.Order("execution_order") + }).First(&finalPlan, rootPlanID) + assert.Len(t, finalPlan.Tasks, 2) + assert.Equal(t, "B Updated", finalPlan.Tasks[0].Name) + assert.Equal(t, uint(11), finalPlan.Tasks[0].ID) + }, + }, + { + name: "子计划协调-新增一个关联", + setupDB: func(db *gorm.DB) uint { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}, Name: "Parent", ContentType: models.PlanContentTypeSubPlans}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}, Name: "Existing Child"}) + return 1 + }, + buildInput: func(db *gorm.DB) *models.Plan { + return &models.Plan{ + Model: gorm.Model{ID: 1}, + Name: "Parent", + ContentType: models.PlanContentTypeSubPlans, + SubPlans: []models.SubPlan{{ChildPlanID: 2}}, + } + }, + verifyDB: func(t *testing.T, db *gorm.DB, rootPlanID uint) { + var links []models.SubPlan + db.Where("parent_plan_id = ?", rootPlanID).Find(&links) + assert.Len(t, links, 1) + assert.Equal(t, uint(2), links[0].ChildPlanID) + }, + }, + { + name: "子计划协调-删除一个关联", + setupDB: func(db *gorm.DB) uint { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}, Name: "Parent", ContentType: models.PlanContentTypeSubPlans}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}, Name: "Child To Unlink"}) + db.Create(&models.SubPlan{ParentPlanID: 1, ChildPlanID: 2}) + return 1 + }, + buildInput: func(db *gorm.DB) *models.Plan { + return &models.Plan{ + Model: gorm.Model{ID: 1}, + Name: "Parent", + ContentType: models.PlanContentTypeSubPlans, + SubPlans: []models.SubPlan{}, + } + }, + verifyDB: func(t *testing.T, db *gorm.DB, rootPlanID uint) { + var linkCount int64 + db.Model(&models.SubPlan{}).Where("parent_plan_id = ?", rootPlanID).Count(&linkCount) + assert.Equal(t, int64(0), linkCount) + + var planCount int64 + db.Model(&models.Plan{}).Where("id = ?", 2).Count(&planCount) + assert.Equal(t, int64(1), planCount, "子计划本身不应被删除") + }, + }, + { + name: "类型转换-从任务切换到子计划", + setupDB: func(db *gorm.DB) uint { + plan := models.Plan{Model: gorm.Model{ID: 1}, Name: "Plan", ContentType: models.PlanContentTypeTasks} + db.Create(&plan) + db.Create(&models.Task{PlanID: 1, Name: "Old Task"}) + db.Create(&models.Plan{Model: gorm.Model{ID: 10}, Name: "New Child"}) + return 1 + }, + buildInput: func(db *gorm.DB) *models.Plan { + return &models.Plan{ + Model: gorm.Model{ID: 1}, + Name: "Plan", + ContentType: models.PlanContentTypeSubPlans, + SubPlans: []models.SubPlan{{ChildPlanID: 10}}, + } + }, + verifyDB: func(t *testing.T, db *gorm.DB, rootPlanID uint) { + var taskCount int64 + db.Model(&models.Task{}).Where("plan_id = ?", rootPlanID).Count(&taskCount) + assert.Equal(t, int64(0), taskCount, "旧任务应被清理") + + var linkCount int64 + db.Model(&models.SubPlan{}).Where("parent_plan_id = ?", rootPlanID).Count(&linkCount) + assert.Equal(t, int64(1), linkCount, "新关联应被创建") + }, + }, + { + name: "递归更新-深层节点的变更", + setupDB: func(db *gorm.DB) uint { + db.Create(&models.Plan{Model: gorm.Model{ID: 1}, Name: "A", ContentType: models.PlanContentTypeSubPlans}) + db.Create(&models.Plan{Model: gorm.Model{ID: 2}, Name: "B", ContentType: models.PlanContentTypeSubPlans}) + db.Create(&models.Plan{Model: gorm.Model{ID: 3}, Name: "C", ContentType: models.PlanContentTypeTasks}) + db.Create(&models.SubPlan{Model: gorm.Model{ID: 101}, ParentPlanID: 1, ChildPlanID: 2, ExecutionOrder: 1}) + db.Create(&models.SubPlan{Model: gorm.Model{ID: 102}, ParentPlanID: 2, ChildPlanID: 3, ExecutionOrder: 1}) + return 1 + }, + buildInput: func(db *gorm.DB) *models.Plan { + planC := &models.Plan{Model: gorm.Model{ID: 3}, Name: "C Updated", ContentType: models.PlanContentTypeTasks} + planB := &models.Plan{ + Model: gorm.Model{ID: 2}, + Name: "B", + ContentType: models.PlanContentTypeSubPlans, + SubPlans: []models.SubPlan{{Model: gorm.Model{ID: 102}, ParentPlanID: 2, ChildPlanID: 3, ChildPlan: planC, ExecutionOrder: 2}}, + } + planA := &models.Plan{ + Model: gorm.Model{ID: 1}, + Name: "A Updated", + ContentType: models.PlanContentTypeSubPlans, + SubPlans: []models.SubPlan{{Model: gorm.Model{ID: 101}, ParentPlanID: 1, ChildPlanID: 2, ChildPlan: planB, ExecutionOrder: 1}}, + } + return planA + }, + verifyDB: func(t *testing.T, db *gorm.DB, rootPlanID uint) { + var finalA, finalC models.Plan + var finalLinkB models.SubPlan + + db.First(&finalA, 1) + assert.Equal(t, "A Updated", finalA.Name) + + db.First(&finalLinkB, 102) + assert.Equal(t, 2, finalLinkB.ExecutionOrder) + + db.First(&finalC, 3) + assert.Equal(t, "C Updated", finalC.Name) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + db := setupTestDB(t) + sqlDB, _ := db.DB() + defer sqlDB.Close() + + rootID := tc.setupDB(db) + input := tc.buildInput(db) + + repo := repository.NewGormPlanRepository(db) + err := repo.UpdatePlan(input) + assert.NoError(t, err) + + tc.verifyDB(t, db, rootID) + }) + } +}