|
@@ -120,14 +120,14 @@ func TestDivideShiftLeftRight(t *testing.T) {
|
|
|
|
|
|
|
|
// 在 root 右值前插入:先 right>=6 +2,再 left>=6 +2(无节点满足)
|
|
// 在 root 右值前插入:先 right>=6 +2,再 left>=6 +2(无节点满足)
|
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
- n, e := repo.ShiftRight(ctx, tx, 6, 2)
|
|
|
|
|
|
|
+ n, e := repo.ShiftRight(ctx, tx, root, 6, 2)
|
|
|
if e != nil {
|
|
if e != nil {
|
|
|
return e
|
|
return e
|
|
|
}
|
|
}
|
|
|
if n != 1 {
|
|
if n != 1 {
|
|
|
t.Fatalf("ShiftRight(6,+2) 应影响 1 行(root),实得 %d", n)
|
|
t.Fatalf("ShiftRight(6,+2) 应影响 1 行(root),实得 %d", n)
|
|
|
}
|
|
}
|
|
|
- _, e = repo.ShiftLeft(ctx, tx, 6, 2)
|
|
|
|
|
|
|
+ _, e = repo.ShiftLeft(ctx, tx, root, 6, 2)
|
|
|
return e
|
|
return e
|
|
|
}); err != nil {
|
|
}); err != nil {
|
|
|
t.Fatalf("位移失败: %v", err)
|
|
t.Fatalf("位移失败: %v", err)
|
|
@@ -137,10 +137,10 @@ func TestDivideShiftLeftRight(t *testing.T) {
|
|
|
}
|
|
}
|
|
|
// 在 a 右值前插入:right>=3 全体 +2(root、a、b),left>=3 全体 +2(b)
|
|
// 在 a 右值前插入:right>=3 全体 +2(root、a、b),left>=3 全体 +2(b)
|
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
- if _, e := repo.ShiftRight(ctx, tx, 3, 2); e != nil {
|
|
|
|
|
|
|
+ if _, e := repo.ShiftRight(ctx, tx, root, 3, 2); e != nil {
|
|
|
return e
|
|
return e
|
|
|
}
|
|
}
|
|
|
- _, e := repo.ShiftLeft(ctx, tx, 3, 2)
|
|
|
|
|
|
|
+ _, e := repo.ShiftLeft(ctx, tx, root, 3, 2)
|
|
|
return e
|
|
return e
|
|
|
}); err != nil {
|
|
}); err != nil {
|
|
|
t.Fatalf("位移失败: %v", err)
|
|
t.Fatalf("位移失败: %v", err)
|
|
@@ -155,18 +155,62 @@ func TestDivideShiftLeftRight(t *testing.T) {
|
|
|
t.Fatalf("b 应为 (6,7),实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
t.Fatalf("b 应为 (6,7),实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 防御:tx nil / 阈值非法 / delta=0
|
|
|
|
|
- if _, err := repo.ShiftRight(ctx, nil, 1, 2); !errors.Is(err, infraerrors.ErrInternal) {
|
|
|
|
|
|
|
+ // 防御:tx nil / root 缺失 / 阈值非法 / delta=0
|
|
|
|
|
+ if _, err := repo.ShiftRight(ctx, nil, root, 1, 2); !errors.Is(err, infraerrors.ErrInternal) {
|
|
|
t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
|
|
t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
|
|
|
}
|
|
}
|
|
|
- if _, err := repo.ShiftLeft(ctx, repo.DB, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
|
|
|
|
+ if _, err := repo.ShiftRight(ctx, repo.DB, "", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
|
|
+ t.Fatalf("root 缺失应 ErrBadParam(多树隔离必填),实得 %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := repo.ShiftLeft(ctx, repo.DB, root, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
t.Fatalf("阈值 <0 应 ErrBadParam,实得 %v", err)
|
|
t.Fatalf("阈值 <0 应 ErrBadParam,实得 %v", err)
|
|
|
}
|
|
}
|
|
|
- if n, err := repo.ShiftLeft(ctx, repo.DB, 1, 0); err != nil || n != 0 {
|
|
|
|
|
|
|
+ if n, err := repo.ShiftLeft(ctx, repo.DB, root, 1, 0); err != nil || n != 0 {
|
|
|
t.Fatalf("delta=0 应空操作,实得 n=%d err=%v", n, err)
|
|
t.Fatalf("delta=0 应空操作,实得 n=%d err=%v", n, err)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// TestDivideShiftRootIsolation 两棵树互不影响(audit V-02,dump 763707-763708 的 root 限定)。
|
|
|
|
|
+//
|
|
|
|
|
+// 造两棵树的 left/right **区间完全重叠**(T1: 1..6,T2: 1..6)。T2 一字不动是本用例的核心断言 ——
|
|
|
|
|
+// 若 shift 漏了 `root_user_id = ?`,T2 的节点会被同一次位移连带改写(双跑 diff 必红)。
|
|
|
|
|
+func TestDivideShiftRootIsolation(t *testing.T) {
|
|
|
|
|
+ db := newDivideRepoDB(t)
|
|
|
|
|
+ t1Root, t2Root := "01HROOT000000000000000101", "01HROOT000000000000000102"
|
|
|
|
|
+ t1a, t2a := "01HNODEA00000000000000101", "01HNODEA00000000000000102"
|
|
|
|
|
+ seedDivideNode(t, db, t1Root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &t1Root)
|
|
|
|
|
+ seedDivideNode(t, db, t1a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &t1Root, &t1Root)
|
|
|
|
|
+ // T2 用同一套左右值(区间重叠,最能暴露漏 root 限定的问题)
|
|
|
|
|
+ seedDivideNode(t, db, t2Root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &t2Root)
|
|
|
|
|
+ seedDivideNode(t, db, t2a, model.DivideTypeOneOff, 4, 5, 2, "5.00", &t2Root, &t2Root)
|
|
|
|
|
+
|
|
|
|
|
+ repo := NewDivideRepo(db)
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ if _, e := repo.ShiftRight(ctx, tx, t1Root, 3, 2); e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ _, e := repo.ShiftLeft(ctx, tx, t1Root, 3, 2)
|
|
|
|
|
+ return e
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("T1 位移失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // T1 被正确改写
|
|
|
|
|
+ if got := loadNode(t, db, t1Root); got.LeftVal != 1 || got.RightVal != 8 {
|
|
|
|
|
+ t.Fatalf("T1 root 应为 (1,8),实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
|
|
|
+ }
|
|
|
|
|
+ // T2 **一字不动**(漏 root 限定时这里会红)
|
|
|
|
|
+ if got := loadNode(t, db, t2Root); got.LeftVal != 1 || got.RightVal != 6 {
|
|
|
|
|
+ t.Fatalf("T2 root 不应被波及,应为 (1,6),实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := loadNode(t, db, t2a); got.LeftVal != 4 || got.RightVal != 5 {
|
|
|
|
|
+ t.Fatalf("T2 子节点不应被波及,应为 (4,5),实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// TestDivideInsertNodeGuards 嵌套集不变式(left<right)与事务必传。
|
|
// TestDivideInsertNodeGuards 嵌套集不变式(left<right)与事务必传。
|
|
|
func TestDivideInsertNodeGuards(t *testing.T) {
|
|
func TestDivideInsertNodeGuards(t *testing.T) {
|
|
|
db := newDivideRepoDB(t)
|
|
db := newDivideRepoDB(t)
|
|
@@ -309,7 +353,7 @@ func TestDivideQuotaPrimitives(t *testing.T) {
|
|
|
|
|
|
|
|
// 级联只降:子树限制 4.00 → a(8.00) 降到 4.00
|
|
// 级联只降:子树限制 4.00 → a(8.00) 降到 4.00
|
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
- n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, money.FromCents(400))
|
|
|
|
|
|
|
+ n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
|
|
|
if e != nil {
|
|
if e != nil {
|
|
|
return e
|
|
return e
|
|
|
}
|
|
}
|
|
@@ -325,7 +369,7 @@ func TestDivideQuotaPrimitives(t *testing.T) {
|
|
|
}
|
|
}
|
|
|
// 再压到 6.00(高于现值)→ 不回升(影响 0 行)
|
|
// 再压到 6.00(高于现值)→ 不回升(影响 0 行)
|
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
- n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, money.FromCents(600))
|
|
|
|
|
|
|
+ n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(600))
|
|
|
if e != nil {
|
|
if e != nil {
|
|
|
return e
|
|
return e
|
|
|
}
|
|
}
|
|
@@ -364,9 +408,65 @@ func TestDivideQuotaPrimitives(t *testing.T) {
|
|
|
if _, err := repo.UpdateNodeQuota(ctx, db, a, money.FromCents(-1)); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
if _, err := repo.UpdateNodeQuota(ctx, db, a, money.FromCents(-1)); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
t.Fatalf("负额度应 ErrBadParam,实得 %v", err)
|
|
t.Fatalf("负额度应 ErrBadParam,实得 %v", err)
|
|
|
}
|
|
}
|
|
|
- if _, err := repo.ClampSubtreeQuota(ctx, db, 5, 1, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
|
|
|
|
+ if _, err := repo.ClampSubtreeQuota(ctx, db, 5, 1, model.DivideTypeOneOff, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
t.Fatalf("非法区间应 ErrBadParam,实得 %v", err)
|
|
t.Fatalf("非法区间应 ErrBadParam,实得 %v", err)
|
|
|
}
|
|
}
|
|
|
|
|
+ // audit A6:分成类型非法 → ErrBadParam(白名单 0/1/2)
|
|
|
|
|
+ if _, err := repo.ClampSubtreeQuota(ctx, db, 1, 6, 9, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
|
|
|
|
|
+ t.Fatalf("非法分成类型应 ErrBadParam,实得 %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestClampSubtreeQuotaTypeFilter 混合类型:级联只压**同一分成类型**的子节点(audit A6,dump 763873)。
|
|
|
|
|
+//
|
|
|
|
|
+// 父(type=1, quota 10) ├─ A(type=1, 8) └─ B(type=2, 8)
|
|
|
|
|
+// 父降到 4 → 只有 A 被压到 4,**B 保持 8**(按课次线的额度不受一次付清线影响)。
|
|
|
|
|
+// 若漏了 `type = ?` 过滤,B 会被一起压扁 → 本用例红。
|
|
|
|
|
+func TestClampSubtreeQuotaTypeFilter(t *testing.T) {
|
|
|
|
|
+ db := newDivideRepoDB(t)
|
|
|
|
|
+ root := "01HROOT000000000000000201"
|
|
|
|
|
+ a := "01HNODEA00000000000000201"
|
|
|
|
|
+ b := "01HNODEB00000000000000201"
|
|
|
|
|
+ seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
|
|
|
|
|
+ seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "8.00", &root, &root)
|
|
|
|
|
+ seedDivideNode(t, db, b, model.DivideTypePerLesson, 4, 5, 2, "8.00", &root, &root)
|
|
|
|
|
+
|
|
|
|
|
+ repo := NewDivideRepo(db)
|
|
|
|
|
+ runner := NewTxRunner(db)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
|
|
|
|
|
+ if e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ if n != 1 {
|
|
|
|
|
+ t.Fatalf("只应压制同类型 1 个节点(A),实得 %d", n)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("ClampSubtreeQuota 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := centsOf(t, loadNode(t, db, a).Quota); got != 400 {
|
|
|
|
|
+ t.Fatalf("A(type=1) 应压到 4.00,实得 %d 分", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := centsOf(t, loadNode(t, db, b).Quota); got != 800 {
|
|
|
|
|
+ t.Fatalf("B(type=2) 不得被跨类型压制,应保持 8.00,实得 %d 分", got)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // divideType=0(admin 全量场景)→ 两种类型一起压
|
|
|
|
|
+ if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
|
|
|
|
|
+ n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, 0, money.FromCents(200))
|
|
|
|
|
+ if e != nil {
|
|
|
|
|
+ return e
|
|
|
|
|
+ }
|
|
|
|
|
+ if n != 2 {
|
|
|
|
|
+ t.Fatalf("divideType=0 应压制 2 个节点,实得 %d", n)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("ClampSubtreeQuota(0) 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// TestDivideLogPrimitives InsertLog 类型校验 / UpdateLogOrderNo 改绑 / DeleteLog 删行。
|
|
// TestDivideLogPrimitives InsertLog 类型校验 / UpdateLogOrderNo 改绑 / DeleteLog 删行。
|
|
@@ -440,7 +540,7 @@ func TestDivideLogPrimitives(t *testing.T) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// TestDivideUpdateNodeParentFillsRoot 改挂分支显式写 root(修正 dump 的 NULL 缺陷)。
|
|
|
|
|
|
|
+// TestDivideUpdateNodeParentFillsRoot 改挂原语显式写 root(**仅数据修复用**,不在 dump 流程内)。
|
|
|
func TestDivideUpdateNodeParentFillsRoot(t *testing.T) {
|
|
func TestDivideUpdateNodeParentFillsRoot(t *testing.T) {
|
|
|
db := newDivideRepoDB(t)
|
|
db := newDivideRepoDB(t)
|
|
|
root := "01HROOT000000000000000005"
|
|
root := "01HROOT000000000000000005"
|