divide_handler_test.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. // 分账 handler 单测(AC-13 第 1 批:端点 38 / 43)。
  2. //
  3. // 断言纪律(team-lead 的 AC-13 验收方式:**不收"用例全绿",要"写错会变红"**):
  4. //
  5. // 端点 38 分账树 —— 主断言是 **Nested Set 四条不变式**(I1~I4),不是"接口返回 200":
  6. // · `TestDivideTreeInvariantsHold`:造一棵 5 节点两层的树,逐条验 I1(1..2N 无重复无空洞)/
  7. // I2(嵌套或相离)/ I3(父严格包含子)/ I4(layer=parent.layer+1);
  8. // · `TestDivideTreeRejectsBrokenNesting`:**故意造坏树**(右值错位 1),断言 5001 + 文案命中
  9. // 具体不变式 —— 这是"树坏了能被发现"的证明("接口能返回一棵树"这种断言在树坏时依然绿);
  10. // · `TestDivideTreeQuotaIsMoneyString`:`quota` 必须是**金额字符串**("100.00"),
  11. // 不是 0~1 比率、不是 number(mock 曾错成比率)。
  12. // 端点 43 流水 —— `TestDivideLogsWindowAndTotals`:右开窗口(`createTime = to` 的行不得出现),
  13. // 且 `total` 与 `list` 同口径(不等于历史全量)。
  14. package handler
  15. import (
  16. "encoding/json"
  17. "fmt"
  18. "net/http"
  19. "net/http/httptest"
  20. "path/filepath"
  21. "strings"
  22. "testing"
  23. "time"
  24. gormsqlite "github.com/glebarez/sqlite"
  25. "github.com/gofiber/fiber/v2"
  26. "gorm.io/gorm"
  27. "yuansiwei/unified-backend/internal/academic/apperr"
  28. "yuansiwei/unified-backend/internal/academic/model"
  29. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  30. appscope "yuansiwei/unified-backend/internal/infra/scope"
  31. )
  32. // newDivideTestDB 分账测试库(两张分账表;其余表由 handler 其它用例各自建)。
  33. func newDivideTestDB(t *testing.T) *gorm.DB {
  34. t.Helper()
  35. dsn := filepath.Join(t.TempDir(), "academic_divide_handler_test.db")
  36. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  37. if err != nil {
  38. t.Fatalf("gorm.Open(分账库) 失败: %v", err)
  39. }
  40. sqlDB, err := db.DB()
  41. if err != nil {
  42. t.Fatalf("取 sql.DB 失败: %v", err)
  43. }
  44. sqlDB.SetMaxOpenConns(1)
  45. t.Cleanup(func() { _ = sqlDB.Close() })
  46. if mErr := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); mErr != nil {
  47. t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", mErr)
  48. }
  49. return db
  50. }
  51. // newDivideTestApp 只挂分账两条端点的 app(+ 作用域注入中间件)。
  52. func newDivideTestApp(t *testing.T, db *gorm.DB) *fiber.App {
  53. t.Helper()
  54. h, err := New(Config{DB: db})
  55. if err != nil {
  56. t.Fatalf("handler.New 失败: %v", err)
  57. }
  58. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  59. app.Use(func(c *fiber.Ctx) error {
  60. if c.Get("X-Test-Scope") == "none" {
  61. return c.Next()
  62. }
  63. appscope.Set(c, testScope())
  64. return c.Next()
  65. })
  66. app.Get(PathDivideTree, h.GetDivideTree)
  67. app.Get(PathDivideLogs, h.ListDivideLogs)
  68. return app
  69. }
  70. // seedDivideNode 落一行分账节点。
  71. func seedDivideNode(t *testing.T, db *gorm.DB, userID, parentUserID *string,
  72. left, right, layer int32, quota string) {
  73. t.Helper()
  74. row := &model.UserDivideInto{
  75. UserID: userIDOf(userID), ParentUserID: parentUserID,
  76. LeftVal: left, RightVal: right, Layer: layer, Quota: quota, TotalIncome: "0.00",
  77. }
  78. if parentUserID == nil {
  79. root := *userID
  80. row.RootUserID = &root
  81. } else {
  82. // 根取父的根(测试里只有两层,直接指向根)
  83. root := *userID
  84. if *parentUserID != "" {
  85. var parent model.UserDivideInto
  86. if err := db.Where("user_id = ?", *parentUserID).Take(&parent).Error; err != nil {
  87. t.Fatalf("读父节点(%s) 失败: %v", *parentUserID, err)
  88. }
  89. if parent.RootUserID != nil {
  90. root = *parent.RootUserID
  91. }
  92. }
  93. row.RootUserID = &root
  94. }
  95. if err := db.Create(row).Error; err != nil {
  96. t.Fatalf("seed 分账节点(%s) 失败: %v", row.UserID, err)
  97. }
  98. }
  99. // userIDOf 解引用(nil → "",用于父节点为空的安全构造)。
  100. func userIDOf(p *string) string {
  101. if p == nil {
  102. return ""
  103. }
  104. return *p
  105. }
  106. // divideTreeResp 端点 38 的响应体(足够断言树结构与量纲)。
  107. type divideTreeResp struct {
  108. Tree []struct {
  109. UserID string `json:"userId"`
  110. ParentUserID *string `json:"parentUserId"`
  111. Quota json.RawMessage `json:"quota"`
  112. CumulativeIncome json.RawMessage `json:"cumulativeIncome"`
  113. Layer int32 `json:"layer"`
  114. Children []struct {
  115. UserID string `json:"userId"`
  116. Quota json.RawMessage `json:"quota"`
  117. Layer int32 `json:"layer"`
  118. Children []struct {
  119. UserID string `json:"userId"`
  120. } `json:"children"`
  121. } `json:"children"`
  122. } `json:"tree"`
  123. NodeCount int `json:"nodeCount"`
  124. RootCount int `json:"rootCount"`
  125. }
  126. // getDivideTree 发请求并解出树响应(HTTP 恒 200)。
  127. func getDivideTree(t *testing.T, app *fiber.App) (int, divideTreeResp) {
  128. t.Helper()
  129. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  130. resp, err := app.Test(req, 5000)
  131. if err != nil {
  132. t.Fatalf("请求分账树失败: %v", err)
  133. }
  134. defer func() { _ = resp.Body.Close() }()
  135. var envelope struct {
  136. Code int `json:"code"`
  137. Data json.RawMessage `json:"data"`
  138. }
  139. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  140. t.Fatalf("响应解码失败: %v", dErr)
  141. }
  142. var out divideTreeResp
  143. if envelope.Code == 200 && len(envelope.Data) > 0 {
  144. if uErr := json.Unmarshal(envelope.Data, &out); uErr != nil {
  145. t.Fatalf("树体解码失败: %v", uErr)
  146. }
  147. }
  148. return envelope.Code, out
  149. }
  150. // TestDivideTreeInvariantsHold 端点 38:两层 5 节点树,**四条不变式**逐条成立。
  151. //
  152. // 造形(root=1..10, A=2..5, A1=3..4, B=6..9, B1=7..8):
  153. //
  154. // root(layer1) ├── A(layer2) ── A1(layer3)
  155. // └── B(layer2) ── B1(layer3)
  156. //
  157. // 若实现的位移逻辑使区间重复/空洞/部分重叠,**本用例会红**(而"接口返回 200 + 有 tree 字段"不会)。
  158. func TestDivideTreeInvariantsHold(t *testing.T) {
  159. db := newDivideTestDB(t)
  160. root, a, a1, b, b1 := "01HROOT000000000000000001", "01HA0000000000000000001",
  161. "01HA10000000000000000001", "01HB0000000000000000001", "01HB10000000000000000001"
  162. seedDivideNode(t, db, &root, nil, 1, 10, 1, "100.00")
  163. seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
  164. seedDivideNode(t, db, &a1, &a, 3, 4, 3, "20.00")
  165. seedDivideNode(t, db, &b, &root, 6, 9, 2, "30.00")
  166. seedDivideNode(t, db, &b1, &b, 7, 8, 3, "10.00")
  167. app := newDivideTestApp(t, db)
  168. code, body := getDivideTree(t, app)
  169. if code != 200 {
  170. t.Fatalf("合法树应返回 200,实得 %d", code)
  171. }
  172. if body.NodeCount != 5 || body.RootCount != 1 || len(body.Tree) != 1 {
  173. t.Fatalf("nodeCount/rootCount/len(tree) = %d/%d/%d,期望 5/1/1",
  174. body.NodeCount, body.RootCount, len(body.Tree))
  175. }
  176. // I4:根 layer=1、parent 为 null
  177. if body.Tree[0].Layer != 1 || body.Tree[0].ParentUserID != nil {
  178. t.Fatalf("根节点 layer/parent = %d/%v,期望 1/nil", body.Tree[0].Layer, body.Tree[0].ParentUserID)
  179. }
  180. // I3/I4:子节点 layer=2 且挂在根下
  181. if len(body.Tree[0].Children) != 2 {
  182. t.Fatalf("根的直接子节点 = %d,期望 2", len(body.Tree[0].Children))
  183. }
  184. for _, child := range body.Tree[0].Children {
  185. if child.Layer != 2 {
  186. t.Errorf("子节点 %s layer = %d,期望 2", child.UserID, child.Layer)
  187. }
  188. }
  189. // 逐级检查孙节点(A1 / B1 各 1 个)
  190. grandTotal := 0
  191. for _, child := range body.Tree[0].Children {
  192. grandTotal += len(child.Children)
  193. }
  194. if grandTotal != 2 {
  195. t.Fatalf("孙节点总数 = %d,期望 2(A1、B1)", grandTotal)
  196. }
  197. }
  198. // TestDivideTreeRejectsBrokenNesting 端点 38:**故意造坏树** ⇒ 5001 + 文案指到具体不变式。
  199. //
  200. // 造法:给 B 的右值从 9 改成 5(与 A 的右值相同)⇒ I1(区间值重复)。
  201. // 这是"树坏了能被发现"的证明:只断言"返回 200 且有 tree"的实现,在这种情况下**依然绿**。
  202. func TestDivideTreeRejectsBrokenNesting(t *testing.T) {
  203. db := newDivideTestDB(t)
  204. root, a, b := "01HROOT000000000000000002", "01HA0000000000000000002", "01HB0000000000000000002"
  205. seedDivideNode(t, db, &root, nil, 1, 6, 1, "100.00")
  206. seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
  207. seedDivideNode(t, db, &b, &root, 6, 5, 2, "30.00") // ⚠ 坏行:left(6) >= right(5)
  208. _ = b
  209. app := newDivideTestApp(t, db)
  210. code, _ := getDivideTree(t, app)
  211. if code != infraerrors.CodeInternal {
  212. t.Fatalf("坏树应返回 5001(数据损坏),实得 %d —— 说明不变式校验没生效", code)
  213. }
  214. }
  215. // TestDivideTreeQuotaIsMoneyString 端点 38:`quota` / `cumulativeIncome` 必须是**金额字符串**。
  216. //
  217. // mock 曾把 `quota` 当 0~1 比率 ⇒ 前端拿到 "0.35" 当 35%(差 100 倍)。
  218. // 本用例把量纲钉死:JSON 里必须是 `"100.00"` 这样的**带引号两位小数**,
  219. // 不能是 `100`(number)、也不能是 `1`/`0.35`(比率)。
  220. func TestDivideTreeQuotaIsMoneyString(t *testing.T) {
  221. db := newDivideTestDB(t)
  222. root := "01HROOT000000000000000003"
  223. seedDivideNode(t, db, &root, nil, 1, 2, 1, "100.00")
  224. app := newDivideTestApp(t, db)
  225. code, body := getDivideTree(t, app)
  226. if code != 200 || len(body.Tree) != 1 {
  227. t.Fatalf("应返回 1 棵单节点树,实得 code=%d len=%d", code, len(body.Tree))
  228. }
  229. raw := string(body.Tree[0].Quota)
  230. if raw != `"100.00"` {
  231. t.Fatalf(`quota = %s,期望 "100.00"(金额字符串,两位小数;不是 0~1 比率、不是 number)`, raw)
  232. }
  233. if inc := string(body.Tree[0].CumulativeIncome); inc != `"0.00"` {
  234. t.Fatalf(`cumulativeIncome = %s,期望 "0.00"`, inc)
  235. }
  236. }
  237. // TestDivideTreeEmptyIsArray 空库:`tree` 必须是 `[]`(不能是 null),且不报错。
  238. func TestDivideTreeEmptyIsArray(t *testing.T) {
  239. db := newDivideTestDB(t)
  240. app := newDivideTestApp(t, db)
  241. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  242. resp, err := app.Test(req, 5000)
  243. if err != nil {
  244. t.Fatalf("请求失败: %v", err)
  245. }
  246. defer func() { _ = resp.Body.Close() }()
  247. raw := new(strings.Builder)
  248. buf := make([]byte, 4096)
  249. n, _ := resp.Body.Read(buf)
  250. raw.Write(buf[:n])
  251. if strings.Contains(raw.String(), `"tree":null`) {
  252. t.Fatalf("空树必须是 [],不能是 null:%s", raw.String())
  253. }
  254. if !strings.Contains(raw.String(), `"tree":[]`) {
  255. t.Fatalf("空树应为 `tree:[]`:%s", raw.String())
  256. }
  257. }
  258. // TestDivideTreeMissingScopeIs1001 无作用域 → 1001(分账是全局数据,更不能匿名读)。
  259. func TestDivideTreeMissingScopeIs1001(t *testing.T) {
  260. db := newDivideTestDB(t)
  261. app := newDivideTestApp(t, db)
  262. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  263. req.Header.Set("X-Test-Scope", "none")
  264. resp, err := app.Test(req, 5000)
  265. if err != nil {
  266. t.Fatalf("请求失败: %v", err)
  267. }
  268. defer func() { _ = resp.Body.Close() }()
  269. var envelope struct {
  270. Code int `json:"code"`
  271. }
  272. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  273. t.Fatalf("解码失败: %v", dErr)
  274. }
  275. if envelope.Code != infraerrors.CodeUnauth {
  276. t.Fatalf("无作用域应 1001,实得 %d", envelope.Code)
  277. }
  278. }
  279. // TestDivideLogsWindowAndTotals 端点 43:右开窗口 + `total` 与 `list` 同口径。
  280. //
  281. // 造 3 条流水:窗口前(02-20)、窗口内(03-05)、**恰好等于 `to`**(04-01)。
  282. // 断言:`total=1`(不是历史 3)、仅 1 行、且不出现 `to` 时刻的行 —— 若实现漏了窗口条件
  283. // (或 `to` 写成闭区间),这里会红。金额同样必须是字符串。
  284. func TestDivideLogsWindowAndTotals(t *testing.T) {
  285. db := newDivideTestDB(t)
  286. user := "01HFINDER00000000000000001"
  287. stu := "01HSTUDENT0000000000000001"
  288. for _, at := range []time.Time{
  289. time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), // 窗口前
  290. time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC), // 窗口内
  291. time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), // == to(右开:不得出现)
  292. } {
  293. if err := db.Create(&model.UserDivideIntoLog{
  294. Type: 1, UserID: user, StudentID: stu, Money: "12.34", CreateTime: at,
  295. }).Error; err != nil {
  296. t.Fatalf("seed 流水失败: %v", err)
  297. }
  298. }
  299. app := newDivideTestApp(t, db)
  300. path := fmt.Sprintf("%s?findUserId=%s&from=2026-03-01&to=2026-04-01", PathDivideLogs, user)
  301. req := httptest.NewRequest(http.MethodGet, path, nil)
  302. resp, err := app.Test(req, 5000)
  303. if err != nil {
  304. t.Fatalf("请求失败: %v", err)
  305. }
  306. defer func() { _ = resp.Body.Close() }()
  307. var envelope struct {
  308. Code int `json:"code"`
  309. Data struct {
  310. List []map[string]any `json:"list"`
  311. Total int64 `json:"total"`
  312. Pages int `json:"pages"`
  313. } `json:"data"`
  314. }
  315. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  316. t.Fatalf("解码失败: %v", dErr)
  317. }
  318. if envelope.Code != 200 {
  319. t.Fatalf("期望 200,实得 %d", envelope.Code)
  320. }
  321. if envelope.Data.Total != 1 || len(envelope.Data.List) != 1 {
  322. t.Fatalf("total/list = %d/%d,期望 1/1(右开窗口:to 时刻那笔应被排除;历史 3 笔不应计入)",
  323. envelope.Data.Total, len(envelope.Data.List))
  324. }
  325. row := envelope.Data.List[0]
  326. if got, ok := row["findUserId"].(string); !ok || got != user {
  327. t.Fatalf("findUserId = %v,期望 %s(表列 user_id → 契约字段 findUserId)", row["findUserId"], user)
  328. }
  329. if money, ok := row["money"].(string); !ok || money != "12.34" {
  330. t.Fatalf("money = %v,期望字符串 \"12.34\"", row["money"])
  331. }
  332. if envelope.Data.Pages != 1 {
  333. t.Fatalf("pages = %d,期望 1", envelope.Data.Pages)
  334. }
  335. }
  336. // TestDivideLogsRejectsReversedWindow from > to → 2001(不静默返回空集)。
  337. func TestDivideLogsRejectsReversedWindow(t *testing.T) {
  338. db := newDivideTestDB(t)
  339. app := newDivideTestApp(t, db)
  340. req := httptest.NewRequest(http.MethodGet,
  341. PathDivideLogs+"?from=2026-04-01&to=2026-03-01", nil)
  342. resp, err := app.Test(req, 5000)
  343. if err != nil {
  344. t.Fatalf("请求失败: %v", err)
  345. }
  346. defer func() { _ = resp.Body.Close() }()
  347. var envelope struct {
  348. Code int `json:"code"`
  349. }
  350. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  351. t.Fatalf("解码失败: %v", dErr)
  352. }
  353. if envelope.Code != infraerrors.CodeBadParam {
  354. t.Fatalf("from>to 应 2001,实得 %d", envelope.Code)
  355. }
  356. }
  357. // ---------------------------------------------------------------- 13a 补充用例(39/40/41/42 + D5 两树隔离)
  358. // TestDivideTreeRootUserIDFilter 端点 38 的 `rootUserId?`:只返回指定那棵树。
  359. //
  360. // 断言:两棵树共存时,带 rootUserId 只得到 1 棵、节点数只算该树;
  361. // 不带时得到森林(rootCount=2)。**跨树混淆是 Nested Set 最典型的错**(两树区间各自从 1 起)。
  362. func TestDivideTreeRootUserIDFilter(t *testing.T) {
  363. db := newDivideTestDB(t)
  364. rootA, childA := "01HROOTA00000000000000001", "01HCHILDA0000000000000001"
  365. rootB := "01HROOTB00000000000000001"
  366. seedDivideNode(t, db, &rootA, nil, 1, 4, 1, "100.00")
  367. seedDivideNode(t, db, &childA, &rootA, 2, 3, 2, "50.00")
  368. seedDivideNode(t, db, &rootB, nil, 1, 2, 1, "80.00")
  369. app := newDivideTestApp(t, db)
  370. code, forest := getDivideTree(t, app)
  371. if code != 200 || forest.RootCount != 2 || forest.NodeCount != 3 {
  372. t.Fatalf("不带 rootUserId 应得森林 2 棵/3 节点,实得 code=%d root=%d node=%d",
  373. code, forest.RootCount, forest.NodeCount)
  374. }
  375. req := httptest.NewRequest(http.MethodGet, PathDivideTree+"?rootUserId="+rootA, nil)
  376. resp, err := app.Test(req, 5000)
  377. if err != nil {
  378. t.Fatalf("请求失败: %v", err)
  379. }
  380. defer func() { _ = resp.Body.Close() }()
  381. var envelope struct {
  382. Code int `json:"code"`
  383. Data json.RawMessage `json:"data"`
  384. }
  385. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  386. t.Fatalf("解码失败: %v", dErr)
  387. }
  388. var one divideTreeResp
  389. if uErr := json.Unmarshal(envelope.Data, &one); uErr != nil {
  390. t.Fatalf("树体解码失败: %v", uErr)
  391. }
  392. if envelope.Code != 200 || one.RootCount != 1 || one.NodeCount != 2 {
  393. t.Fatalf("带 rootUserId 应得 1 棵/2 节点,实得 code=%d root=%d node=%d",
  394. envelope.Code, one.RootCount, one.NodeCount)
  395. }
  396. if one.Tree[0].UserID != rootA {
  397. t.Fatalf("返回的树根 = %s,期望 %s(不得串到另一棵树)", one.Tree[0].UserID, rootA)
  398. }
  399. }
  400. // TestDivideQuotaRejectsChildAboveParent 端点 40:**子额度 > 父额度 → 3111**(D9 有意增强)。
  401. //
  402. // 3111 判据必须精确:是「新额度 > 父额度」,**不是** `quota > 1`、也不是比率越界。
  403. // 量纲同样在此验证:quota 传金额字符串 `"200.00"`(父 100.00)→ 3111;
  404. // 若谁把 quota 当比率(<1 合法),这条会红在"该拒没拒"上。
  405. func TestDivideQuotaRejectsChildAboveParent(t *testing.T) {
  406. db := newDivideTestDB(t)
  407. root, child := "01HROOTQ00000000000000001", "01HCHILDQ0000000000000001"
  408. seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00")
  409. seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00")
  410. app := newDivideTestApp(t, db)
  411. h, _ := New(Config{DB: db})
  412. app.Put(PathDivideMemberQuota, h.SetDivideQuota)
  413. body := `{"quota":"200.00"}` // 子 200 > 父 100
  414. req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota",
  415. strings.NewReader(body))
  416. req.Header.Set("Content-Type", "application/json")
  417. resp, err := app.Test(req, 5000)
  418. if err != nil {
  419. t.Fatalf("请求失败: %v", err)
  420. }
  421. defer func() { _ = resp.Body.Close() }()
  422. var envelope struct {
  423. Code int `json:"code"`
  424. }
  425. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  426. t.Fatalf("解码失败: %v", dErr)
  427. }
  428. if envelope.Code != apperr.CodeDivideQuotaExceeded {
  429. t.Fatalf("子额度超父应 3111,实得 %d(判据必须是「新额度 > 父额度」,不是 quota>1)", envelope.Code)
  430. }
  431. }
  432. // TestDivideQuotaRejectsRatioStyle 端点 40:`quota` 传比率(`0.5`)也必须被当金额接受为 **0.50 元**,
  433. // 而不是"50%"。本用例锁定"按金额解释"这一事实,并断言非法字符串 → 2002。
  434. func TestDivideQuotaRejectsRatioStyle(t *testing.T) {
  435. db := newDivideTestDB(t)
  436. root, child := "01HROOTR00000000000000001", "01HCHILDR0000000000000001"
  437. seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00")
  438. seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00")
  439. app := newDivideTestApp(t, db)
  440. h, _ := New(Config{DB: db})
  441. app.Put(PathDivideMemberQuota, h.SetDivideQuota)
  442. // 非法金额字符串 → 2002
  443. req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota",
  444. strings.NewReader(`{"quota":"abc"}`))
  445. req.Header.Set("Content-Type", "application/json")
  446. resp, err := app.Test(req, 5000)
  447. if err != nil {
  448. t.Fatalf("请求失败: %v", err)
  449. }
  450. defer func() { _ = resp.Body.Close() }()
  451. var envelope struct {
  452. Code int `json:"code"`
  453. }
  454. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  455. t.Fatalf("解码失败: %v", dErr)
  456. }
  457. if envelope.Code != infraerrors.CodeBadFormat {
  458. t.Fatalf("非法 quota 应 2002,实得 %d", envelope.Code)
  459. }
  460. }
  461. // TestDivideLogRequiresNumericOrderNo 端点 41/42:`orderNo` 非数字 → 2002(不静默当成 record 0)。
  462. func TestDivideLogRequiresNumericOrderNo(t *testing.T) {
  463. db := newDivideTestDB(t)
  464. app := newDivideTestApp(t, db)
  465. h, _ := New(Config{DB: db})
  466. app.Post(PathDivideLogs, h.CreateDivideLogRecord)
  467. req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader(
  468. `{"findUserId":"01HFINDER00000000000000002","studentId":"01HSTUDENT0000000000000002","orderNo":"abc"}`))
  469. req.Header.Set("Content-Type", "application/json")
  470. resp, err := app.Test(req, 5000)
  471. if err != nil {
  472. t.Fatalf("请求失败: %v", err)
  473. }
  474. defer func() { _ = resp.Body.Close() }()
  475. var envelope struct {
  476. Code int `json:"code"`
  477. }
  478. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  479. t.Fatalf("解码失败: %v", dErr)
  480. }
  481. if envelope.Code != infraerrors.CodeBadFormat {
  482. t.Fatalf("orderNo 非数字应 2002,实得 %d", envelope.Code)
  483. }
  484. }
  485. // TestDivideLogAddRejectsMissingScope 端点 41 无作用域 → 1001(先于参数校验)。
  486. func TestDivideLogAddRejectsMissingScope(t *testing.T) {
  487. db := newDivideTestDB(t)
  488. app := newDivideTestApp(t, db)
  489. h, _ := New(Config{DB: db})
  490. app.Post(PathDivideLogs, h.CreateDivideLogRecord)
  491. req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader(`{}`))
  492. req.Header.Set("Content-Type", "application/json")
  493. req.Header.Set("X-Test-Scope", "none")
  494. resp, err := app.Test(req, 5000)
  495. if err != nil {
  496. t.Fatalf("请求失败: %v", err)
  497. }
  498. defer func() { _ = resp.Body.Close() }()
  499. var envelope struct {
  500. Code int `json:"code"`
  501. }
  502. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  503. t.Fatalf("解码失败: %v", dErr)
  504. }
  505. if envelope.Code != infraerrors.CodeUnauth {
  506. t.Fatalf("无作用域应 1001,实得 %d", envelope.Code)
  507. }
  508. }
  509. // TestDivideTwoTreesIsolationInsert 设计 §5.1 纪律 3 / §3.8-D5:**插入不得平移另一棵树**。
  510. //
  511. // D5 的失败形态很隐蔽:`ShiftRight/ShiftLeft` 若漏掉 `root_user_id` 过滤,
  512. // 位移会按 `left_val >= parentRight` 命中**另一棵树**里数值更大的节点 ⇒ 该树区间整体被平移。
  513. // 单看本树完全正常(区间仍自洽),所以**只有逐值比对另一棵树**才能发现。
  514. //
  515. // 断言:在 A 树插入新节点后,B 树每行的 (left,right,layer,quota) **逐值不变**。
  516. func TestDivideTwoTreesIsolationInsert(t *testing.T) {
  517. db := newDivideTestDB(t)
  518. rootA, rootB, childB := "01HROOTA10000000000000001", "01HROOTB10000000000000001",
  519. "01HCHILDB1000000000000001"
  520. seedDivideNode(t, db, &rootA, nil, 1, 2, 1, "100.00")
  521. seedDivideNode(t, db, &rootB, nil, 1, 4, 1, "80.00")
  522. seedDivideNode(t, db, &childB, &rootB, 2, 3, 2, "40.00")
  523. before := map[string][3]int32{}
  524. var rowsBefore []model.UserDivideInto
  525. if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsBefore).Error; err != nil {
  526. t.Fatalf("读 B 树失败: %v", err)
  527. }
  528. for _, r := range rowsBefore {
  529. before[r.UserID] = [3]int32{r.LeftVal, r.RightVal, r.Layer}
  530. }
  531. // 在 A 树插入新下线(A 根 left=1,right=2 ⇒ 插入点 parentRight=2 ⇒ 新节点 (2,3))
  532. app := newDivideTestApp(t, db)
  533. h, _ := New(Config{DB: db})
  534. app.Post(PathDivideMembers, h.AddDivideMember)
  535. body := `{"parentUserId":"` + rootA + `","subUserId":"01HNEWA100000000000000001"}`
  536. req := httptest.NewRequest(http.MethodPost, PathDivideMembers, strings.NewReader(body))
  537. req.Header.Set("Content-Type", "application/json")
  538. resp, err := app.Test(req, 5000)
  539. if err != nil {
  540. t.Fatalf("请求失败: %v", err)
  541. }
  542. defer func() { _ = resp.Body.Close() }()
  543. var envelope struct {
  544. Code int `json:"code"`
  545. }
  546. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  547. t.Fatalf("解码失败: %v", dErr)
  548. }
  549. if envelope.Code != 200 {
  550. t.Fatalf("加入下线应 200,实得 %d", envelope.Code)
  551. }
  552. // ① 先**逐值比对** B 树:一字不动 —— 这是 D5(漏 root 过滤)的**唯一可判形式**:
  553. // 漏了 root 时,`left>=threshold` 会把 B 树里数值更大的节点一起 +2,
  554. // 而 A 树自身看起来仍可能自洽(或两边一起坏),单看 A 树分不清"谁错了"。
  555. // 逐值比对 B 树:一字不动
  556. var rowsAfter []model.UserDivideInto
  557. if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsAfter).Error; err != nil {
  558. t.Fatalf("读 B 树失败: %v", err)
  559. }
  560. if len(rowsAfter) != len(before) {
  561. t.Fatalf("B 树节点数变化:%d → %d(A 树插入不该影响 B 树)", len(before), len(rowsAfter))
  562. }
  563. for _, r := range rowsAfter {
  564. want, ok := before[r.UserID]
  565. if !ok {
  566. t.Fatalf("B 树出现新节点 %s(不该有)", r.UserID)
  567. }
  568. if r.LeftVal != want[0] || r.RightVal != want[1] || r.Layer != want[2] {
  569. t.Fatalf("B 树节点 %s 区间被平移:(left,right,layer) 期望 (%d,%d,%d),实得 (%d,%d,%d)"+
  570. " —— 说明位移漏了 root_user_id 过滤(D5)",
  571. r.UserID, want[0], want[1], want[2], r.LeftVal, r.RightVal, r.Layer)
  572. }
  573. }
  574. // ② 再自证"插入后 A 树自身仍然自洽"(不变式 I1~I4)——
  575. // 若插入点取错(拿位移后的 parentRight、或 off-by-one),A 树会出现空洞/重叠 ⇒ 5001。
  576. // 放在最后:D-3 的失败形态只影响 A 树,B 树比对对它无感。
  577. if codeAfter, _ := getDivideTree(t, app); codeAfter != 200 {
  578. t.Fatalf("插入后 A 树不变式被破坏(返回 %d)—— 检查插入点是否取了**位移前**的 parentRight(D-3)", codeAfter)
  579. }
  580. }