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