// 分账 handler 单测(AC-13 第 1 批:端点 38 / 43)。 // // 断言纪律(team-lead 的 AC-13 验收方式:**不收"用例全绿",要"写错会变红"**): // // 端点 38 分账树 —— 主断言是 **Nested Set 四条不变式**(I1~I4),不是"接口返回 200": // · `TestDivideTreeInvariantsHold`:造一棵 5 节点两层的树,逐条验 I1(1..2N 无重复无空洞)/ // I2(嵌套或相离)/ I3(父严格包含子)/ I4(layer=parent.layer+1); // · `TestDivideTreeRejectsBrokenNesting`:**故意造坏树**(右值错位 1),断言 5001 + 文案命中 // 具体不变式 —— 这是"树坏了能被发现"的证明("接口能返回一棵树"这种断言在树坏时依然绿); // · `TestDivideTreeQuotaIsMoneyString`:`quota` 必须是**金额字符串**("100.00"), // 不是 0~1 比率、不是 number(mock 曾错成比率)。 // 端点 43 流水 —— `TestDivideLogsWindowAndTotals`:右开窗口(`createTime = to` 的行不得出现), // 且 `total` 与 `list` 同口径(不等于历史全量)。 package handler import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "github.com/gofiber/fiber/v2" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" "yuansiwei/unified-backend/internal/infra/tz" ) // newDivideTestDB 分账测试库(两张分账表;其余表由 handler 其它用例各自建)。 func newDivideTestDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_divide_handler_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open(分账库) 失败: %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("取 sql.DB 失败: %v", err) } sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { _ = sqlDB.Close() }) if mErr := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); mErr != nil { t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", mErr) } return db } // newDivideTestApp 只挂分账两条端点的 app(+ 作用域注入中间件)。 func newDivideTestApp(t *testing.T, db *gorm.DB) *fiber.App { t.Helper() h, err := New(Config{DB: db}) if err != nil { t.Fatalf("handler.New 失败: %v", err) } app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Use(func(c *fiber.Ctx) error { if c.Get("X-Test-Scope") == "none" { return c.Next() } appscope.Set(c, testScope()) return c.Next() }) app.Get(PathDivideTree, h.GetDivideTree) app.Get(PathDivideLogs, h.ListDivideLogs) return app } // seedDivideNode 落一行分账节点。 func seedDivideNode(t *testing.T, db *gorm.DB, userID, parentUserID *string, left, right, layer int32, quota string) { t.Helper() row := &model.UserDivideInto{ UserID: userIDOf(userID), ParentUserID: parentUserID, LeftVal: left, RightVal: right, Layer: layer, Quota: quota, TotalIncome: "0.00", } if parentUserID == nil { root := *userID row.RootUserID = &root } else { // 根取父的根(测试里只有两层,直接指向根) root := *userID if *parentUserID != "" { var parent model.UserDivideInto if err := db.Where("user_id = ?", *parentUserID).Take(&parent).Error; err != nil { t.Fatalf("读父节点(%s) 失败: %v", *parentUserID, err) } if parent.RootUserID != nil { root = *parent.RootUserID } } row.RootUserID = &root } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 分账节点(%s) 失败: %v", row.UserID, err) } } // userIDOf 解引用(nil → "",用于父节点为空的安全构造)。 func userIDOf(p *string) string { if p == nil { return "" } return *p } // divideTreeResp 端点 38 的响应体(足够断言树结构与量纲)。 type divideTreeResp struct { Tree []struct { UserID string `json:"userId"` ParentUserID *string `json:"parentUserId"` Quota json.RawMessage `json:"quota"` CumulativeIncome json.RawMessage `json:"cumulativeIncome"` Layer int32 `json:"layer"` Children []struct { UserID string `json:"userId"` Quota json.RawMessage `json:"quota"` Layer int32 `json:"layer"` Children []struct { UserID string `json:"userId"` } `json:"children"` } `json:"children"` } `json:"tree"` NodeCount int `json:"nodeCount"` RootCount int `json:"rootCount"` } // getDivideTree 发请求并解出树响应(HTTP 恒 200)。 func getDivideTree(t *testing.T, app *fiber.App) (int, divideTreeResp) { t.Helper() req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil) resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求分账树失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` Data json.RawMessage `json:"data"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("响应解码失败: %v", dErr) } var out divideTreeResp if envelope.Code == 200 && len(envelope.Data) > 0 { if uErr := json.Unmarshal(envelope.Data, &out); uErr != nil { t.Fatalf("树体解码失败: %v", uErr) } } return envelope.Code, out } // TestDivideTreeInvariantsHold 端点 38:两层 5 节点树,**四条不变式**逐条成立。 // // 造形(root=1..10, A=2..5, A1=3..4, B=6..9, B1=7..8): // // root(layer1) ├── A(layer2) ── A1(layer3) // └── B(layer2) ── B1(layer3) // // 若实现的位移逻辑使区间重复/空洞/部分重叠,**本用例会红**(而"接口返回 200 + 有 tree 字段"不会)。 func TestDivideTreeInvariantsHold(t *testing.T) { db := newDivideTestDB(t) root, a, a1, b, b1 := "01HROOT000000000000000001", "01HA0000000000000000001", "01HA10000000000000000001", "01HB0000000000000000001", "01HB10000000000000000001" seedDivideNode(t, db, &root, nil, 1, 10, 1, "100.00") seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00") seedDivideNode(t, db, &a1, &a, 3, 4, 3, "20.00") seedDivideNode(t, db, &b, &root, 6, 9, 2, "30.00") seedDivideNode(t, db, &b1, &b, 7, 8, 3, "10.00") app := newDivideTestApp(t, db) code, body := getDivideTree(t, app) if code != 200 { t.Fatalf("合法树应返回 200,实得 %d", code) } if body.NodeCount != 5 || body.RootCount != 1 || len(body.Tree) != 1 { t.Fatalf("nodeCount/rootCount/len(tree) = %d/%d/%d,期望 5/1/1", body.NodeCount, body.RootCount, len(body.Tree)) } // I4:根 layer=1、parent 为 null if body.Tree[0].Layer != 1 || body.Tree[0].ParentUserID != nil { t.Fatalf("根节点 layer/parent = %d/%v,期望 1/nil", body.Tree[0].Layer, body.Tree[0].ParentUserID) } // I3/I4:子节点 layer=2 且挂在根下 if len(body.Tree[0].Children) != 2 { t.Fatalf("根的直接子节点 = %d,期望 2", len(body.Tree[0].Children)) } for _, child := range body.Tree[0].Children { if child.Layer != 2 { t.Errorf("子节点 %s layer = %d,期望 2", child.UserID, child.Layer) } } // 逐级检查孙节点(A1 / B1 各 1 个) grandTotal := 0 for _, child := range body.Tree[0].Children { grandTotal += len(child.Children) } if grandTotal != 2 { t.Fatalf("孙节点总数 = %d,期望 2(A1、B1)", grandTotal) } } // TestDivideTreeRejectsBrokenNesting 端点 38:**故意造坏树** ⇒ 5001 + 文案指到具体不变式。 // // 造法:给 B 的右值从 9 改成 5(与 A 的右值相同)⇒ I1(区间值重复)。 // 这是"树坏了能被发现"的证明:只断言"返回 200 且有 tree"的实现,在这种情况下**依然绿**。 func TestDivideTreeRejectsBrokenNesting(t *testing.T) { db := newDivideTestDB(t) root, a, b := "01HROOT000000000000000002", "01HA0000000000000000002", "01HB0000000000000000002" seedDivideNode(t, db, &root, nil, 1, 6, 1, "100.00") seedDivideNode(t, db, &a, &root, 2, 5, 2, "50.00") seedDivideNode(t, db, &b, &root, 6, 5, 2, "30.00") // ⚠ 坏行:left(6) >= right(5) _ = b app := newDivideTestApp(t, db) code, _ := getDivideTree(t, app) if code != infraerrors.CodeInternal { t.Fatalf("坏树应返回 5001(数据损坏),实得 %d —— 说明不变式校验没生效", code) } } // TestDivideTreeQuotaIsMoneyString 端点 38:`quota` / `cumulativeIncome` 必须是**金额字符串**。 // // mock 曾把 `quota` 当 0~1 比率 ⇒ 前端拿到 "0.35" 当 35%(差 100 倍)。 // 本用例把量纲钉死:JSON 里必须是 `"100.00"` 这样的**带引号两位小数**, // 不能是 `100`(number)、也不能是 `1`/`0.35`(比率)。 func TestDivideTreeQuotaIsMoneyString(t *testing.T) { db := newDivideTestDB(t) root := "01HROOT000000000000000003" seedDivideNode(t, db, &root, nil, 1, 2, 1, "100.00") app := newDivideTestApp(t, db) code, body := getDivideTree(t, app) if code != 200 || len(body.Tree) != 1 { t.Fatalf("应返回 1 棵单节点树,实得 code=%d len=%d", code, len(body.Tree)) } raw := string(body.Tree[0].Quota) if raw != `"100.00"` { t.Fatalf(`quota = %s,期望 "100.00"(金额字符串,两位小数;不是 0~1 比率、不是 number)`, raw) } if inc := string(body.Tree[0].CumulativeIncome); inc != `"0.00"` { t.Fatalf(`cumulativeIncome = %s,期望 "0.00"`, inc) } } // TestDivideTreeEmptyIsArray 空库:`tree` 必须是 `[]`(不能是 null),且不报错。 func TestDivideTreeEmptyIsArray(t *testing.T) { db := newDivideTestDB(t) app := newDivideTestApp(t, db) req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil) resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() raw := new(strings.Builder) buf := make([]byte, 4096) n, _ := resp.Body.Read(buf) raw.Write(buf[:n]) if strings.Contains(raw.String(), `"tree":null`) { t.Fatalf("空树必须是 [],不能是 null:%s", raw.String()) } if !strings.Contains(raw.String(), `"tree":[]`) { t.Fatalf("空树应为 `tree:[]`:%s", raw.String()) } } // TestDivideTreeMissingScopeIs1001 无作用域 → 1001(分账是全局数据,更不能匿名读)。 func TestDivideTreeMissingScopeIs1001(t *testing.T) { db := newDivideTestDB(t) app := newDivideTestApp(t, db) req := httptest.NewRequest(http.MethodGet, PathDivideTree, nil) req.Header.Set("X-Test-Scope", "none") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != infraerrors.CodeUnauth { t.Fatalf("无作用域应 1001,实得 %d", envelope.Code) } } // TestDivideLogsWindowAndTotals 端点 43:右开窗口 + `total` 与 `list` 同口径。 // // 造 3 条流水:窗口前(02-20)、窗口内(03-05)、**恰好等于 `to`**(04-01)。 // 断言:`total=1`(不是历史 3)、仅 1 行、且不出现 `to` 时刻的行 —— 若实现漏了窗口条件 // (或 `to` 写成闭区间),这里会红。金额同样必须是字符串。 func TestDivideLogsWindowAndTotals(t *testing.T) { db := newDivideTestDB(t) user := "01HFINDER00000000000000001" stu := "01HSTUDENT0000000000000001" // 种子时间必须与 `parseTimeParam` 用**同一个业务时区**(`tz.Location()`), // **不能用 `time.UTC`**:测试库是 SQLite,时间列按**文本**比较 —— // `time.UTC` 存成 `...T00:00:00Z`、`+08:00` 存成 `...T00:00:00+08:00`, // 同一绝对时刻的字面量在 offset 段就分出了大小 ⇒ `create_time < to` 判错 //(实测:两种写法对同一时刻的命中结果相反)。生产 MySQL 的 `datetime` 是无时区 // 字面值、按 DSN `loc` 解释,不存在该现象。见台账 F31。 for _, at := range []time.Time{ time.Date(2026, 2, 20, 10, 0, 0, 0, tz.Location()), // 窗口前 time.Date(2026, 3, 5, 10, 0, 0, 0, tz.Location()), // 窗口内 time.Date(2026, 4, 1, 0, 0, 0, 0, tz.Location()), // == to(右开:不得出现) } { if err := db.Create(&model.UserDivideIntoLog{ Type: 1, UserID: user, StudentID: stu, Money: "12.34", CreateTime: at, }).Error; err != nil { t.Fatalf("seed 流水失败: %v", err) } } app := newDivideTestApp(t, db) path := fmt.Sprintf("%s?findUserId=%s&from=2026-03-01&to=2026-04-01", PathDivideLogs, user) req := httptest.NewRequest(http.MethodGet, path, nil) resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` Data struct { List []map[string]any `json:"list"` Total int64 `json:"total"` Pages int `json:"pages"` } `json:"data"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != 200 { t.Fatalf("期望 200,实得 %d", envelope.Code) } if envelope.Data.Total != 1 || len(envelope.Data.List) != 1 { t.Fatalf("total/list = %d/%d,期望 1/1(右开窗口:to 时刻那笔应被排除;历史 3 笔不应计入)", envelope.Data.Total, len(envelope.Data.List)) } row := envelope.Data.List[0] if got, ok := row["findUserId"].(string); !ok || got != user { t.Fatalf("findUserId = %v,期望 %s(表列 user_id → 契约字段 findUserId)", row["findUserId"], user) } if money, ok := row["money"].(string); !ok || money != "12.34" { t.Fatalf("money = %v,期望字符串 \"12.34\"", row["money"]) } if envelope.Data.Pages != 1 { t.Fatalf("pages = %d,期望 1", envelope.Data.Pages) } } // TestDivideLogsRejectsReversedWindow from > to → 2001(不静默返回空集)。 func TestDivideLogsRejectsReversedWindow(t *testing.T) { db := newDivideTestDB(t) app := newDivideTestApp(t, db) req := httptest.NewRequest(http.MethodGet, PathDivideLogs+"?from=2026-04-01&to=2026-03-01", nil) resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != infraerrors.CodeBadParam { t.Fatalf("from>to 应 2001,实得 %d", envelope.Code) } } // ---------------------------------------------------------------- 13a 补充用例(39/40/41/42 + D5 两树隔离) // TestDivideTreeRootUserIDFilter 端点 38 的 `rootUserId?`:只返回指定那棵树。 // // 断言:两棵树共存时,带 rootUserId 只得到 1 棵、节点数只算该树; // 不带时得到森林(rootCount=2)。**跨树混淆是 Nested Set 最典型的错**(两树区间各自从 1 起)。 func TestDivideTreeRootUserIDFilter(t *testing.T) { db := newDivideTestDB(t) rootA, childA := "01HROOTA00000000000000001", "01HCHILDA0000000000000001" rootB := "01HROOTB00000000000000001" seedDivideNode(t, db, &rootA, nil, 1, 4, 1, "100.00") seedDivideNode(t, db, &childA, &rootA, 2, 3, 2, "50.00") seedDivideNode(t, db, &rootB, nil, 1, 2, 1, "80.00") app := newDivideTestApp(t, db) code, forest := getDivideTree(t, app) if code != 200 || forest.RootCount != 2 || forest.NodeCount != 3 { t.Fatalf("不带 rootUserId 应得森林 2 棵/3 节点,实得 code=%d root=%d node=%d", code, forest.RootCount, forest.NodeCount) } req := httptest.NewRequest(http.MethodGet, PathDivideTree+"?rootUserId="+rootA, nil) resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` Data json.RawMessage `json:"data"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } var one divideTreeResp if uErr := json.Unmarshal(envelope.Data, &one); uErr != nil { t.Fatalf("树体解码失败: %v", uErr) } if envelope.Code != 200 || one.RootCount != 1 || one.NodeCount != 2 { t.Fatalf("带 rootUserId 应得 1 棵/2 节点,实得 code=%d root=%d node=%d", envelope.Code, one.RootCount, one.NodeCount) } if one.Tree[0].UserID != rootA { t.Fatalf("返回的树根 = %s,期望 %s(不得串到另一棵树)", one.Tree[0].UserID, rootA) } } // TestDivideQuotaRejectsChildAboveParent 端点 40:**子额度 > 父额度 → 3111,且响应体带两个金额**。 // // 3111 判据必须精确:是「新额度 > 父额度」,**不是** `quota > 1`、也不是比率越界。 // 量纲同样在此验证:quota 传金额字符串 `"200.00"`(父 100.00)→ 3111; // 若谁把 quota 当比率(<1 合法),这条会红在"该拒没拒"上。 // // 2026-11-11 拍板 A3 后新增的**端到端**判据:`message` 必须带具体金额。 // 此前 message 恒为 sentinel 的"分账层级超限或额度超上限",用户不知道超了多少 —— // 那是 F32 的核心问题。断言放在 handler 层(而不是只断言 service 的 PublicMsg), // 因为只有这里能同时证明 `WithPublicMsg → AppError.PublicMsg → response.Fail` 三段都通。 func TestDivideQuotaRejectsChildAboveParent(t *testing.T) { db := newDivideTestDB(t) root, child := "01HROOTQ00000000000000001", "01HCHILDQ0000000000000001" seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00") seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00") app := newDivideTestApp(t, db) h, _ := New(Config{DB: db}) app.Put(PathDivideMemberQuota, h.SetDivideQuota) body := `{"quota":"200.00"}` // 子 200 > 父 100 req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` Message string `json:"message"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != apperr.CodeDivideQuotaExceeded { t.Fatalf("子额度超父应 3111,实得 %d(判据必须是「新额度 > 父额度」,不是 quota>1)", envelope.Code) } // **对外文案带数字**(A3):这是前端契约里钉死的一句话,改文案必须同步改契约与本断言 wantMsg := "分成额度不得超过上级:本次 200.00 元,上级额度 100.00 元" if envelope.Message != wantMsg { t.Fatalf("3111 响应 message 不符:\n 期望 %q\n 实得 %q\n"+ "若实得 '分账层级超限或额度超上限',说明 PublicMsg 没传到这里(A3 未落地)", wantMsg, envelope.Message) } } // TestDivideLogDataCorruptionIs5001 端点 41:分账树**数据损坏**(自环)→ 5001,**不是 3111**。 // // 这是 2026-11-11 拍板 A3 的端到端验收:此前自环报 3111,前端会提示"额度超上限", // 运维照着去查额度 —— 方向完全错(真实原因是树的数据坏了)。 // // 本用例同时锁住两件事: // 1. 错误码是 5001(不是 3111); // 2. `message` 是 sentinel 默认的"系统繁忙",**不得**把"parent 指向自身"这类内部结构描述漏给前端。 func TestDivideLogDataCorruptionIs5001(t *testing.T) { db := newDivideTestDB(t) loop := "01HLOOPXX0000000000000001" // 真自环:parent 指向自身。正常接口建不出来(AddSubordinate 有 3140 拦截),只能人为造脏数据。 // ⚠ 必须先按"根"落库再改父:`seedDivideNode` 在插入**前**会查父节点, // 自环时父就是自己、此刻还不存在 ⇒ 直接传 parent=self 会 fail 在"读父节点"。 seedDivideNode(t, db, &loop, nil, 2, 3, 2, "10.00") if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", loop). Updates(map[string]any{"parent_user_id": loop}).Error; err != nil { t.Fatalf("造自环失败: %v", err) } app := newDivideTestApp(t, db) h, _ := New(Config{DB: db}) app.Post(PathDivideLogs, h.CreateDivideLogRecord) body := `{"findUserId":"` + loop + `","studentId":"01HSTUDENT0000000000000001","orderNo":"777"}` req := httptest.NewRequest(http.MethodPost, "/admin/divide-into/logs", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` Message string `json:"message"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != infraerrors.CodeInternal { t.Fatalf("分账树自环属数据损坏,对外应 5001,实得 %d(message=%q)\n"+ "若为 3111 ⇒ A3 未落地:数据损坏与业务规则共码,前端会把运维引向额度", envelope.Code, envelope.Message) } if envelope.Code == apperr.CodeDivideQuotaExceeded { t.Fatal("仍是 3111:A3 要求三个数据异常场景(结构上限/自环/链超长)全部改判 5001") } if envelope.Message != "系统繁忙" { t.Fatalf("5001 对外文案应为 sentinel 默认值「系统繁忙」,实得 %q\n"+ "(数据损坏不得给 PublicMsg,否则会把内部结构描述漏给用户)", envelope.Message) } // 落库反证:自环应在入循环前拒绝,一条明细都不该留下 var logCount int64 if err := db.Model(&model.UserDivideIntoLog{}).Count(&logCount).Error; err != nil { t.Fatalf("count 失败: %v", err) } if logCount != 0 { t.Fatalf("自环应整体回滚,实得 %d 条明细", logCount) } } // TestDivideQuotaRejectsRatioStyle 端点 40:`quota` 传比率(`0.5`)也必须被当金额接受为 **0.50 元**, // 而不是"50%"。本用例锁定"按金额解释"这一事实,并断言非法字符串 → 2002。 func TestDivideQuotaRejectsRatioStyle(t *testing.T) { db := newDivideTestDB(t) root, child := "01HROOTR00000000000000001", "01HCHILDR0000000000000001" seedDivideNode(t, db, &root, nil, 1, 4, 1, "100.00") seedDivideNode(t, db, &child, &root, 2, 3, 2, "50.00") app := newDivideTestApp(t, db) h, _ := New(Config{DB: db}) app.Put(PathDivideMemberQuota, h.SetDivideQuota) // 非法金额字符串 → 2002 req := httptest.NewRequest(http.MethodPut, "/admin/divide-into/members/"+child+"/quota", strings.NewReader(`{"quota":"abc"}`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != infraerrors.CodeBadFormat { t.Fatalf("非法 quota 应 2002,实得 %d", envelope.Code) } } // TestDivideLogRequiresNumericOrderNo 端点 41/42:`orderNo` 非数字 → 2002(不静默当成 record 0)。 func TestDivideLogRequiresNumericOrderNo(t *testing.T) { db := newDivideTestDB(t) app := newDivideTestApp(t, db) h, _ := New(Config{DB: db}) app.Post(PathDivideLogs, h.CreateDivideLogRecord) req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader( `{"findUserId":"01HFINDER00000000000000002","studentId":"01HSTUDENT0000000000000002","orderNo":"abc"}`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != infraerrors.CodeBadFormat { t.Fatalf("orderNo 非数字应 2002,实得 %d", envelope.Code) } } // TestDivideLogAddRejectsMissingScope 端点 41 无作用域 → 1001(先于参数校验)。 func TestDivideLogAddRejectsMissingScope(t *testing.T) { db := newDivideTestDB(t) app := newDivideTestApp(t, db) h, _ := New(Config{DB: db}) app.Post(PathDivideLogs, h.CreateDivideLogRecord) req := httptest.NewRequest(http.MethodPost, PathDivideLogs, strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Test-Scope", "none") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != infraerrors.CodeUnauth { t.Fatalf("无作用域应 1001,实得 %d", envelope.Code) } } // TestDivideTwoTreesIsolationInsert 设计 §5.1 纪律 3 / §3.8-D5:**插入不得平移另一棵树**。 // // D5 的失败形态很隐蔽:`ShiftRight/ShiftLeft` 若漏掉 `root_user_id` 过滤, // 位移会按 `left_val >= parentRight` 命中**另一棵树**里数值更大的节点 ⇒ 该树区间整体被平移。 // 单看本树完全正常(区间仍自洽),所以**只有逐值比对另一棵树**才能发现。 // // 断言:在 A 树插入新节点后,B 树每行的 (left,right,layer,quota) **逐值不变**。 func TestDivideTwoTreesIsolationInsert(t *testing.T) { db := newDivideTestDB(t) rootA, rootB, childB := "01HROOTA10000000000000001", "01HROOTB10000000000000001", "01HCHILDB1000000000000001" seedDivideNode(t, db, &rootA, nil, 1, 2, 1, "100.00") seedDivideNode(t, db, &rootB, nil, 1, 4, 1, "80.00") seedDivideNode(t, db, &childB, &rootB, 2, 3, 2, "40.00") before := map[string][3]int32{} var rowsBefore []model.UserDivideInto if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsBefore).Error; err != nil { t.Fatalf("读 B 树失败: %v", err) } for _, r := range rowsBefore { before[r.UserID] = [3]int32{r.LeftVal, r.RightVal, r.Layer} } // 在 A 树插入新下线(A 根 left=1,right=2 ⇒ 插入点 parentRight=2 ⇒ 新节点 (2,3)) app := newDivideTestApp(t, db) h, _ := New(Config{DB: db}) app.Post(PathDivideMembers, h.AddDivideMember) body := `{"parentUserId":"` + rootA + `","subUserId":"01HNEWA100000000000000001"}` req := httptest.NewRequest(http.MethodPost, PathDivideMembers, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("请求失败: %v", err) } defer func() { _ = resp.Body.Close() }() var envelope struct { Code int `json:"code"` } if dErr := json.NewDecoder(resp.Body).Decode(&envelope); dErr != nil { t.Fatalf("解码失败: %v", dErr) } if envelope.Code != 200 { t.Fatalf("加入下线应 200,实得 %d", envelope.Code) } // ① 先**逐值比对** B 树:一字不动 —— 这是 D5(漏 root 过滤)的**唯一可判形式**: // 漏了 root 时,`left>=threshold` 会把 B 树里数值更大的节点一起 +2, // 而 A 树自身看起来仍可能自洽(或两边一起坏),单看 A 树分不清"谁错了"。 // 逐值比对 B 树:一字不动 var rowsAfter []model.UserDivideInto if err := db.Where("root_user_id = ?", rootB).Order("left_val").Find(&rowsAfter).Error; err != nil { t.Fatalf("读 B 树失败: %v", err) } if len(rowsAfter) != len(before) { t.Fatalf("B 树节点数变化:%d → %d(A 树插入不该影响 B 树)", len(before), len(rowsAfter)) } for _, r := range rowsAfter { want, ok := before[r.UserID] if !ok { t.Fatalf("B 树出现新节点 %s(不该有)", r.UserID) } if r.LeftVal != want[0] || r.RightVal != want[1] || r.Layer != want[2] { t.Fatalf("B 树节点 %s 区间被平移:(left,right,layer) 期望 (%d,%d,%d),实得 (%d,%d,%d)"+ " —— 说明位移漏了 root_user_id 过滤(D5)", r.UserID, want[0], want[1], want[2], r.LeftVal, r.RightVal, r.Layer) } } // ①bis 端点 39 的新节点 `quota` 必须是**金额零** `"0.00"`(D6/DDL 默认), // 不是 null、不是空串、不是"未设置"——`0` 是"该节点不分成"的合法配置值, // 用 null 表达会让"不分成"与"未配置"混为一谈(契约 §2.3 提示前端引导设额度即基于此)。 var newNode model.UserDivideInto if qErr := db.Where("user_id = ?", "01HNEWA100000000000000001").Take(&newNode).Error; qErr != nil { t.Fatalf("读新节点失败: %v", qErr) } // 断言"金额零"这一语义(sqlite 的 NUMERIC 亲和性会把 "0.00" 读回 "0",MySQL 读回 "0.00", // 两者定点值相同;故按 money.Amount 比较,再单独断言"不能用空串/null 表达") quotaAmt, quotaErr := money.Parse(newNode.Quota) if quotaErr != nil || !quotaAmt.IsZero() { t.Fatalf("新节点 quota = %q(解析 err=%v),期望金额零 0.00", newNode.Quota, quotaErr) } if strings.TrimSpace(newNode.Quota) == "" { t.Fatal("新节点 quota 不能用空串/null 表达 —— 0 是「不分成」的合法配置值,与「未配置」不是一回事") } // ② 再自证"插入后 A 树自身仍然自洽"(不变式 I1~I4)—— // 若插入点取错(拿位移后的 parentRight、或 off-by-one),A 树会出现空洞/重叠 ⇒ 5001。 // 放在最后:D-3 的失败形态只影响 A 树,B 树比对对它无感。 if codeAfter, _ := getDivideTree(t, app); codeAfter != 200 { t.Fatalf("插入后 A 树不变式被破坏(返回 %d)—— 检查插入点是否取了**位移前**的 parentRight(D-3)", codeAfter) } }