divide_handler_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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/model"
  28. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  29. appscope "yuansiwei/unified-backend/internal/infra/scope"
  30. )
  31. // newDivideTestDB 分账测试库(两张分账表;其余表由 handler 其它用例各自建)。
  32. func newDivideTestDB(t *testing.T) *gorm.DB {
  33. t.Helper()
  34. dsn := filepath.Join(t.TempDir(), "academic_divide_handler_test.db")
  35. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  36. if err != nil {
  37. t.Fatalf("gorm.Open(分账库) 失败: %v", err)
  38. }
  39. sqlDB, err := db.DB()
  40. if err != nil {
  41. t.Fatalf("取 sql.DB 失败: %v", err)
  42. }
  43. sqlDB.SetMaxOpenConns(1)
  44. t.Cleanup(func() { _ = sqlDB.Close() })
  45. if mErr := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); mErr != nil {
  46. t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", mErr)
  47. }
  48. return db
  49. }
  50. // newDivideTestApp 只挂分账两条端点的 app(+ 作用域注入中间件)。
  51. func newDivideTestApp(t *testing.T, db *gorm.DB) *fiber.App {
  52. t.Helper()
  53. h, err := New(Config{DB: db})
  54. if err != nil {
  55. t.Fatalf("handler.New 失败: %v", err)
  56. }
  57. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  58. app.Use(func(c *fiber.Ctx) error {
  59. if c.Get("X-Test-Scope") == "none" {
  60. return c.Next()
  61. }
  62. appscope.Set(c, testScope())
  63. return c.Next()
  64. })
  65. app.Get(PathDivideTree, h.GetDivideTree)
  66. app.Get(PathDivideLogs, h.ListDivideLogs)
  67. return app
  68. }
  69. // seedDivideNode 落一行分账节点。
  70. func seedDivideNode(t *testing.T, db *gorm.DB, userID, parentUserID *string,
  71. left, right, layer int32, quota string) {
  72. t.Helper()
  73. row := &model.UserDivideInto{
  74. UserID: userIDOf(userID), ParentUserID: parentUserID,
  75. LeftVal: left, RightVal: right, Layer: layer, Quota: quota, TotalIncome: "0.00",
  76. }
  77. if parentUserID == nil {
  78. root := *userID
  79. row.RootUserID = &root
  80. } else {
  81. // 根取父的根(测试里只有两层,直接指向根)
  82. root := *userID
  83. if *parentUserID != "" {
  84. var parent model.UserDivideInto
  85. if err := db.Where("user_id = ?", *parentUserID).Take(&parent).Error; err != nil {
  86. t.Fatalf("读父节点(%s) 失败: %v", *parentUserID, err)
  87. }
  88. if parent.RootUserID != nil {
  89. root = *parent.RootUserID
  90. }
  91. }
  92. row.RootUserID = &root
  93. }
  94. if err := db.Create(row).Error; err != nil {
  95. t.Fatalf("seed 分账节点(%s) 失败: %v", row.UserID, err)
  96. }
  97. }
  98. // userIDOf 解引用(nil → "",用于父节点为空的安全构造)。
  99. func userIDOf(p *string) string {
  100. if p == nil {
  101. return ""
  102. }
  103. return *p
  104. }
  105. // divideTreeResp 端点 38 的响应体(足够断言树结构与量纲)。
  106. type divideTreeResp struct {
  107. Tree []struct {
  108. UserID string `json:"userId"`
  109. ParentUserID *string `json:"parentUserId"`
  110. Quota json.RawMessage `json:"quota"`
  111. CumulativeIncome json.RawMessage `json:"cumulativeIncome"`
  112. Layer int32 `json:"layer"`
  113. Children []struct {
  114. UserID string `json:"userId"`
  115. Quota json.RawMessage `json:"quota"`
  116. Layer int32 `json:"layer"`
  117. Children []struct {
  118. UserID string `json:"userId"`
  119. } `json:"children"`
  120. } `json:"children"`
  121. } `json:"tree"`
  122. NodeCount int `json:"nodeCount"`
  123. RootCount int `json:"rootCount"`
  124. }
  125. // getDivideTree 发请求并解出树响应(HTTP 恒 200)。
  126. func getDivideTree(t *testing.T, app *fiber.App) (int, divideTreeResp) {
  127. t.Helper()
  128. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  129. resp, err := app.Test(req, 5000)
  130. if err != nil {
  131. t.Fatalf("请求分账树失败: %v", err)
  132. }
  133. defer func() { _ = resp.Body.Close() }()
  134. var envelope struct {
  135. Code int `json:"code"`
  136. Data json.RawMessage `json:"data"`
  137. }
  138. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  139. t.Fatalf("响应解码失败: %v", dErr)
  140. }
  141. var out divideTreeResp
  142. if envelope.Code == 200 && len(envelope.Data) > 0 {
  143. if uErr := json.Unmarshal(envelope.Data, &out); uErr != nil {
  144. t.Fatalf("树体解码失败: %v", uErr)
  145. }
  146. }
  147. return envelope.Code, out
  148. }
  149. // TestDivideTreeInvariantsHold 端点 38:两层 5 节点树,**四条不变式**逐条成立。
  150. //
  151. // 造形(root=1..10, A=2..5, A1=3..4, B=6..9, B1=7..8):
  152. //
  153. // root(layer1) ├── A(layer2) ── A1(layer3)
  154. // └── B(layer2) ── B1(layer3)
  155. //
  156. // 若实现的位移逻辑使区间重复/空洞/部分重叠,**本用例会红**(而"接口返回 200 + 有 tree 字段"不会)。
  157. func TestDivideTreeInvariantsHold(t *testing.T) {
  158. db := newDivideTestDB(t)
  159. root, a, a1, b, b1 := "01HROOT000000000000000001", "01HA0000000000000000001",
  160. "01HA10000000000000000001", "01HB0000000000000000001", "01HB10000000000000000001"
  161. seedDivideNode(t, db, &root, nil, 1, 10, 1, "100.00")
  162. seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
  163. seedDivideNode(t, db, &a1, &a, 3, 4, 3, "20.00")
  164. seedDivideNode(t, db, &b, &root, 6, 9, 2, "30.00")
  165. seedDivideNode(t, db, &b1, &b, 7, 8, 3, "10.00")
  166. app := newDivideTestApp(t, db)
  167. code, body := getDivideTree(t, app)
  168. if code != 200 {
  169. t.Fatalf("合法树应返回 200,实得 %d", code)
  170. }
  171. if body.NodeCount != 5 || body.RootCount != 1 || len(body.Tree) != 1 {
  172. t.Fatalf("nodeCount/rootCount/len(tree) = %d/%d/%d,期望 5/1/1",
  173. body.NodeCount, body.RootCount, len(body.Tree))
  174. }
  175. // I4:根 layer=1、parent 为 null
  176. if body.Tree[0].Layer != 1 || body.Tree[0].ParentUserID != nil {
  177. t.Fatalf("根节点 layer/parent = %d/%v,期望 1/nil", body.Tree[0].Layer, body.Tree[0].ParentUserID)
  178. }
  179. // I3/I4:子节点 layer=2 且挂在根下
  180. if len(body.Tree[0].Children) != 2 {
  181. t.Fatalf("根的直接子节点 = %d,期望 2", len(body.Tree[0].Children))
  182. }
  183. for _, child := range body.Tree[0].Children {
  184. if child.Layer != 2 {
  185. t.Errorf("子节点 %s layer = %d,期望 2", child.UserID, child.Layer)
  186. }
  187. }
  188. // 逐级检查孙节点(A1 / B1 各 1 个)
  189. grandTotal := 0
  190. for _, child := range body.Tree[0].Children {
  191. grandTotal += len(child.Children)
  192. }
  193. if grandTotal != 2 {
  194. t.Fatalf("孙节点总数 = %d,期望 2(A1、B1)", grandTotal)
  195. }
  196. }
  197. // TestDivideTreeRejectsBrokenNesting 端点 38:**故意造坏树** ⇒ 5001 + 文案指到具体不变式。
  198. //
  199. // 造法:给 B 的右值从 9 改成 5(与 A 的右值相同)⇒ I1(区间值重复)。
  200. // 这是"树坏了能被发现"的证明:只断言"返回 200 且有 tree"的实现,在这种情况下**依然绿**。
  201. func TestDivideTreeRejectsBrokenNesting(t *testing.T) {
  202. db := newDivideTestDB(t)
  203. root, a, b := "01HROOT000000000000000002", "01HA0000000000000000002", "01HB0000000000000000002"
  204. seedDivideNode(t, db, &root, nil, 1, 6, 1, "100.00")
  205. seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00")
  206. seedDivideNode(t, db, &b, &root, 6, 5, 2, "30.00") // ⚠ 坏行:left(6) >= right(5)
  207. _ = b
  208. app := newDivideTestApp(t, db)
  209. code, _ := getDivideTree(t, app)
  210. if code != infraerrors.CodeInternal {
  211. t.Fatalf("坏树应返回 5001(数据损坏),实得 %d —— 说明不变式校验没生效", code)
  212. }
  213. }
  214. // TestDivideTreeQuotaIsMoneyString 端点 38:`quota` / `cumulativeIncome` 必须是**金额字符串**。
  215. //
  216. // mock 曾把 `quota` 当 0~1 比率 ⇒ 前端拿到 "0.35" 当 35%(差 100 倍)。
  217. // 本用例把量纲钉死:JSON 里必须是 `"100.00"` 这样的**带引号两位小数**,
  218. // 不能是 `100`(number)、也不能是 `1`/`0.35`(比率)。
  219. func TestDivideTreeQuotaIsMoneyString(t *testing.T) {
  220. db := newDivideTestDB(t)
  221. root := "01HROOT000000000000000003"
  222. seedDivideNode(t, db, &root, nil, 1, 2, 1, "100.00")
  223. app := newDivideTestApp(t, db)
  224. code, body := getDivideTree(t, app)
  225. if code != 200 || len(body.Tree) != 1 {
  226. t.Fatalf("应返回 1 棵单节点树,实得 code=%d len=%d", code, len(body.Tree))
  227. }
  228. raw := string(body.Tree[0].Quota)
  229. if raw != `"100.00"` {
  230. t.Fatalf(`quota = %s,期望 "100.00"(金额字符串,两位小数;不是 0~1 比率、不是 number)`, raw)
  231. }
  232. if inc := string(body.Tree[0].CumulativeIncome); inc != `"0.00"` {
  233. t.Fatalf(`cumulativeIncome = %s,期望 "0.00"`, inc)
  234. }
  235. }
  236. // TestDivideTreeEmptyIsArray 空库:`tree` 必须是 `[]`(不能是 null),且不报错。
  237. func TestDivideTreeEmptyIsArray(t *testing.T) {
  238. db := newDivideTestDB(t)
  239. app := newDivideTestApp(t, db)
  240. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  241. resp, err := app.Test(req, 5000)
  242. if err != nil {
  243. t.Fatalf("请求失败: %v", err)
  244. }
  245. defer func() { _ = resp.Body.Close() }()
  246. raw := new(strings.Builder)
  247. buf := make([]byte, 4096)
  248. n, _ := resp.Body.Read(buf)
  249. raw.Write(buf[:n])
  250. if strings.Contains(raw.String(), `"tree":null`) {
  251. t.Fatalf("空树必须是 [],不能是 null:%s", raw.String())
  252. }
  253. if !strings.Contains(raw.String(), `"tree":[]`) {
  254. t.Fatalf("空树应为 `tree:[]`:%s", raw.String())
  255. }
  256. }
  257. // TestDivideTreeMissingScopeIs1001 无作用域 → 1001(分账是全局数据,更不能匿名读)。
  258. func TestDivideTreeMissingScopeIs1001(t *testing.T) {
  259. db := newDivideTestDB(t)
  260. app := newDivideTestApp(t, db)
  261. req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil)
  262. req.Header.Set("X-Test-Scope", "none")
  263. resp, err := app.Test(req, 5000)
  264. if err != nil {
  265. t.Fatalf("请求失败: %v", err)
  266. }
  267. defer func() { _ = resp.Body.Close() }()
  268. var envelope struct {
  269. Code int `json:"code"`
  270. }
  271. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  272. t.Fatalf("解码失败: %v", dErr)
  273. }
  274. if envelope.Code != infraerrors.CodeUnauth {
  275. t.Fatalf("无作用域应 1001,实得 %d", envelope.Code)
  276. }
  277. }
  278. // TestDivideLogsWindowAndTotals 端点 43:右开窗口 + `total` 与 `list` 同口径。
  279. //
  280. // 造 3 条流水:窗口前(02-20)、窗口内(03-05)、**恰好等于 `to`**(04-01)。
  281. // 断言:`total=1`(不是历史 3)、仅 1 行、且不出现 `to` 时刻的行 —— 若实现漏了窗口条件
  282. // (或 `to` 写成闭区间),这里会红。金额同样必须是字符串。
  283. func TestDivideLogsWindowAndTotals(t *testing.T) {
  284. db := newDivideTestDB(t)
  285. user := "01HFINDER00000000000000001"
  286. stu := "01HSTUDENT0000000000000001"
  287. for _, at := range []time.Time{
  288. time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), // 窗口前
  289. time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC), // 窗口内
  290. time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), // == to(右开:不得出现)
  291. } {
  292. if err := db.Create(&model.UserDivideIntoLog{
  293. Type: 1, UserID: user, StudentID: stu, Money: "12.34", CreateTime: at,
  294. }).Error; err != nil {
  295. t.Fatalf("seed 流水失败: %v", err)
  296. }
  297. }
  298. app := newDivideTestApp(t, db)
  299. path := fmt.Sprintf("%s?findUserId=%s&from=2026-03-01&to=2026-04-01", PathDivideLogs, user)
  300. req := httptest.NewRequest(http.MethodGet, path, nil)
  301. resp, err := app.Test(req, 5000)
  302. if err != nil {
  303. t.Fatalf("请求失败: %v", err)
  304. }
  305. defer func() { _ = resp.Body.Close() }()
  306. var envelope struct {
  307. Code int `json:"code"`
  308. Data struct {
  309. List []map[string]any `json:"list"`
  310. Total int64 `json:"total"`
  311. Pages int `json:"pages"`
  312. } `json:"data"`
  313. }
  314. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  315. t.Fatalf("解码失败: %v", dErr)
  316. }
  317. if envelope.Code != 200 {
  318. t.Fatalf("期望 200,实得 %d", envelope.Code)
  319. }
  320. if envelope.Data.Total != 1 || len(envelope.Data.List) != 1 {
  321. t.Fatalf("total/list = %d/%d,期望 1/1(右开窗口:to 时刻那笔应被排除;历史 3 笔不应计入)",
  322. envelope.Data.Total, len(envelope.Data.List))
  323. }
  324. row := envelope.Data.List[0]
  325. if got, ok := row["findUserId"].(string); !ok || got != user {
  326. t.Fatalf("findUserId = %v,期望 %s(表列 user_id → 契约字段 findUserId)", row["findUserId"], user)
  327. }
  328. if money, ok := row["money"].(string); !ok || money != "12.34" {
  329. t.Fatalf("money = %v,期望字符串 \"12.34\"", row["money"])
  330. }
  331. if envelope.Data.Pages != 1 {
  332. t.Fatalf("pages = %d,期望 1", envelope.Data.Pages)
  333. }
  334. }
  335. // TestDivideLogsRejectsReversedWindow from > to → 2001(不静默返回空集)。
  336. func TestDivideLogsRejectsReversedWindow(t *testing.T) {
  337. db := newDivideTestDB(t)
  338. app := newDivideTestApp(t, db)
  339. req := httptest.NewRequest(http.MethodGet,
  340. PathDivideLogs+"?from=2026-04-01&to=2026-03-01", nil)
  341. resp, err := app.Test(req, 5000)
  342. if err != nil {
  343. t.Fatalf("请求失败: %v", err)
  344. }
  345. defer func() { _ = resp.Body.Close() }()
  346. var envelope struct {
  347. Code int `json:"code"`
  348. }
  349. if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil {
  350. t.Fatalf("解码失败: %v", dErr)
  351. }
  352. if envelope.Code != infraerrors.CodeBadParam {
  353. t.Fatalf("from>to 应 2001,实得 %d", envelope.Code)
  354. }
  355. }