// DivideRepo 单测(AC-09):左右值位移 / 嵌套集不变式守卫 / 两级幂等键 / 撤销原语 / 额度级联原语。 // // 注意:本仓储**不继承 BaseRepo**(全局无作用域表),因此没有 scoping 用例; // 越权防线在 Handler 角色白名单(AC-14)+ Service 显式参数,本层只保证数据正确性。 package repository import ( "context" "errors" "path/filepath" "strings" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // newDivideRepoDB 分账域真实库(文件库;2 表 AutoMigrate)。 func newDivideRepoDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_divide_repo_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open(分账文件库) 失败: %v", err) } t.Cleanup(func() { if sqlDB, e := db.DB(); e == nil { _ = sqlDB.Close() } }) if err := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); err != nil { t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", err) } return db } // seedDivideNode 插入分账树节点(显式左右值,用于原语级用例)。 func seedDivideNode(t *testing.T, db *gorm.DB, userID string, typ int8, left, right, layer int32, quota string, parent, root *string) { t.Helper() row := &model.UserDivideInto{ UserID: userID, Type: typ, Quota: quota, TotalIncome: "0.00", TotalIncomePayed: "0.00", RootUserID: root, ParentUserID: parent, LeftVal: left, RightVal: right, Layer: layer, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 分账节点(%s) 失败: %v", userID, err) } } // loadNode 读节点(断言辅助)。 func loadNode(t *testing.T, db *gorm.DB, userID string) model.UserDivideInto { t.Helper() var row model.UserDivideInto if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil { t.Fatalf("读节点(%s) 失败: %v", userID, err) } return row } // centsOf 把库里的金额字符串归一为「分」(sqlite NUMERIC 亲和回读会丢 "0.00" 尾巴,故不得比字符串)。 func centsOf(t *testing.T, s string) int64 { t.Helper() amt, err := money.Parse(s) if err != nil { t.Fatalf("金额解析失败 %q: %v", s, err) } return amt.Cents() } // TestDivideGetByUserID 命中 / 未命中(ErrNotFound)/ 参数防御。 func TestDivideGetByUserID(t *testing.T) { db := newDivideRepoDB(t) root := "01HROOT000000000000000001" seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 2, 1, "10.00", nil, &root) repo := NewDivideRepo(db) ctx := context.Background() got, err := repo.GetByUserID(ctx, root) if err != nil { t.Fatalf("GetByUserID 失败: %v", err) } if got.LeftVal != 1 || got.RightVal != 2 || got.Layer != 1 { t.Fatalf("节点左右值/层级不符: %+v", got) } if _, err := repo.GetByUserID(ctx, "01HNONE000000000000000001"); !errors.Is(err, infraerrors.ErrNotFound) { t.Fatalf("不存在应 ErrNotFound,实得 %v", err) } if _, err := repo.GetByUserID(ctx, ""); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err) } if _, err := (&DivideRepo{}).GetByUserID(ctx, root); !errors.Is(err, infraerrors.ErrInternal) { t.Fatalf("DB 未装配应 ErrInternal,实得 %v", err) } } // TestDivideShiftLeftRight 左右值位移(+2/-2 与阈值语义)。 func TestDivideShiftLeftRight(t *testing.T) { db := newDivideRepoDB(t) root := "01HROOT000000000000000002" a, b := "01HNODEA00000000000000002", "01HNODEB00000000000000002" seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root) seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &root, &root) seedDivideNode(t, db, b, model.DivideTypeOneOff, 4, 5, 2, "5.00", &root, &root) repo := NewDivideRepo(db) runner := NewTxRunner(db) ctx := context.Background() // 在 root 右值前插入:先 right>=6 +2,再 left>=6 +2(无节点满足) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.ShiftRight(ctx, tx, root, 6, 2) if e != nil { return e } if n != 1 { t.Fatalf("ShiftRight(6,+2) 应影响 1 行(root),实得 %d", n) } _, e = repo.ShiftLeft(ctx, tx, root, 6, 2) return e }); err != nil { t.Fatalf("位移失败: %v", err) } if got := loadNode(t, db, root); got.RightVal != 8 { t.Fatalf("root.right 应为 8,实得 %d", got.RightVal) } // 在 a 右值前插入:right>=3 全体 +2(root、a、b),left>=3 全体 +2(b) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { if _, e := repo.ShiftRight(ctx, tx, root, 3, 2); e != nil { return e } _, e := repo.ShiftLeft(ctx, tx, root, 3, 2) return e }); err != nil { t.Fatalf("位移失败: %v", err) } if got := loadNode(t, db, root); got.LeftVal != 1 || got.RightVal != 10 { t.Fatalf("root 应为 (1,10),实得 (%d,%d)", got.LeftVal, got.RightVal) } if got := loadNode(t, db, a); got.LeftVal != 2 || got.RightVal != 5 { t.Fatalf("a 应为 (2,5),实得 (%d,%d)", got.LeftVal, got.RightVal) } if got := loadNode(t, db, b); got.LeftVal != 6 || got.RightVal != 7 { t.Fatalf("b 应为 (6,7),实得 (%d,%d)", got.LeftVal, got.RightVal) } // 防御: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) } 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) } 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) } } // 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 应 ErrBadParam(不变式守卫),实得 %v", err) } if err := repo.InsertNode(ctx, db, &model.UserDivideInto{UserID: " ", LeftVal: 1, RightVal: 2}); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err) } if err := repo.InsertNode(ctx, db, &model.UserDivideInto{ UserID: "01HOK00000000000000000001", Type: model.DivideTypeOneOff, Quota: "1.00", TotalIncome: "0.00", TotalIncomePayed: "0.00", LeftVal: 1, RightVal: 2, Layer: 1, }); err != nil { t.Fatalf("InsertNode 失败: %v", err) } } // TestDivideGetSubtree 子树查询(严格区间 + 类型过滤 + 前序排序)。 func TestDivideGetSubtree(t *testing.T) { db := newDivideRepoDB(t) root := "01HROOT000000000000000003" a, b := "01HNODEA00000000000000003", "01HNODEB00000000000000003" seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root) seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &root, &root) seedDivideNode(t, db, b, model.DivideTypePerLesson, 4, 5, 2, "5.00", &root, &root) repo := NewDivideRepo(db) ctx := context.Background() all, err := repo.GetSubtree(ctx, 1, 6, 0) if err != nil { t.Fatalf("GetSubtree 失败: %v", err) } if len(all) != 2 || all[0].UserID != a || all[1].UserID != b { t.Fatalf("子树应为 [a,b](left 升序),实得 %+v", all) } onlyOne, err := repo.GetSubtree(ctx, 1, 6, model.DivideTypePerLesson) if err != nil { t.Fatalf("GetSubtree(type) 失败: %v", err) } if len(onlyOne) != 1 || onlyOne[0].UserID != b { t.Fatalf("type=2 子树应仅 [b],实得 %+v", onlyOne) } if _, err := repo.GetSubtree(ctx, 6, 1, 0); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("非法区间应 ErrBadParam,实得 %v", err) } } // TestDivideFindLogKeys 两级幂等键(type=1 忽略订单;type=2 含订单)。 func TestDivideFindLogKeys(t *testing.T) { db := newDivideRepoDB(t) repo := NewDivideRepo(db) ctx := context.Background() stu := "01HSTU00000000000000000001" u := "01HUSER000000000000000021" if err := db.Create(&model.UserDivideIntoLog{ Type: model.DivideTypeOneOff, UserID: u, StudentID: stu, OrderNo: ptrString("100"), Money: "10.00", CreateTime: time.Now(), }).Error; err != nil { t.Fatalf("seed log 失败: %v", err) } if err := db.Create(&model.UserDivideIntoLog{ Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("200"), Money: "5.00", CreateTime: time.Now(), }).Error; err != nil { t.Fatalf("seed log 失败: %v", err) } // type=1:任意 order 均命中同一行(一次付清口径) got, err := repo.FindLog(ctx, u, stu, model.DivideTypeOneOff, "999") if err != nil { t.Fatalf("FindLog(type1) 失败: %v", err) } if c := centsOf(t, got.Money); c != 1000 { t.Fatalf("type=1 应命中 10.00 的行,实得 %q(%d 分)", got.Money, c) } // type=2:order 必须匹配 if _, err := repo.FindLog(ctx, u, stu, model.DivideTypePerLesson, "200"); err != nil { t.Fatalf("FindLog(type2,200) 应命中: %v", err) } if _, err := repo.FindLog(ctx, u, stu, model.DivideTypePerLesson, "999"); !errors.Is(err, infraerrors.ErrNotFound) { t.Fatalf("type=2 不同 order 应 ErrNotFound,实得 %v", err) } if _, err := repo.FindLog(ctx, "", stu, model.DivideTypeOneOff, "1"); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err) } } // TestDivideListLogsByStudentOrder 两参撤销入口:按 (student, order) 取全量上行明细。 func TestDivideListLogsByStudentOrder(t *testing.T) { db := newDivideRepoDB(t) repo := NewDivideRepo(db) ctx := context.Background() stu := "01HSTU00000000000000000002" for _, u := range []string{"01HUSER000000000000000031", "01HUSER000000000000000032", "01HUSER000000000000000033"} { if err := db.Create(&model.UserDivideIntoLog{ Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("777"), Money: "1.00", CreateTime: time.Now(), }).Error; err != nil { t.Fatalf("seed log 失败: %v", err) } } rows, err := repo.ListLogsByStudentOrder(ctx, stu, "777") if err != nil { t.Fatalf("ListLogsByStudentOrder 失败: %v", err) } if len(rows) != 3 { t.Fatalf("应返回同一订单的 3 条上行明细,实得 %d", len(rows)) } if rows[0].ID >= rows[1].ID || rows[1].ID >= rows[2].ID { t.Fatalf("应按 id 升序返回,实得 %+v", rows) } empty, err := repo.ListLogsByStudentOrder(ctx, stu, "888") if err != nil || len(empty) != 0 { t.Fatalf("无匹配应返回空切片,实得 %v err=%v", empty, err) } if _, err := repo.ListLogsByStudentOrder(ctx, stu, ""); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 order_no 应 ErrBadParam,实得 %v", err) } } // TestDivideQuotaPrimitives 额度更新 / 级联只降不升 / 收入累加。 func TestDivideQuotaPrimitives(t *testing.T) { db := newDivideRepoDB(t) root := "01HROOT000000000000000004" a := "01HNODEA00000000000000004" seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root) seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 5, 2, "8.00", &root, &root) repo := NewDivideRepo(db) runner := NewTxRunner(db) ctx := context.Background() // 级联只降:子树限制 4.00 → a(8.00) 降到 4.00 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 个子节点,实得 %d", n) } return nil }); err != nil { t.Fatalf("ClampSubtreeQuota 失败: %v", err) } if got := loadNode(t, db, a); centsOf(t, got.Quota) != 400 { t.Fatalf("a.quota 应压到 4.00,实得 %q", got.Quota) } // 再压到 6.00(高于现值)→ 不回升(影响 0 行) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(600)) if e != nil { return e } if n != 0 { t.Fatalf("压制只降不升,应 0 行,实得 %d", n) } return nil }); err != nil { t.Fatalf("ClampSubtreeQuota(仅降) 失败: %v", err) } // 收入累加 + 冲正 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { if _, e := repo.IncTotalIncome(ctx, tx, a, money.FromCents(1250)); e != nil { return e } _, e := repo.IncTotalIncome(ctx, tx, a, money.FromCents(-250)) return e }); err != nil { t.Fatalf("IncTotalIncome 失败: %v", err) } if got := loadNode(t, db, a); centsOf(t, got.TotalIncome) != 1000 { t.Fatalf("a.total_income 应为 10.00,实得 %q", got.TotalIncome) } // 额度更新 + 负额度拒绝 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.UpdateNodeQuota(ctx, tx, a, money.FromCents(1500)) return e }); err != nil { t.Fatalf("UpdateNodeQuota 失败: %v", err) } if got := loadNode(t, db, a); centsOf(t, got.Quota) != 1500 { t.Fatalf("a.quota 应为 15.00,实得 %q", got.Quota) } if _, err := repo.UpdateNodeQuota(ctx, db, a, money.FromCents(-1)); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("负额度应 ErrBadParam,实得 %v", err) } if _, err := repo.ClampSubtreeQuota(ctx, db, 5, 1, model.DivideTypeOneOff, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) { 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 删行。 func TestDivideLogPrimitives(t *testing.T) { db := newDivideRepoDB(t) repo := NewDivideRepo(db) runner := NewTxRunner(db) ctx := context.Background() stu := "01HSTU00000000000000000003" u := "01HUSER000000000000000041" if err := repo.InsertLog(ctx, db, &model.UserDivideIntoLog{Type: 9, UserID: u, StudentID: stu}); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("非法 type 应 ErrBadParam,实得 %v", err) } if err := repo.InsertLog(ctx, db, &model.UserDivideIntoLog{Type: model.DivideTypeOneOff, UserID: "", StudentID: stu}); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err) } var logID int64 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { row := &model.UserDivideIntoLog{ Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("777"), Money: "3.50", CreateTime: time.Now(), } if e := repo.InsertLog(ctx, tx, row); e != nil { return e } logID = row.ID return nil }); err != nil { t.Fatalf("InsertLog 失败: %v", err) } // 改绑:order_no=888 + 备注 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.UpdateLogOrderNo(ctx, tx, logID, "888", "推广分成") if e != nil { return e } if n != 1 { t.Fatalf("改绑应影响 1 行,实得 %d", n) } return nil }); err != nil { t.Fatalf("UpdateLogOrderNo 失败: %v", err) } var row model.UserDivideIntoLog if err := db.Where("id = ?", logID).First(&row).Error; err != nil { t.Fatalf("读 log 失败: %v", err) } if row.OrderNo == nil || *row.OrderNo != "888" { t.Fatalf("order_no 应改为 888,实得 %v", row.OrderNo) } if row.Remark == nil || *row.Remark != "推广分成" { t.Fatalf("remark 应写推广分成,实得 %v", row.Remark) } // 删除 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.DeleteLog(ctx, tx, logID) }); err != nil { t.Fatalf("DeleteLog 失败: %v", err) } var count int64 if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil { t.Fatalf("count 失败: %v", err) } if count != 0 { t.Fatalf("删除后应为 0 行,实得 %d", count) } if err := repo.DeleteLog(ctx, nil, 1); !errors.Is(err, infraerrors.ErrInternal) { t.Fatalf("tx nil 应 ErrInternal,实得 %v", err) } } // TestDivideUpdateNodeParentFillsRoot 改挂原语显式写 root(**仅数据修复用**,不在 dump 流程内)。 func TestDivideUpdateNodeParentFillsRoot(t *testing.T) { db := newDivideRepoDB(t) root := "01HROOT000000000000000005" orphan := "01HORPH000000000000000005" seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 2, 1, "10.00", nil, &root) seedDivideNode(t, db, orphan, model.DivideTypeOneOff, 100, 101, 1, "0.00", nil, nil) // root 为 NULL(dump 脏数据形态) repo := NewDivideRepo(db) runner := NewTxRunner(db) ctx := context.Background() if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.UpdateNodeParent(ctx, tx, orphan, root, root, 2) return e }); err != nil { t.Fatalf("UpdateNodeParent 失败: %v", err) } got := loadNode(t, db, orphan) if got.ParentUserID == nil || *got.ParentUserID != root { t.Fatalf("parent 应为 %s,实得 %v", root, got.ParentUserID) } if got.RootUserID == nil || *got.RootUserID != root { t.Fatalf("root_user_id 必须显式补齐(dump 缺陷修正),实得 %v", got.RootUserID) } if got.Layer != 2 { t.Fatalf("layer 应为 2,实得 %d", got.Layer) } if _, err := repo.UpdateNodeParent(ctx, db, orphan, "", root, 2); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 parent 应 ErrBadParam,实得 %v", err) } if _, err := repo.UpdateNodeParent(ctx, db, orphan, root, root, 0); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("layer<1 应 ErrBadParam,实得 %v", err) } } // TestIsNotFound 便捷判定(避免各处抄 errors.Is)。 func TestIsNotFound(t *testing.T) { if IsNotFound(nil) { t.Fatal("nil 不应判为 NotFound") } if !IsNotFound(infraerrors.WithCause(infraerrors.ErrNotFound, errors.New("x"))) { t.Fatal("WithCause(ErrNotFound) 应判为 NotFound") } if !strings.Contains(infraerrors.ErrNotFound.Error(), "不存在") { t.Fatal("ErrNotFound 文案契约被改动") } }