|
|
@@ -27,6 +27,7 @@ import (
|
|
|
"github.com/gofiber/fiber/v2"
|
|
|
"gorm.io/gorm"
|
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/apperr"
|
|
|
"yuansiwei/unified-backend/internal/academic/model"
|
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
@@ -374,3 +375,246 @@ func TestDivideLogsRejectsReversedWindow(t *testing.T) {
|
|
|
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**(D9 有意增强)。
|
|
|
+//
|
|
|
+// 3111 判据必须精确:是「新额度 > 父额度」,**不是** `quota > 1`、也不是比率越界。
|
|
|
+// 量纲同样在此验证:quota 传金额字符串 `"200.00"`(父 100.00)→ 3111;
|
|
|
+// 若谁把 quota 当比率(<1 合法),这条会红在"该拒没拒"上。
|
|
|
+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"`
|
|
|
+ }
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ② 再自证"插入后 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)
|
|
|
+ }
|
|
|
+}
|