package dept import ( "context" "errors" "fmt" "math" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "perms-system-server/internal/consts" deptModel "perms-system-server/internal/model/dept" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/ctxhelper" "perms-system-server/internal/types" "sync" "sync/atomic" "testing" "time" ) func insertDeptRaw(ctx context.Context, svcCtx *svc.ServiceContext, parentId int64, name, path string) (int64, error) { now := time.Now().Unix() result, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: parentId, Name: name, Path: path, Sort: 0, DeptType: "NORMAL", Status: 1, CreateTime: now, UpdateTime: now, }) if err != nil { return 0, err } id, _ := result.LastInsertId() d, err := svcCtx.SysDeptModel.FindOne(ctx, id) if err != nil { return id, err } d.Path = fmt.Sprintf("%s%d/", path, id) d.UpdateTime = now return id, svcCtx.SysDeptModel.Update(ctx, d) } // TC-0093: 父部门不存在 func TestCreateDept_ParentNotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) l := NewCreateDeptLogic(ctx, svcCtx) resp, err := l.CreateDept(&types.CreateDeptReq{ ParentId: 999999999, Name: "orphan_" + testutil.UniqueId(), }) assert.Nil(t, resp) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 404, ce.Code()) assert.Contains(t, ce.Error(), "父部门不存在") } // TC-0091: 创建顶级部门 func TestCreateDept_TopLevel_ViaRawInsert(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() name := "dept_top_" + testutil.UniqueId() id, err := insertDeptRaw(ctx, svcCtx, 0, name, "/") require.NoError(t, err) require.Greater(t, id, int64(0)) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", id) }) dept, err := svcCtx.SysDeptModel.FindOne(ctx, id) require.NoError(t, err) assert.Equal(t, name, dept.Name) assert.Equal(t, int64(0), dept.ParentId) assert.Equal(t, fmt.Sprintf("/%d/", id), dept.Path) assert.Equal(t, int64(1), dept.Status) } // TC-0092: 创建子部门 func TestCreateDept_Child_ViaRawInsert(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() parentId, err := insertDeptRaw(ctx, svcCtx, 0, "par_"+testutil.UniqueId(), "/") require.NoError(t, err) parentDept, _ := svcCtx.SysDeptModel.FindOne(ctx, parentId) childId, err := insertDeptRaw(ctx, svcCtx, parentId, "child_"+testutil.UniqueId(), parentDept.Path) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", childId, parentId) }) child, err := svcCtx.SysDeptModel.FindOne(ctx, childId) require.NoError(t, err) assert.Equal(t, parentId, child.ParentId) assert.Equal(t, fmt.Sprintf("/%d/%d/", parentId, childId), child.Path) } // TC-0099: 多层嵌套(5层) func TestCreateDept_MultiLevel(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() l1Id, err := insertDeptRaw(ctx, svcCtx, 0, "L1_"+testutil.UniqueId(), "/") require.NoError(t, err) l1, _ := svcCtx.SysDeptModel.FindOne(ctx, l1Id) l2Id, err := insertDeptRaw(ctx, svcCtx, l1Id, "L2_"+testutil.UniqueId(), l1.Path) require.NoError(t, err) l2, _ := svcCtx.SysDeptModel.FindOne(ctx, l2Id) l3Id, err := insertDeptRaw(ctx, svcCtx, l2Id, "L3_"+testutil.UniqueId(), l2.Path) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", l3Id, l2Id, l1Id) }) d3, err := svcCtx.SysDeptModel.FindOne(ctx, l3Id) require.NoError(t, err) assert.Equal(t, fmt.Sprintf("/%d/%d/%d/", l1Id, l2Id, l3Id), d3.Path) } // TC-0099: 多层嵌套(5层) func TestCreateDept_FiveLevelNesting(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() var ids []int64 parentId := int64(0) parentPath := "/" for level := 1; level <= 5; level++ { id, err := insertDeptRaw(ctx, svcCtx, parentId, fmt.Sprintf("L%d_%s", level, testutil.UniqueId()), parentPath) require.NoError(t, err) ids = append(ids, id) dept, err := svcCtx.SysDeptModel.FindOne(ctx, id) require.NoError(t, err) parentId = id parentPath = dept.Path } t.Cleanup(func() { for i := len(ids) - 1; i >= 0; i-- { testutil.CleanTable(ctx, conn, "`sys_dept`", ids[i]) } }) deepest, err := svcCtx.SysDeptModel.FindOne(ctx, ids[4]) require.NoError(t, err) expected := fmt.Sprintf("/%d/%d/%d/%d/%d/", ids[0], ids[1], ids[2], ids[3], ids[4]) assert.Equal(t, expected, deepest.Path) } // TC-0094: 不传DeptType默认NORMAL func TestCreateDept_DefaultDeptType(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() l := NewCreateDeptLogic(ctx, svcCtx) resp, err := l.CreateDept(&types.CreateDeptReq{ ParentId: 0, Name: "deftype_" + testutil.UniqueId(), }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", resp.Id) }) d, err := svcCtx.SysDeptModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, "NORMAL", d.DeptType) assert.Contains(t, d.Path, fmt.Sprintf("/%d/", resp.Id)) } // TC-0095: 传DeptType=DEV func TestCreateDept_DevDeptType(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() l := NewCreateDeptLogic(ctx, svcCtx) resp, err := l.CreateDept(&types.CreateDeptReq{ ParentId: 0, Name: "devtype_" + testutil.UniqueId(), DeptType: "DEV", }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", resp.Id) }) d, err := svcCtx.SysDeptModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, "DEV", d.DeptType) assert.Contains(t, d.Path, fmt.Sprintf("/%d/", resp.Id)) } // TC-0100: 通过Logic创建+验证Path func TestCreateDept_ViaLogic_PathCorrect(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() l := NewCreateDeptLogic(ctx, svcCtx) parentResp, err := l.CreateDept(&types.CreateDeptReq{ ParentId: 0, Name: "parent_" + testutil.UniqueId(), }) require.NoError(t, err) childResp, err := l.CreateDept(&types.CreateDeptReq{ ParentId: parentResp.Id, Name: "child_" + testutil.UniqueId(), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", childResp.Id, parentResp.Id) }) parent, err := svcCtx.SysDeptModel.FindOne(ctx, parentResp.Id) require.NoError(t, err) assert.Equal(t, fmt.Sprintf("/%d/", parentResp.Id), parent.Path) child, err := svcCtx.SysDeptModel.FindOne(ctx, childResp.Id) require.NoError(t, err) assert.Equal(t, fmt.Sprintf("/%d/%d/", parentResp.Id, childResp.Id), child.Path) } // TC-0532: createDept非超管拒绝 func TestCreateDept_NonSuperAdminRejected(t *testing.T) { ctx := ctxhelper.AdminCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) l := NewCreateDeptLogic(ctx, svcCtx) _, err := l.CreateDept(&types.CreateDeptReq{Name: "test", Sort: 1}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } var _ = deptModel.ErrNotFound func insertDeptWithStatus(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, name, path string, status int64) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: 0, Name: name + "_" + testutil.UniqueId(), Path: path, Sort: 0, DeptType: "NORMAL", Remark: "", Status: status, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-1084: 父部门已禁用(Status=2)时 CreateDept 必须 400 拒绝 // 修复前:事务内只查 id,Status=2 的父同样放行 → 子部门以 Enabled 状态挂到禁用父上。 func TestCreateDept_ParentDisabled_RejectedAt400(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() // 直接以 Status=Disabled 插入父部门,模拟"父部门先被禁用后 CreateDept 才到"的时序终态 parentId := insertDeptWithStatus(t, ctx, svcCtx, "r12_2_par_disabled", "/", consts.StatusDisabled) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", parentId) }) resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{ ParentId: parentId, Name: "child_" + testutil.UniqueId(), }) assert.Nil(t, resp, "父部门已禁用时 CreateDept 不得返回子部门 id —— 返回非空即意味着事务已提交,"+ "DB 中出现挂在禁用父下的 Enabled 子部门") require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "禁用父下创建子部门是业务约束冲突而非鉴权/未找到") assert.Contains(t, ce.Error(), "父部门已被禁用", "错误消息必须明确指向'父部门已被禁用',方便运营定位;"+ "不允许降级为泛用的'部门不存在'") // DB 侧兜底断言:子部门绝不应落库 var cnt int64 require.NoError(t, conn.QueryRowCtx(ctx, &cnt, "SELECT COUNT(*) FROM `sys_dept` WHERE `parentId` = ?", parentId)) assert.Equal(t, int64(0), cnt, "失败路径必须保证事务整体回滚,DB 中禁用父下不能有任何遗留子行") } // TC-1085: 父部门启用时 CreateDept 走锁视图读到 Path 并组装子 Path // 正向路径:父 Enabled → 子成功创建,且 parentPath 来自事务内 snapshot。 func TestCreateDept_ParentEnabled_UsesTxSnapshotPath(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() parentId, err := insertDeptRaw(context.Background(), svcCtx, 0, "r12_2_par_ok_"+testutil.UniqueId(), "/") require.NoError(t, err) parent, err := svcCtx.SysDeptModel.FindOne(ctx, parentId) require.NoError(t, err) resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{ ParentId: parentId, Name: "r12_2_child_" + testutil.UniqueId(), }) require.NoError(t, err) require.NotNil(t, resp) childId := resp.Id t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", childId, parentId) }) child, err := svcCtx.SysDeptModel.FindOne(ctx, childId) require.NoError(t, err) assert.Equal(t, fmt.Sprintf("%s%d/", parent.Path, childId), child.Path, "子 Path 应当在 parent.Path(事务内 snapshot)基础上拼接自己的 id,"+ "证明 parentPath 走的是修复后事务内的视图而非空字符串") assert.Equal(t, int64(consts.StatusEnabled), child.Status, "启用父下的新子部门默认 Enabled") } // TC-1086: CreateDept × UpdateDept(Status=Disabled) 并发:无"挂在已禁用父下的 Enabled 子" // 并发交错: // // A) CreateDept 先拿到 sys_dept[parent] 的 S 锁 → UpdateDept 的 X 锁阻塞; // CreateDept 插入子、提交;此时父仍 Enabled,合法。 // UpdateDept 随后拿到 X 锁 → 将父改 Disabled 提交;此时子已在,但那一瞬子是 Enabled // (这是 UpdateDept 的契约:仅改父自己,不会级联冻结子树。本 TC 不把这个当 bug,因为 // 这就是修复后认可的语义——"禁用父后由运营决定是否禁用子",而本轮修复要消灭的只是 // "禁用发生在前、子部门 Create 在后仍然挂上"的时序 bug。) // B) UpdateDept 先提交,父变 Disabled → CreateDept 的 FindOneForShareTx 在 S 锁视图里 // 看到 Status=Disabled → 400,子部门不落库。 // // 断言:任一轮成功的 CreateDept 必须伴随 "create 时刻父仍 Enabled";一切失败的 CreateDept // 必须是 400 "父部门已被禁用,无法创建子部门",不得出现 500 /静默吞错 /部分落库。 func TestCreateDept_Vs_DisableParent_NoSilentChildUnderDisabled(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() const rounds = 6 for round := 0; round < rounds; round++ { parentId, err := insertDeptRaw(context.Background(), svcCtx, 0, "r12_2_race_par_"+testutil.UniqueId(), "/") require.NoError(t, err) var ( wg sync.WaitGroup childId atomic.Int64 createErr atomic.Value disableOK atomic.Bool ) start := make(chan struct{}) wg.Add(2) go func() { defer wg.Done() <-start resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{ ParentId: parentId, Name: "r12_2_race_child_" + testutil.UniqueId(), }) if err != nil { createErr.Store(err) return } if resp != nil { childId.Store(resp.Id) } }() go func() { defer wg.Done() <-start // 直接用原生 UPDATE 模拟并发的"禁用父部门"操作,避免引入 UpdateDept Logic 的 // 上游鉴权/PathRewrite 噪声;真实 UpdateDept 禁用路径最终落到 DB 也是同一句 UPDATE。 _, err := conn.ExecCtx(context.Background(), "UPDATE `sys_dept` SET `status`=?, `updateTime`=? WHERE `id`=?", consts.StatusDisabled, time.Now().Unix(), parentId) if err == nil { disableOK.Store(true) } }() close(start) wg.Wait() require.True(t, disableOK.Load(), "前置:禁用父的裸 UPDATE 必须成功,否则本轮测试不等价于并发语义") var parentStatus int64 require.NoError(t, conn.QueryRowCtx(context.Background(), &parentStatus, "SELECT `status` FROM `sys_dept` WHERE `id` = ?", parentId)) require.Equal(t, int64(consts.StatusDisabled), parentStatus, "前置:本轮终态父必为 Disabled(直读 DB 绕过 CachedConn 可能的过期缓存)") if cid := childId.Load(); cid > 0 { // CreateDept 成功 → 说明在 FindOneForShareTx 那一刻,父仍是 Enabled。 // 本 TC 不限制此路径(这是合法的时序:先创建,后禁用),但子部门一旦落库就必须是 Enabled, // 且 Path 来自事务内 snapshot(写入后才被禁用父"覆盖"是业务意图)。 testutil.CleanTable(ctx, conn, "`sys_dept`", cid) } else { // CreateDept 失败路径:必须是 400 "父部门已被禁用"。非此即代表修复没到位, // 或把 write skew 暴露成了 500。 if raw := createErr.Load(); raw != nil { var ce *response.CodeError require.True(t, errors.As(raw.(error), &ce), "CreateDept 在并发禁用场景下只能抛 response.CodeError,不得是裸 err") assert.Equal(t, 400, ce.Code(), "并发禁用父时 CreateDept 必须 400(父已禁用),不得泄漏为 500/404") assert.Contains(t, ce.Error(), "父部门已被禁用", "错误消息必须是'父部门已被禁用',便于前端精确提示;"+ "不是'父部门不存在'(DeleteDept 那条路径)") } // DB 侧兜底:子部门绝不应落库 var cnt int64 require.NoError(t, conn.QueryRowCtx(context.Background(), &cnt, "SELECT COUNT(*) FROM `sys_dept` WHERE `parentId` = ?", parentId)) assert.Equal(t, int64(0), cnt, "失败轮次 DB 不得残留子行;若 > 0 证明事务只做了 parent S 锁校验却 "+ "没把 InsertWithTx 所在事务整体回滚") } testutil.CleanTable(ctx, conn, "`sys_dept`", parentId) } } // TC-1202: CreateDept Sort 超出范围 [-100000, 100000] 被拒绝。 // Sort 只在同级部门间相对有效,用不到 int64 的极端值;把合法区间固定为 [-100000, 100000] // 防止前端偶发把 math.MaxInt64 之类的值透传到 DB 触发"排序溢出"的 edge case。 func TestCreateDept_SortOutOfRange_Rejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) outOfRange := []int64{100001, -100001, math.MaxInt64, math.MinInt64} for _, s := range outOfRange { resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{ Name: "sort_test_" + testutil.UniqueId(), Sort: s, }) require.Error(t, err, "Sort=%d 应被拒绝", s) assert.Nil(t, resp) var ce *response.CodeError require.True(t, errors.As(err, &ce), "Sort=%d 必须返回 CodeError", s) assert.Equal(t, 400, ce.Code(), "Sort=%d 应 400 拒绝", s) assert.Contains(t, ce.Error(), "排序值必须在 -100000 到 100000 之间", "Sort=%d 的错误消息与 UpdateDept 校验文案不一致", s) } } // TC-1202 (正向): Sort 在 [-100000, 100000] 边界内应放行。 func TestCreateDept_SortBoundaryValid(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() for _, s := range []int64{-100000, 0, 100000} { resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{ Name: "sort_valid_" + testutil.UniqueId(), Sort: s, }) require.NoError(t, err, "Sort=%d 应被放行", s) require.NotNil(t, resp) require.Greater(t, resp.Id, int64(0)) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", resp.Id) }) } }