createProductLogic_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. package product
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "github.com/go-sql-driver/mysql"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/require"
  10. "github.com/zeromicro/go-zero/core/stores/redis"
  11. "github.com/zeromicro/go-zero/core/stores/sqlx"
  12. "go.uber.org/mock/gomock"
  13. "golang.org/x/crypto/bcrypt"
  14. deptModel "perms-system-server/internal/model/dept"
  15. productModel "perms-system-server/internal/model/product"
  16. userModel "perms-system-server/internal/model/user"
  17. "perms-system-server/internal/response"
  18. "perms-system-server/internal/svc"
  19. "perms-system-server/internal/testutil"
  20. "perms-system-server/internal/testutil/ctxhelper"
  21. "perms-system-server/internal/testutil/mocks"
  22. "perms-system-server/internal/types"
  23. "sync"
  24. "testing"
  25. "time"
  26. )
  27. func TestCreateProduct_Success(t *testing.T) {
  28. ctx := ctxhelper.SuperAdminCtx()
  29. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  30. conn := testutil.GetTestSqlConn()
  31. code := testutil.UniqueId()
  32. logic := NewCreateProductLogic(ctx, svcCtx)
  33. resp, err := logic.CreateProduct(&types.CreateProductReq{
  34. Code: code,
  35. Name: "测试产品",
  36. Remark: "集成测试",
  37. AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  38. })
  39. require.NoError(t, err)
  40. require.NotNil(t, resp)
  41. t.Cleanup(func() {
  42. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  43. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  44. testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id)
  45. })
  46. assert.True(t, resp.Id > 0)
  47. assert.Equal(t, code, resp.Code)
  48. assert.NotEmpty(t, resp.AppKey)
  49. assert.Equal(t, "admin_"+code, resp.AdminUser)
  50. // 响应体必须不再明文携带 appSecret / adminPassword,
  51. // 改为发放一次性 credentialsTicket + 过期时间;调用方需凭 ticket 走
  52. // /api/product/fetchInitialCredentials 领取敏感凭证。
  53. assert.NotEmpty(t, resp.CredentialsTicket, "必须返回一次性凭证票据")
  54. assert.True(t, resp.CredentialsExpiresAt > 0, "必须返回过期时间戳")
  55. // 契约性校验:CreateProductResp 的 JSON 序列化里不应再出现 appSecret / adminPassword 字段。
  56. buf, err := json.Marshal(resp)
  57. require.NoError(t, err)
  58. var asMap map[string]interface{}
  59. require.NoError(t, json.Unmarshal(buf, &asMap))
  60. _, hasSecret := asMap["appSecret"]
  61. _, hasPwd := asMap["adminPassword"]
  62. assert.False(t, hasSecret, "CreateProductResp JSON 不得包含 appSecret 字段(避免日志落盘)")
  63. assert.False(t, hasPwd, "CreateProductResp JSON 不得包含 adminPassword 字段(避免日志落盘)")
  64. }
  65. // TC-0064: 正常创建
  66. func TestCreateProduct_VerifyDB(t *testing.T) {
  67. ctx := ctxhelper.SuperAdminCtx()
  68. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  69. conn := testutil.GetTestSqlConn()
  70. code := testutil.UniqueId()
  71. logic := NewCreateProductLogic(ctx, svcCtx)
  72. resp, err := logic.CreateProduct(&types.CreateProductReq{
  73. Code: code,
  74. Name: "DB验证产品",
  75. Remark: "验证数据库记录",
  76. AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  77. })
  78. require.NoError(t, err)
  79. t.Cleanup(func() {
  80. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  81. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  82. testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id)
  83. })
  84. product, err := svcCtx.SysProductModel.FindOne(ctx, resp.Id)
  85. require.NoError(t, err)
  86. assert.Equal(t, code, product.Code)
  87. assert.Equal(t, "DB验证产品", product.Name)
  88. assert.Equal(t, resp.AppKey, product.AppKey)
  89. // CreateProduct 响应不再明文吐 appSecret;appSecret 经 ticket 领取后再核对。
  90. // 这里改为用 FetchInitialCredentialsLogic 把明文 appSecret 取出来,与 DB 中的 bcrypt hash 比对,
  91. // 既验证"DB 存的是 hash 而不是明文",也验证 ticket 流程正确交还了原始 appSecret。
  92. fetch := NewFetchInitialCredentialsLogic(ctx, svcCtx)
  93. cred, err := fetch.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: resp.CredentialsTicket})
  94. require.NoError(t, err, "使用 ticket 必须能领取到初始 appSecret / adminPassword")
  95. require.NotEmpty(t, cred.AppSecret)
  96. require.NotEmpty(t, cred.AdminPassword)
  97. assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(cred.AppSecret)),
  98. "DB should store bcrypt hash of appSecret, verifiable with plaintext from ticket payload")
  99. assert.Equal(t, int64(1), product.Status)
  100. var userCount int64
  101. err = conn.QueryRowCtx(ctx, &userCount,
  102. "SELECT COUNT(*) FROM `sys_user` WHERE `username` = ?", "admin_"+code)
  103. require.NoError(t, err)
  104. assert.Equal(t, int64(1), userCount)
  105. var memberCount int64
  106. err = conn.QueryRowCtx(ctx, &memberCount,
  107. "SELECT COUNT(*) FROM `sys_product_member` WHERE `productCode` = ? AND `memberType` = 'ADMIN'", code)
  108. require.NoError(t, err)
  109. assert.Equal(t, int64(1), memberCount)
  110. }
  111. // TC-0067: 编码已存在
  112. func TestCreateProduct_DuplicateCode(t *testing.T) {
  113. ctx := ctxhelper.SuperAdminCtx()
  114. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  115. conn := testutil.GetTestSqlConn()
  116. code := testutil.UniqueId()
  117. deptId := seedAdminDept(t, ctx, svcCtx)
  118. logic := NewCreateProductLogic(ctx, svcCtx)
  119. resp, err := logic.CreateProduct(&types.CreateProductReq{
  120. Code: code,
  121. Name: "第一个产品",
  122. AdminDeptId: deptId,
  123. })
  124. require.NoError(t, err)
  125. t.Cleanup(func() {
  126. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  127. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  128. testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id)
  129. })
  130. logic2 := NewCreateProductLogic(ctx, svcCtx)
  131. _, err = logic2.CreateProduct(&types.CreateProductReq{
  132. Code: code,
  133. Name: "重复产品",
  134. AdminDeptId: deptId,
  135. })
  136. require.Error(t, err)
  137. var codeErr *response.CodeError
  138. require.True(t, errors.As(err, &codeErr))
  139. assert.Equal(t, 409, codeErr.Code())
  140. assert.Equal(t, "产品编码已存在", codeErr.Error())
  141. }
  142. // TC-0068: 并发创建同编码
  143. func TestCreateProduct_ConcurrentSameCode(t *testing.T) {
  144. ctx := ctxhelper.SuperAdminCtx()
  145. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  146. conn := testutil.GetTestSqlConn()
  147. code := testutil.UniqueId()
  148. t.Cleanup(func() {
  149. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  150. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  151. testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
  152. })
  153. deptId := seedAdminDept(t, ctx, svcCtx)
  154. var wg sync.WaitGroup
  155. results := make(chan error, 2)
  156. for i := 0; i < 2; i++ {
  157. wg.Add(1)
  158. go func() {
  159. defer wg.Done()
  160. logic := NewCreateProductLogic(ctx, svcCtx)
  161. _, err := logic.CreateProduct(&types.CreateProductReq{
  162. Code: code,
  163. Name: "并发测试产品",
  164. AdminDeptId: deptId,
  165. })
  166. results <- err
  167. }()
  168. }
  169. wg.Wait()
  170. close(results)
  171. var errs []error
  172. for err := range results {
  173. errs = append(errs, err)
  174. }
  175. require.Len(t, errs, 2)
  176. successCount := 0
  177. failCount := 0
  178. for _, err := range errs {
  179. if err == nil {
  180. successCount++
  181. } else {
  182. failCount++
  183. }
  184. }
  185. assert.Equal(t, 1, successCount, "exactly one goroutine should succeed")
  186. assert.Equal(t, 1, failCount, "exactly one goroutine should fail (409 or DB duplicate)")
  187. }
  188. // TC-0535: createProduct非超管拒绝
  189. func TestCreateProduct_NonSuperAdminRejected(t *testing.T) {
  190. ctx := ctxhelper.AdminCtx("test_product")
  191. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  192. logic := NewCreateProductLogic(ctx, svcCtx)
  193. _, err := logic.CreateProduct(&types.CreateProductReq{Code: "test", Name: "test"})
  194. require.Error(t, err)
  195. var ce *response.CodeError
  196. require.True(t, errors.As(err, &ce))
  197. assert.Equal(t, 403, ce.Code())
  198. }
  199. // TC-0069~0593: createProduct 编码格式校验( 修复验证)
  200. func TestCreateProduct_InvalidCodeFormat(t *testing.T) {
  201. ctx := ctxhelper.SuperAdminCtx()
  202. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  203. logic := NewCreateProductLogic(ctx, svcCtx)
  204. cases := []struct {
  205. name string
  206. code string
  207. }{
  208. {"空", ""},
  209. {"数字开头", "1abc"},
  210. {"下划线开头", "_abc"},
  211. {"中划线开头", "-abc"},
  212. {"包含中文", "产品A"},
  213. {"单字母(过短)", "a"},
  214. {"包含空格", "ab c"},
  215. {"包含特殊字符!", "ab!c"},
  216. {"包含斜杠", "ab/c"},
  217. }
  218. for _, c := range cases {
  219. t.Run(c.name, func(t *testing.T) {
  220. _, err := logic.CreateProduct(&types.CreateProductReq{Code: c.code, Name: "x"})
  221. require.Error(t, err, "code=%q 应被拒绝", c.code)
  222. var ce *response.CodeError
  223. require.True(t, errors.As(err, &ce))
  224. assert.Equal(t, 400, ce.Code())
  225. })
  226. }
  227. }
  228. // TC-0074: createProduct 编码长度>64 被拒绝
  229. func TestCreateProduct_CodeTooLong(t *testing.T) {
  230. ctx := ctxhelper.SuperAdminCtx()
  231. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  232. logic := NewCreateProductLogic(ctx, svcCtx)
  233. long := "a"
  234. for i := 0; i < 64; i++ {
  235. long += "b"
  236. }
  237. _, err := logic.CreateProduct(&types.CreateProductReq{Code: long, Name: "x"})
  238. require.Error(t, err)
  239. var ce *response.CodeError
  240. require.True(t, errors.As(err, &ce))
  241. assert.Equal(t, 400, ce.Code())
  242. }
  243. // TC-0075: createProduct 合法编码(包含下划线、中划线、数字)
  244. func TestCreateProduct_ValidCodeWithSymbols(t *testing.T) {
  245. ctx := ctxhelper.SuperAdminCtx()
  246. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  247. conn := testutil.GetTestSqlConn()
  248. code := "a_1-" + testutil.UniqueId()
  249. logic := NewCreateProductLogic(ctx, svcCtx)
  250. resp, err := logic.CreateProduct(&types.CreateProductReq{
  251. Code: code, Name: "x", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  252. })
  253. require.NoError(t, err)
  254. t.Cleanup(func() {
  255. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  256. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  257. testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id)
  258. })
  259. assert.Equal(t, code, resp.Code)
  260. }
  261. // suppress unused import
  262. var _ = (*productModel.SysProduct)(nil)
  263. func newBrokenSvcCtxForM1(t *testing.T) *svc.ServiceContext {
  264. t.Helper()
  265. cfg := testutil.GetTestConfig()
  266. svcCtx := svc.NewServiceContext(cfg)
  267. // 把 svcCtx.Redis 换掉;注意 MustNewRedis 不立即拨号,只有真正调 Setex 才会爆。
  268. // NonBlock=true 避免构造期强制 ping;PingTimeout 只在 NonBlock=false 时生效,这里保留是为
  269. // 防御未来默认值漂移。真正的失败发生在运行 SetexCtx 时,连不通 127.0.0.1:1 会立刻返 err。
  270. broken := redis.MustNewRedis(redis.RedisConf{
  271. Host: "127.0.0.1:1",
  272. Type: "node",
  273. NonBlock: true,
  274. PingTimeout: 200 * time.Millisecond,
  275. })
  276. svcCtx.Redis = broken
  277. return svcCtx
  278. }
  279. // assertNoOrphanRowsLeft 在补偿完成后,按 (productCode, adminUsername) 反查 DB 是否还有脏行。
  280. // 三张表都必须干净 —— 这是补偿契约的硬不变式。
  281. func assertNoOrphanRowsLeft(t *testing.T, ctx context.Context, conn sqlx.SqlConn, productCode, adminUsername string) {
  282. t.Helper()
  283. var productId int64
  284. err := conn.QueryRowCtx(ctx, &productId, "SELECT `id` FROM `sys_product` WHERE `code` = ? LIMIT 1", productCode)
  285. assert.ErrorIs(t, err, sql.ErrNoRows,
  286. "补偿后 sys_product 不得留下 code=%s 的行", productCode)
  287. var userId int64
  288. err = conn.QueryRowCtx(ctx, &userId, "SELECT `id` FROM `sys_user` WHERE `username` = ? LIMIT 1", adminUsername)
  289. assert.ErrorIs(t, err, sql.ErrNoRows,
  290. "补偿后 sys_user 不得留下 username=%s 的行", adminUsername)
  291. var memberId int64
  292. err = conn.QueryRowCtx(ctx, &memberId,
  293. "SELECT `id` FROM `sys_product_member` WHERE `productCode` = ? LIMIT 1", productCode)
  294. assert.ErrorIs(t, err, sql.ErrNoRows,
  295. "补偿后 sys_product_member 不得留下 productCode=%s 的行", productCode)
  296. }
  297. // TC-0976: Redis SetexCtx 失败时走补偿 —— DB 三张表必须回到"从未创建"。
  298. func TestCreateProduct_RedisSetexFail_CompensatesAllRows(t *testing.T) {
  299. ctx := ctxhelper.SuperAdminCtx()
  300. svcCtx := newBrokenSvcCtxForM1(t)
  301. conn := testutil.GetTestSqlConn()
  302. code := "m1_cpf_" + testutil.UniqueId()
  303. adminUsername := "admin_" + code
  304. // 兜底清理,防止断言失败后把孤儿行留下来污染下一次运行。
  305. t.Cleanup(func() {
  306. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  307. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername)
  308. testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
  309. })
  310. // 补偿路径必须在"入库 → SetexCtx 失败 → 补偿"整条链路生效。 要求传 AdminDeptId:
  311. // 这里必须用真实 DB(svcCtx.SysDeptModel 走 testutil 底层 conn),所以 seed 一条真实 dept。
  312. deptId := seedAdminDept(t, ctx, svcCtx)
  313. logic := NewCreateProductLogic(ctx, svcCtx)
  314. resp, err := logic.CreateProduct(&types.CreateProductReq{
  315. Code: code,
  316. Name: "m1压测产品",
  317. Remark: "补偿验证",
  318. AdminDeptId: deptId,
  319. })
  320. // 要求:返回 503 "暂存初始凭证失败,请稍后重试";响应体不得携带 ticket / adminPassword。
  321. require.Error(t, err, "Redis 挂了时必须返回错误而不是静默吞掉")
  322. require.Nil(t, resp, "失败路径下不应把半成品 CreateProductResp 塞回给客户端")
  323. // 核心断言:补偿必须把产品 / admin 用户 / product_member 三行全部抹除。
  324. assertNoOrphanRowsLeft(t, ctx, conn, code, adminUsername)
  325. }
  326. // TC-0977: 同一 product code 在补偿后可以再次创建成功(幂等性)。
  327. // 没有这条断言的话,"补偿把行删干净"还可能与"索引未释放"组合成 ErrConflict,运维的修复动作会
  328. // 在二次尝试时被"产品编码已存在"挡回,补偿只是半成品。
  329. func TestCreateProduct_RedisSetexFail_AfterCompensation_CanRecreate(t *testing.T) {
  330. ctx := ctxhelper.SuperAdminCtx()
  331. brokenCtx := newBrokenSvcCtxForM1(t)
  332. conn := testutil.GetTestSqlConn()
  333. code := "m1_recreate_" + testutil.UniqueId()
  334. adminUsername := "admin_" + code
  335. t.Cleanup(func() {
  336. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  337. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername)
  338. testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
  339. })
  340. // 两次 CreateProduct 复用同一个有效 dept,seedAdminDept 在 brokenCtx 下创建
  341. // (brokenCtx 的 Models 共享底层 conn,所以 goodCtx 二次查询也能读到)。
  342. deptId := seedAdminDept(t, ctx, brokenCtx)
  343. // 第一次:Redis 坏 → 补偿 → 503
  344. _, err := NewCreateProductLogic(ctx, brokenCtx).CreateProduct(&types.CreateProductReq{
  345. Code: code, Name: "first_attempt", Remark: "redis_down", AdminDeptId: deptId,
  346. })
  347. require.Error(t, err)
  348. assertNoOrphanRowsLeft(t, ctx, conn, code, adminUsername)
  349. // 第二次:Redis 好 → 必须成功;若第一次补偿不彻底会在 FindOneByCode/FindOneByUsername 里被拦。
  350. goodCtx := svc.NewServiceContext(testutil.GetTestConfig())
  351. resp2, err := NewCreateProductLogic(ctx, goodCtx).CreateProduct(&types.CreateProductReq{
  352. Code: code, Name: "second_attempt", Remark: "redis_ok", AdminDeptId: deptId,
  353. })
  354. require.NoError(t, err, "补偿后二次创建必须成功;若失败说明有行没被清干净")
  355. require.NotNil(t, resp2)
  356. assert.Equal(t, code, resp2.Code)
  357. assert.NotEmpty(t, resp2.CredentialsTicket)
  358. }
  359. // TC-0978: 补偿的三张表删除顺序必须是"子 → 父"(product_member → user → product),
  360. // 保证即使外键/缓存/唯一索引尚未释放也不会互相拦截。
  361. //
  362. // 验证手法:在第一次补偿完成后,直接用数据库元数据反查三张表里均无与 productCode 相关的残留;
  363. // 如果删除顺序错误(例如先 product 后 member),product 会被外键/级联规则阻塞,member 行会留下。
  364. //
  365. // 注:sys_product 与 sys_product_member 之间没有强制外键(见 perm.sql 确认),所以 MySQL 不会在
  366. // 引擎层阻止错序 DELETE。但错序在事务里依然会导致:
  367. // - 如果先删 product 再删 member,member 仍有引用的 productCode 已经没有对应 product,
  368. // 再之后的 "ON DUPLICATE KEY UPDATE" 或外部查询会看到脏引用;
  369. // - 即便 DB 不拦,测试也要在更高一层用"三张表均为 0 行"来钉死补偿是否真正覆盖 3 行。
  370. func TestCreateProduct_RedisSetexFail_CompensatesInChildFirstOrder(t *testing.T) {
  371. ctx := ctxhelper.SuperAdminCtx()
  372. svcCtx := newBrokenSvcCtxForM1(t)
  373. conn := testutil.GetTestSqlConn()
  374. code := "m1_order_" + testutil.UniqueId()
  375. adminUsername := "admin_" + code
  376. t.Cleanup(func() {
  377. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  378. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername)
  379. testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
  380. })
  381. _, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  382. Code: code, Name: "m1 order", Remark: "delete order", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  383. })
  384. require.Error(t, err)
  385. // 交叉验证:三张表按独立 SELECT COUNT 查询,每张都必须为 0。
  386. for _, tc := range []struct {
  387. sql string
  388. arg interface{}
  389. table string
  390. }{
  391. {"SELECT COUNT(*) FROM `sys_product_member` WHERE `productCode` = ?", code, "sys_product_member"},
  392. {"SELECT COUNT(*) FROM `sys_user` WHERE `username` = ?", adminUsername, "sys_user"},
  393. {"SELECT COUNT(*) FROM `sys_product` WHERE `code` = ?", code, "sys_product"},
  394. } {
  395. var n int64
  396. require.NoError(t, conn.QueryRowCtx(ctx, &n, tc.sql, tc.arg))
  397. assert.Equal(t, int64(0), n,
  398. "补偿完成后 %s 应 0 行(命名 code=%s / admin=%s)", tc.table, code, adminUsername)
  399. }
  400. }
  401. func TestCreateProduct_DuplicateEntry_UnknownIndexName_MapsTo409(t *testing.T) {
  402. ctrl := gomock.NewController(t)
  403. t.Cleanup(ctrl.Finish)
  404. // 关键:索引名选一个完全不含 "uk_code" 的,让旧 strings.Contains 分支必然 miss。
  405. dupErr := &mysql.MySQLError{
  406. Number: 1062,
  407. Message: "Duplicate entry 'abc' for key 'sys_product_PRIMARY'",
  408. }
  409. mockProduct := mocks.NewMockSysProductModel(ctrl)
  410. mockProduct.EXPECT().FindOneByCode(gomock.Any(), "m5_code").
  411. Return(nil, productModel.ErrNotFound)
  412. mockProduct.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
  413. DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
  414. return fn(ctx, nil)
  415. })
  416. // 直接让 InsertWithTx 冒出 1062
  417. mockProduct.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
  418. Return(nil, dupErr)
  419. mockUser := mocks.NewMockSysUserModel(ctrl)
  420. mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_m5_code").
  421. Return(nil, userModel.ErrNotFound)
  422. // CreateProduct 现在必填 AdminDeptId:外层 FindOne 做启用状态 pre-check,
  423. // tx 内部再用 FindOneForShareTx 取 S 锁闭合 pre-check→insert 之间的并发删除窗口(H-R17-1)。
  424. mockDept := mocks.NewMockSysDeptModel(ctrl)
  425. mockDept.EXPECT().FindOne(gomock.Any(), int64(77)).
  426. Return(&deptModel.SysDept{Id: 77, Path: "/77/", Status: 1}, nil)
  427. mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), int64(77)).
  428. Return(&deptModel.SysDept{Id: 77, Path: "/77/", Status: 1}, nil)
  429. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  430. Product: mockProduct,
  431. User: mockUser,
  432. Dept: mockDept,
  433. })
  434. resp, err := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateProduct(&types.CreateProductReq{
  435. Code: "m5_code",
  436. Name: "M5 Product",
  437. AdminDeptId: 77,
  438. })
  439. assert.Nil(t, resp)
  440. require.Error(t, err)
  441. var ce *response.CodeError
  442. require.True(t, errors.As(err, &ce), "必须是结构化 CodeError")
  443. assert.Equal(t, 409, ce.Code(),
  444. "任何 1062 都应统一返回 409;修复前不含 uk_code 的索引名会被吞成 500")
  445. assert.Contains(t, ce.Error(), "数据冲突",
  446. "错误消息应当是通用的'数据冲突,请稍后重试',不再尝试解析索引名文案")
  447. }
  448. // TC-1202: L-R17-2 —— CreateProduct 落盘的 admin_<code> 账号 Avatar 必须是 SQL NULL,
  449. // 不得被零值 `sql.NullString{""}` 悄悄写成空串。与 CreateUser 对齐,保证未来 SELECT JOIN
  450. // 判空语义稳定("空串"与"NULL"在 ORM / SDK 里不等价,会让业务分支误差)。
  451. func TestCreateProduct_L_R17_2_AdminUserAvatarIsExplicitNull(t *testing.T) {
  452. ctx := ctxhelper.SuperAdminCtx()
  453. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  454. conn := testutil.GetTestSqlConn()
  455. code := testutil.UniqueId()
  456. resp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  457. Code: code,
  458. Name: "avatar_null_" + code,
  459. AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  460. })
  461. require.NoError(t, err)
  462. require.NotNil(t, resp)
  463. t.Cleanup(func() {
  464. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  465. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  466. testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id)
  467. })
  468. var avatarIsNull int64
  469. require.NoError(t, conn.QueryRowCtx(ctx, &avatarIsNull,
  470. "SELECT CASE WHEN `avatar` IS NULL THEN 1 ELSE 0 END FROM `sys_user` WHERE `username`=?",
  471. "admin_"+code))
  472. assert.Equal(t, int64(1), avatarIsNull,
  473. "L-R17-2:admin_<code>.avatar 必须落 NULL(而非空串),与 CreateUser 口径对齐")
  474. }
  475. // TC-1203: H-R17-1 —— CreateProduct tx 内部必须对 adminDeptId 取 S 锁后再 Insert;
  476. // 若 FindOneForShareTx 返回 ErrNotFound(并发 DeleteDept 已提交),必须 400 "管理员部门不存在或已删除"
  477. // 且三张表(product/user/product_member)InsertWithTx 都不得被调用。
  478. func TestCreateProduct_H_R17_1_DeptConcurrentlyDeletedRejected(t *testing.T) {
  479. ctrl := gomock.NewController(t)
  480. t.Cleanup(ctrl.Finish)
  481. const pc = "h_r17_1_prod"
  482. const deptId = int64(777)
  483. mockProduct := mocks.NewMockSysProductModel(ctrl)
  484. mockProduct.EXPECT().FindOneByCode(gomock.Any(), pc).
  485. Return(nil, productModel.ErrNotFound)
  486. mockProduct.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
  487. DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
  488. return fn(ctx, nil)
  489. })
  490. // Critical: InsertWithTx 全系列都不得被调用——S 锁拿不到时应立刻 400 退出 tx。
  491. // 若未来 FindOneForShareTx 的返回被误忽略,gomock 会在未声明 EXPECT 的 Insert* 上立刻报错。
  492. mockUser := mocks.NewMockSysUserModel(ctrl)
  493. mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_"+pc).
  494. Return(nil, userModel.ErrNotFound)
  495. mockDept := mocks.NewMockSysDeptModel(ctrl)
  496. // 外层 pre-check 此刻还能读到启用状态的 dept(快照时间点早于 DeleteDept 提交)。
  497. mockDept.EXPECT().FindOne(gomock.Any(), deptId).
  498. Return(&deptModel.SysDept{Id: deptId, Path: "/777/", Status: 1}, nil)
  499. // tx 内的 FOR SHARE 读 —— DeleteDept 已先于本 tx 提交,回 ErrNotFound。
  500. mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), deptId).
  501. Return(nil, sqlx.ErrNotFound)
  502. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  503. Product: mockProduct,
  504. User: mockUser,
  505. Dept: mockDept,
  506. })
  507. resp, err := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateProduct(&types.CreateProductReq{
  508. Code: pc,
  509. Name: "H-R17-1",
  510. AdminDeptId: deptId,
  511. })
  512. assert.Nil(t, resp)
  513. require.Error(t, err)
  514. var ce *response.CodeError
  515. require.True(t, errors.As(err, &ce))
  516. assert.Equal(t, 400, ce.Code(),
  517. "H-R17-1:tx 内 FindOneForShareTx 命中 ErrNotFound 必须立刻 400,不能继续 Insert "+
  518. "导致 orphan admin 挂在 deptId=777 上")
  519. assert.Contains(t, ce.Error(), "管理员部门不存在或已删除",
  520. "文案必须点出'已删除'情形,帮助调用方立刻识别'并发 DeleteDept 胜出'场景")
  521. }