createDeptLogic_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. package dept
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. "perms-system-server/internal/consts"
  9. deptModel "perms-system-server/internal/model/dept"
  10. "perms-system-server/internal/response"
  11. "perms-system-server/internal/svc"
  12. "perms-system-server/internal/testutil"
  13. "perms-system-server/internal/testutil/ctxhelper"
  14. "perms-system-server/internal/types"
  15. "sync"
  16. "sync/atomic"
  17. "testing"
  18. "time"
  19. )
  20. func insertDeptRaw(ctx context.Context, svcCtx *svc.ServiceContext, parentId int64, name, path string) (int64, error) {
  21. now := time.Now().Unix()
  22. result, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  23. ParentId: parentId,
  24. Name: name,
  25. Path: path,
  26. Sort: 0,
  27. DeptType: "NORMAL",
  28. Status: 1,
  29. CreateTime: now,
  30. UpdateTime: now,
  31. })
  32. if err != nil {
  33. return 0, err
  34. }
  35. id, _ := result.LastInsertId()
  36. d, err := svcCtx.SysDeptModel.FindOne(ctx, id)
  37. if err != nil {
  38. return id, err
  39. }
  40. d.Path = fmt.Sprintf("%s%d/", path, id)
  41. d.UpdateTime = now
  42. return id, svcCtx.SysDeptModel.Update(ctx, d)
  43. }
  44. // TC-0093: 父部门不存在
  45. func TestCreateDept_ParentNotFound(t *testing.T) {
  46. ctx := ctxhelper.SuperAdminCtx()
  47. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  48. l := NewCreateDeptLogic(ctx, svcCtx)
  49. resp, err := l.CreateDept(&types.CreateDeptReq{
  50. ParentId: 999999999,
  51. Name: "orphan_" + testutil.UniqueId(),
  52. })
  53. assert.Nil(t, resp)
  54. require.Error(t, err)
  55. var ce *response.CodeError
  56. require.True(t, errors.As(err, &ce))
  57. assert.Equal(t, 404, ce.Code())
  58. assert.Contains(t, ce.Error(), "父部门不存在")
  59. }
  60. // TC-0091: 创建顶级部门
  61. func TestCreateDept_TopLevel_ViaRawInsert(t *testing.T) {
  62. ctx := context.Background()
  63. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  64. conn := testutil.GetTestSqlConn()
  65. name := "dept_top_" + testutil.UniqueId()
  66. id, err := insertDeptRaw(ctx, svcCtx, 0, name, "/")
  67. require.NoError(t, err)
  68. require.Greater(t, id, int64(0))
  69. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", id) })
  70. dept, err := svcCtx.SysDeptModel.FindOne(ctx, id)
  71. require.NoError(t, err)
  72. assert.Equal(t, name, dept.Name)
  73. assert.Equal(t, int64(0), dept.ParentId)
  74. assert.Equal(t, fmt.Sprintf("/%d/", id), dept.Path)
  75. assert.Equal(t, int64(1), dept.Status)
  76. }
  77. // TC-0092: 创建子部门
  78. func TestCreateDept_Child_ViaRawInsert(t *testing.T) {
  79. ctx := context.Background()
  80. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  81. conn := testutil.GetTestSqlConn()
  82. parentId, err := insertDeptRaw(ctx, svcCtx, 0, "par_"+testutil.UniqueId(), "/")
  83. require.NoError(t, err)
  84. parentDept, _ := svcCtx.SysDeptModel.FindOne(ctx, parentId)
  85. childId, err := insertDeptRaw(ctx, svcCtx, parentId, "child_"+testutil.UniqueId(), parentDept.Path)
  86. require.NoError(t, err)
  87. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", childId, parentId) })
  88. child, err := svcCtx.SysDeptModel.FindOne(ctx, childId)
  89. require.NoError(t, err)
  90. assert.Equal(t, parentId, child.ParentId)
  91. assert.Equal(t, fmt.Sprintf("/%d/%d/", parentId, childId), child.Path)
  92. }
  93. // TC-0099: 多层嵌套(5层)
  94. func TestCreateDept_MultiLevel(t *testing.T) {
  95. ctx := context.Background()
  96. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  97. conn := testutil.GetTestSqlConn()
  98. l1Id, err := insertDeptRaw(ctx, svcCtx, 0, "L1_"+testutil.UniqueId(), "/")
  99. require.NoError(t, err)
  100. l1, _ := svcCtx.SysDeptModel.FindOne(ctx, l1Id)
  101. l2Id, err := insertDeptRaw(ctx, svcCtx, l1Id, "L2_"+testutil.UniqueId(), l1.Path)
  102. require.NoError(t, err)
  103. l2, _ := svcCtx.SysDeptModel.FindOne(ctx, l2Id)
  104. l3Id, err := insertDeptRaw(ctx, svcCtx, l2Id, "L3_"+testutil.UniqueId(), l2.Path)
  105. require.NoError(t, err)
  106. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", l3Id, l2Id, l1Id) })
  107. d3, err := svcCtx.SysDeptModel.FindOne(ctx, l3Id)
  108. require.NoError(t, err)
  109. assert.Equal(t, fmt.Sprintf("/%d/%d/%d/", l1Id, l2Id, l3Id), d3.Path)
  110. }
  111. // TC-0099: 多层嵌套(5层)
  112. func TestCreateDept_FiveLevelNesting(t *testing.T) {
  113. ctx := context.Background()
  114. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  115. conn := testutil.GetTestSqlConn()
  116. var ids []int64
  117. parentId := int64(0)
  118. parentPath := "/"
  119. for level := 1; level <= 5; level++ {
  120. id, err := insertDeptRaw(ctx, svcCtx, parentId, fmt.Sprintf("L%d_%s", level, testutil.UniqueId()), parentPath)
  121. require.NoError(t, err)
  122. ids = append(ids, id)
  123. dept, err := svcCtx.SysDeptModel.FindOne(ctx, id)
  124. require.NoError(t, err)
  125. parentId = id
  126. parentPath = dept.Path
  127. }
  128. t.Cleanup(func() {
  129. for i := len(ids) - 1; i >= 0; i-- {
  130. testutil.CleanTable(ctx, conn, "`sys_dept`", ids[i])
  131. }
  132. })
  133. deepest, err := svcCtx.SysDeptModel.FindOne(ctx, ids[4])
  134. require.NoError(t, err)
  135. expected := fmt.Sprintf("/%d/%d/%d/%d/%d/", ids[0], ids[1], ids[2], ids[3], ids[4])
  136. assert.Equal(t, expected, deepest.Path)
  137. }
  138. // TC-0094: 不传DeptType默认NORMAL
  139. func TestCreateDept_DefaultDeptType(t *testing.T) {
  140. ctx := ctxhelper.SuperAdminCtx()
  141. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  142. conn := testutil.GetTestSqlConn()
  143. l := NewCreateDeptLogic(ctx, svcCtx)
  144. resp, err := l.CreateDept(&types.CreateDeptReq{
  145. ParentId: 0,
  146. Name: "deftype_" + testutil.UniqueId(),
  147. })
  148. require.NoError(t, err)
  149. require.NotNil(t, resp)
  150. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", resp.Id) })
  151. d, err := svcCtx.SysDeptModel.FindOne(ctx, resp.Id)
  152. require.NoError(t, err)
  153. assert.Equal(t, "NORMAL", d.DeptType)
  154. assert.Contains(t, d.Path, fmt.Sprintf("/%d/", resp.Id))
  155. }
  156. // TC-0095: 传DeptType=DEV
  157. func TestCreateDept_DevDeptType(t *testing.T) {
  158. ctx := ctxhelper.SuperAdminCtx()
  159. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  160. conn := testutil.GetTestSqlConn()
  161. l := NewCreateDeptLogic(ctx, svcCtx)
  162. resp, err := l.CreateDept(&types.CreateDeptReq{
  163. ParentId: 0,
  164. Name: "devtype_" + testutil.UniqueId(),
  165. DeptType: "DEV",
  166. })
  167. require.NoError(t, err)
  168. require.NotNil(t, resp)
  169. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", resp.Id) })
  170. d, err := svcCtx.SysDeptModel.FindOne(ctx, resp.Id)
  171. require.NoError(t, err)
  172. assert.Equal(t, "DEV", d.DeptType)
  173. assert.Contains(t, d.Path, fmt.Sprintf("/%d/", resp.Id))
  174. }
  175. // TC-0100: 通过Logic创建+验证Path
  176. func TestCreateDept_ViaLogic_PathCorrect(t *testing.T) {
  177. ctx := ctxhelper.SuperAdminCtx()
  178. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  179. conn := testutil.GetTestSqlConn()
  180. l := NewCreateDeptLogic(ctx, svcCtx)
  181. parentResp, err := l.CreateDept(&types.CreateDeptReq{
  182. ParentId: 0,
  183. Name: "parent_" + testutil.UniqueId(),
  184. })
  185. require.NoError(t, err)
  186. childResp, err := l.CreateDept(&types.CreateDeptReq{
  187. ParentId: parentResp.Id,
  188. Name: "child_" + testutil.UniqueId(),
  189. })
  190. require.NoError(t, err)
  191. t.Cleanup(func() {
  192. testutil.CleanTable(ctx, conn, "`sys_dept`", childResp.Id, parentResp.Id)
  193. })
  194. parent, err := svcCtx.SysDeptModel.FindOne(ctx, parentResp.Id)
  195. require.NoError(t, err)
  196. assert.Equal(t, fmt.Sprintf("/%d/", parentResp.Id), parent.Path)
  197. child, err := svcCtx.SysDeptModel.FindOne(ctx, childResp.Id)
  198. require.NoError(t, err)
  199. assert.Equal(t, fmt.Sprintf("/%d/%d/", parentResp.Id, childResp.Id), child.Path)
  200. }
  201. // TC-0532: createDept非超管拒绝
  202. func TestCreateDept_NonSuperAdminRejected(t *testing.T) {
  203. ctx := ctxhelper.AdminCtx("test_product")
  204. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  205. l := NewCreateDeptLogic(ctx, svcCtx)
  206. _, err := l.CreateDept(&types.CreateDeptReq{Name: "test", Sort: 1})
  207. require.Error(t, err)
  208. var ce *response.CodeError
  209. require.True(t, errors.As(err, &ce))
  210. assert.Equal(t, 403, ce.Code())
  211. }
  212. var _ = deptModel.ErrNotFound
  213. func insertDeptWithStatus(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, name, path string, status int64) int64 {
  214. t.Helper()
  215. now := time.Now().Unix()
  216. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  217. ParentId: 0, Name: name + "_" + testutil.UniqueId(),
  218. Path: path, Sort: 0, DeptType: "NORMAL", Remark: "",
  219. Status: status, CreateTime: now, UpdateTime: now,
  220. })
  221. require.NoError(t, err)
  222. id, _ := res.LastInsertId()
  223. return id
  224. }
  225. // TC-1084: 父部门已禁用(Status=2)时 CreateDept 必须 400 拒绝
  226. // 修复前:事务内只查 id,Status=2 的父同样放行 → 子部门以 Enabled 状态挂到禁用父上。
  227. func TestCreateDept_ParentDisabled_RejectedAt400(t *testing.T) {
  228. ctx := ctxhelper.SuperAdminCtx()
  229. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  230. conn := testutil.GetTestSqlConn()
  231. // 直接以 Status=Disabled 插入父部门,模拟"父部门先被禁用后 CreateDept 才到"的时序终态
  232. parentId := insertDeptWithStatus(t, ctx, svcCtx, "r12_2_par_disabled", "/", consts.StatusDisabled)
  233. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", parentId) })
  234. resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{
  235. ParentId: parentId,
  236. Name: "child_" + testutil.UniqueId(),
  237. })
  238. assert.Nil(t, resp,
  239. "父部门已禁用时 CreateDept 不得返回子部门 id —— 返回非空即意味着事务已提交,"+
  240. "DB 中出现挂在禁用父下的 Enabled 子部门")
  241. require.Error(t, err)
  242. var ce *response.CodeError
  243. require.True(t, errors.As(err, &ce))
  244. assert.Equal(t, 400, ce.Code(), "禁用父下创建子部门是业务约束冲突而非鉴权/未找到")
  245. assert.Contains(t, ce.Error(), "父部门已被禁用",
  246. "错误消息必须明确指向'父部门已被禁用',方便运营定位;"+
  247. "不允许降级为泛用的'部门不存在'")
  248. // DB 侧兜底断言:子部门绝不应落库
  249. var cnt int64
  250. require.NoError(t, conn.QueryRowCtx(ctx, &cnt,
  251. "SELECT COUNT(*) FROM `sys_dept` WHERE `parentId` = ?", parentId))
  252. assert.Equal(t, int64(0), cnt,
  253. "失败路径必须保证事务整体回滚,DB 中禁用父下不能有任何遗留子行")
  254. }
  255. // TC-1085: 父部门启用时 CreateDept 走锁视图读到 Path 并组装子 Path
  256. // 正向路径:父 Enabled → 子成功创建,且 parentPath 来自事务内 snapshot。
  257. func TestCreateDept_ParentEnabled_UsesTxSnapshotPath(t *testing.T) {
  258. ctx := ctxhelper.SuperAdminCtx()
  259. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  260. conn := testutil.GetTestSqlConn()
  261. parentId, err := insertDeptRaw(context.Background(), svcCtx,
  262. 0, "r12_2_par_ok_"+testutil.UniqueId(), "/")
  263. require.NoError(t, err)
  264. parent, err := svcCtx.SysDeptModel.FindOne(ctx, parentId)
  265. require.NoError(t, err)
  266. resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{
  267. ParentId: parentId,
  268. Name: "r12_2_child_" + testutil.UniqueId(),
  269. })
  270. require.NoError(t, err)
  271. require.NotNil(t, resp)
  272. childId := resp.Id
  273. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", childId, parentId) })
  274. child, err := svcCtx.SysDeptModel.FindOne(ctx, childId)
  275. require.NoError(t, err)
  276. assert.Equal(t, fmt.Sprintf("%s%d/", parent.Path, childId), child.Path,
  277. "子 Path 应当在 parent.Path(事务内 snapshot)基础上拼接自己的 id,"+
  278. "证明 parentPath 走的是修复后事务内的视图而非空字符串")
  279. assert.Equal(t, int64(consts.StatusEnabled), child.Status,
  280. "启用父下的新子部门默认 Enabled")
  281. }
  282. // TC-1086: CreateDept × UpdateDept(Status=Disabled) 并发:无"挂在已禁用父下的 Enabled 子"
  283. // 并发交错:
  284. //
  285. // A) CreateDept 先拿到 sys_dept[parent] 的 S 锁 → UpdateDept 的 X 锁阻塞;
  286. // CreateDept 插入子、提交;此时父仍 Enabled,合法。
  287. // UpdateDept 随后拿到 X 锁 → 将父改 Disabled 提交;此时子已在,但那一瞬子是 Enabled
  288. // (这是 UpdateDept 的契约:仅改父自己,不会级联冻结子树。本 TC 不把这个当 bug,因为
  289. // 这就是修复后认可的语义——"禁用父后由运营决定是否禁用子",而本轮修复要消灭的只是
  290. // "禁用发生在前、子部门 Create 在后仍然挂上"的时序 bug。)
  291. // B) UpdateDept 先提交,父变 Disabled → CreateDept 的 FindOneForShareTx 在 S 锁视图里
  292. // 看到 Status=Disabled → 400,子部门不落库。
  293. //
  294. // 断言:任一轮成功的 CreateDept 必须伴随 "create 时刻父仍 Enabled";一切失败的 CreateDept
  295. // 必须是 400 "父部门已被禁用,无法创建子部门",不得出现 500 /静默吞错 /部分落库。
  296. func TestCreateDept_Vs_DisableParent_NoSilentChildUnderDisabled(t *testing.T) {
  297. ctx := ctxhelper.SuperAdminCtx()
  298. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  299. conn := testutil.GetTestSqlConn()
  300. const rounds = 6
  301. for round := 0; round < rounds; round++ {
  302. parentId, err := insertDeptRaw(context.Background(), svcCtx,
  303. 0, "r12_2_race_par_"+testutil.UniqueId(), "/")
  304. require.NoError(t, err)
  305. var (
  306. wg sync.WaitGroup
  307. childId atomic.Int64
  308. createErr atomic.Value
  309. disableOK atomic.Bool
  310. )
  311. start := make(chan struct{})
  312. wg.Add(2)
  313. go func() {
  314. defer wg.Done()
  315. <-start
  316. resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{
  317. ParentId: parentId,
  318. Name: "r12_2_race_child_" + testutil.UniqueId(),
  319. })
  320. if err != nil {
  321. createErr.Store(err)
  322. return
  323. }
  324. if resp != nil {
  325. childId.Store(resp.Id)
  326. }
  327. }()
  328. go func() {
  329. defer wg.Done()
  330. <-start
  331. // 直接用原生 UPDATE 模拟并发的"禁用父部门"操作,避免引入 UpdateDept Logic 的
  332. // 上游鉴权/PathRewrite 噪声;真实 UpdateDept 禁用路径最终落到 DB 也是同一句 UPDATE。
  333. _, err := conn.ExecCtx(context.Background(),
  334. "UPDATE `sys_dept` SET `status`=?, `updateTime`=? WHERE `id`=?",
  335. consts.StatusDisabled, time.Now().Unix(), parentId)
  336. if err == nil {
  337. disableOK.Store(true)
  338. }
  339. }()
  340. close(start)
  341. wg.Wait()
  342. require.True(t, disableOK.Load(),
  343. "前置:禁用父的裸 UPDATE 必须成功,否则本轮测试不等价于并发语义")
  344. var parentStatus int64
  345. require.NoError(t, conn.QueryRowCtx(context.Background(), &parentStatus,
  346. "SELECT `status` FROM `sys_dept` WHERE `id` = ?", parentId))
  347. require.Equal(t, int64(consts.StatusDisabled), parentStatus,
  348. "前置:本轮终态父必为 Disabled(直读 DB 绕过 CachedConn 可能的过期缓存)")
  349. if cid := childId.Load(); cid > 0 {
  350. // CreateDept 成功 → 说明在 FindOneForShareTx 那一刻,父仍是 Enabled。
  351. // 本 TC 不限制此路径(这是合法的时序:先创建,后禁用),但子部门一旦落库就必须是 Enabled,
  352. // 且 Path 来自事务内 snapshot(写入后才被禁用父"覆盖"是业务意图)。
  353. testutil.CleanTable(ctx, conn, "`sys_dept`", cid)
  354. } else {
  355. // CreateDept 失败路径:必须是 400 "父部门已被禁用"。非此即代表修复没到位,
  356. // 或把 write skew 暴露成了 500。
  357. if raw := createErr.Load(); raw != nil {
  358. var ce *response.CodeError
  359. require.True(t, errors.As(raw.(error), &ce),
  360. "CreateDept 在并发禁用场景下只能抛 response.CodeError,不得是裸 err")
  361. assert.Equal(t, 400, ce.Code(),
  362. "并发禁用父时 CreateDept 必须 400(父已禁用),不得泄漏为 500/404")
  363. assert.Contains(t, ce.Error(), "父部门已被禁用",
  364. "错误消息必须是'父部门已被禁用',便于前端精确提示;"+
  365. "不是'父部门不存在'(DeleteDept 那条路径)")
  366. }
  367. // DB 侧兜底:子部门绝不应落库
  368. var cnt int64
  369. require.NoError(t, conn.QueryRowCtx(context.Background(), &cnt,
  370. "SELECT COUNT(*) FROM `sys_dept` WHERE `parentId` = ?", parentId))
  371. assert.Equal(t, int64(0), cnt,
  372. "失败轮次 DB 不得残留子行;若 > 0 证明事务只做了 parent S 锁校验却 "+
  373. "没把 InsertWithTx 所在事务整体回滚")
  374. }
  375. testutil.CleanTable(ctx, conn, "`sys_dept`", parentId)
  376. }
  377. }