divide_handler_test.go 25 KB

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