|
|
@@ -11,6 +11,7 @@ import (
|
|
|
"math/rand"
|
|
|
"path/filepath"
|
|
|
"testing"
|
|
|
+ "time"
|
|
|
|
|
|
gormsqlite "github.com/glebarez/sqlite"
|
|
|
"gorm.io/gorm"
|
|
|
@@ -219,7 +220,12 @@ func TestAddSubordinateGuards(t *testing.T) {
|
|
|
}
|
|
|
|
|
|
// TestAddSubordinateReparentBranch 已存在节点分支:改挂父链并显式补齐 root(dump NULL 缺陷修正)。
|
|
|
-func TestAddSubordinateReparentBranch(t *testing.T) {
|
|
|
+// TestAddSubordinateExistingNodeNoop 已存在 subid → **完全 no-op**(audit D4,dump 763695)。
|
|
|
+//
|
|
|
+// dump 的 `if exists(... where userid = subid) then ... end if;` 分支体内**没有任何写动作**:
|
|
|
+// 已存在即视为幂等成功,parent/root/layer/左右值一律不动。
|
|
|
+// 旧实现会"改挂父链",等于凭空移动整棵子树且不重排左右值 → 破坏嵌套集不变式(本次删除)。
|
|
|
+func TestAddSubordinateExistingNodeNoop(t *testing.T) {
|
|
|
db := newDivideServiceDB(t)
|
|
|
svc := NewDivideService(db, nil)
|
|
|
ctx := context.Background()
|
|
|
@@ -236,21 +242,24 @@ func TestAddSubordinateReparentBranch(t *testing.T) {
|
|
|
}
|
|
|
|
|
|
if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
|
|
|
- t.Fatalf("改挂分支失败: %v", err)
|
|
|
+ t.Fatalf("已存在节点应幂等成功(no-op),实得 %v", err)
|
|
|
}
|
|
|
got := loadDivideNode(t, db, orphan)
|
|
|
- if got.ParentUserID == nil || *got.ParentUserID != root {
|
|
|
- t.Fatalf("parent 应改为 %s,实得 %v", root, got.ParentUserID)
|
|
|
+ if got.ParentUserID != nil {
|
|
|
+ t.Fatalf("已存在节点不得改挂父链(dump 763695 分支体无写动作),实得 parent=%v", *got.ParentUserID)
|
|
|
}
|
|
|
- if got.RootUserID == nil || *got.RootUserID != root {
|
|
|
- t.Fatalf("root_user_id 必须补齐为 %s(dump 缺陷修正),实得 %v", root, got.RootUserID)
|
|
|
+ if got.RootUserID != nil {
|
|
|
+ t.Fatalf("已存在节点不得写 root_user_id,实得 %v", *got.RootUserID)
|
|
|
}
|
|
|
- if got.Layer != 2 {
|
|
|
- t.Fatalf("layer 应为 2,实得 %d", got.Layer)
|
|
|
+ if got.Layer != 1 {
|
|
|
+ t.Fatalf("layer 不应变化(应 1),实得 %d", got.Layer)
|
|
|
}
|
|
|
- // 不重排左右值(与原 SP 对齐)→ 值保持不变
|
|
|
if got.LeftVal != 100 || got.RightVal != 101 {
|
|
|
- t.Fatalf("改挂分支不应重排左右值,实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
|
+ t.Fatalf("左右值不应重排,实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
|
+ }
|
|
|
+ // 重复调用同样 no-op(幂等)
|
|
|
+ if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
|
|
|
+ t.Fatalf("重复调用应幂等成功,实得 %v", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -327,8 +336,12 @@ func TestAddLogBFSThreeLevels(t *testing.T) {
|
|
|
if rows[i].Remark == nil || *rows[i].Remark != "推荐分成" {
|
|
|
t.Fatalf("remark 应为推荐分成,实得 %v", rows[i].Remark)
|
|
|
}
|
|
|
- if rows[i].Type != model.DivideTypePerLesson {
|
|
|
- t.Fatalf("type 应继承节点(2),实得 %d", rows[i].Type)
|
|
|
+ // 明细 type = **该节点自己的 type**:a 是预置节点(type=2),b 是 AddSubordinate 新建节点
|
|
|
+ // → audit D6 规定新节点固定 1(不继承父的 2),故这里逐用户校验
|
|
|
+ wantType := map[string]int8{a: model.DivideTypePerLesson, b: model.DivideTypeOneOff}
|
|
|
+ if rows[i].Type != wantType[rows[i].UserID] {
|
|
|
+ t.Fatalf("用户 %s 的明细 type 应为 %d(D6:新节点固定 1),实得 %d",
|
|
|
+ rows[i].UserID, wantType[rows[i].UserID], rows[i].Type)
|
|
|
}
|
|
|
}
|
|
|
// total_income 各自累加自己的 quota
|
|
|
@@ -638,8 +651,11 @@ func TestDeleteLogRebindBranch(t *testing.T) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// TestDeleteLogFirstEqualsOrder 首次扣费记录 = 当前订单 → 不属改绑场景,走回滚删除。
|
|
|
-func TestDeleteLogFirstEqualsOrder(t *testing.T) {
|
|
|
+// TestDeleteLogFirstEqualsOrderRebind 首扣记录**就是**当前订单 → 仍然改绑保留(audit D11,dump 762547)。
|
|
|
+//
|
|
|
+// dump 的改绑分支不与当前 orderID 比较:撤的正是"当前这单"而它本身就是首扣记录,
|
|
|
+// 带 `first != orderID` 条件会误判成"无可改绑"→ 回滚删除,把真实产生的分成整组冲掉(少发钱)。
|
|
|
+func TestDeleteLogFirstEqualsOrderRebind(t *testing.T) {
|
|
|
db := newDivideServiceDB(t)
|
|
|
port := &fakeFirstCharged{recordID: 777, found: true}
|
|
|
svc := NewDivideService(db, port)
|
|
|
@@ -653,14 +669,36 @@ func TestDeleteLogFirstEqualsOrder(t *testing.T) {
|
|
|
if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
|
|
|
t.Fatalf("AddLog 失败: %v", err)
|
|
|
}
|
|
|
+ before := loadDivideNode(t, db, a).TotalIncome
|
|
|
+ // 把改绑前的 create_time 拨到 2020:改绑要求"重置为 now()",用 2020 作参照可绕开
|
|
|
+ // sqlite CURRENT_TIMESTAMP 的秒级精度(同秒内 After() 无法区分)
|
|
|
+ old2020 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
+ if err := db.Model(&model.UserDivideIntoLog{}).Where("user_id = ?", a).
|
|
|
+ UpdateColumn("create_time", old2020).Error; err != nil {
|
|
|
+ t.Fatalf("回拨 create_time 失败: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
if err := svc.DeleteLog(ctx, stu, 777); err != nil {
|
|
|
t.Fatalf("DeleteLog 失败: %v", err)
|
|
|
}
|
|
|
- if count := countLogs(t, db); count != 0 {
|
|
|
- t.Fatalf("首次记录即当前订单 → 应回滚删除(0 条),实得 %d", count)
|
|
|
+ if count := countLogs(t, db); count != 1 {
|
|
|
+ t.Fatalf("首扣记录=当前订单 → 应改绑保留(1 条),实得 %d", count)
|
|
|
}
|
|
|
- if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
|
|
|
- t.Fatalf("total_income 应回滚为 0,实得 %d 分", got)
|
|
|
+ var rows []model.UserDivideIntoLog
|
|
|
+ if err := db.Find(&rows).Error; err != nil {
|
|
|
+ t.Fatalf("读明细失败: %v", err)
|
|
|
+ }
|
|
|
+ if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" {
|
|
|
+ t.Fatalf("order_no 应仍为 777(改绑到首扣记录),实得 %v", rows[0].OrderNo)
|
|
|
+ }
|
|
|
+ if rows[0].Remark == nil || *rows[0].Remark != remarkRebind {
|
|
|
+ t.Fatalf("remark 应为 %q,实得 %v", remarkRebind, rows[0].Remark)
|
|
|
+ }
|
|
|
+ if got := loadDivideNode(t, db, a).TotalIncome; got != before {
|
|
|
+ t.Fatalf("改绑不回滚 total_income(应仍 %q),实得 %q", before, got)
|
|
|
+ }
|
|
|
+ if !rows[0].CreateTime.After(old2020) {
|
|
|
+ t.Fatalf("改绑后 create_time 应重置为 now()(> 2020),实得 %s", rows[0].CreateTime)
|
|
|
}
|
|
|
if err := svc.DeleteLog(ctx, stu, 0); !errors.Is(err, apperr.ErrBusinessParam) {
|
|
|
t.Fatalf("orderID<=0 应 3140,实得 %v", err)
|
|
|
@@ -713,6 +751,47 @@ func TestUpdateQuotaCascade(t *testing.T) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// TestUpdateQuotaNoCascadeWhenOrigZero 原 quota=0 时不级联(audit A6,dump 763872 `IF (uQuota>0)`)。
|
|
|
+//
|
|
|
+// 语义:还没配置过额度的节点,被"设成某个值"时不该顺手压低子树(子树额度是独立配置的阶梯)。
|
|
|
+// 对照:节点原额度 > 0 时级联照旧生效(后半段断言)。
|
|
|
+func TestUpdateQuotaNoCascadeWhenOrigZero(t *testing.T) {
|
|
|
+ db := newDivideServiceDB(t)
|
|
|
+ svc := NewDivideService(db, nil)
|
|
|
+ ctx := context.Background()
|
|
|
+ a, b := nodeID(0), nodeID(1)
|
|
|
+ seedRoot(t, db, a, model.DivideTypeOneOff, "0.00") // 原 quota = 0
|
|
|
+ if err := svc.AddSubordinate(ctx, a, b); err != nil {
|
|
|
+ t.Fatalf("建链失败: %v", err)
|
|
|
+ }
|
|
|
+ // 直接把子节点额度设成 5.00(绕过服务,构造"父 0 / 子 5"的起始态)
|
|
|
+ if err := db.Exec("UPDATE t_user_divide_into SET quota = ? WHERE user_id = ?", "5.00", b).Error; err != nil {
|
|
|
+ t.Fatalf("配置子额度失败: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 父原 quota=0 → 设成 10.00 **不级联**:子保持 5.00
|
|
|
+ if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
|
|
|
+ t.Fatalf("UpdateQuota 失败: %v", err)
|
|
|
+ }
|
|
|
+ if got := cents(t, loadDivideNode(t, db, a).Quota); got != 1000 {
|
|
|
+ t.Fatalf("父额度应为 10.00,实得 %d 分", got)
|
|
|
+ }
|
|
|
+ if got := cents(t, loadDivideNode(t, db, b).Quota); got != 500 {
|
|
|
+ t.Fatalf("原 quota=0 不应级联:子应保持 5.00,实得 %d 分", got)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 父原 quota=10.00 > 0 → 降到 3.00 **会级联**:子被压到 3.00
|
|
|
+ if err := svc.UpdateQuota(ctx, a, money.FromCents(300)); err != nil {
|
|
|
+ t.Fatalf("UpdateQuota(第二次) 失败: %v", err)
|
|
|
+ }
|
|
|
+ if got := cents(t, loadDivideNode(t, db, a).Quota); got != 300 {
|
|
|
+ t.Fatalf("父额度应为 3.00,实得 %d 分", got)
|
|
|
+ }
|
|
|
+ if got := cents(t, loadDivideNode(t, db, b).Quota); got != 300 {
|
|
|
+ t.Fatalf("原 quota>0 应级联压到 3.00,实得 %d 分", got)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// TestDivideServiceNestedTxPassThrough 事务透传纪律:本服务在**外层事务内**被调用时
|
|
|
// 必须经 WithTxContext 复用同一 tx(不重复 BEGIN),且随外层回滚一并撤销。
|
|
|
func TestDivideServiceNestedTxPassThrough(t *testing.T) {
|
|
|
@@ -779,6 +858,12 @@ func TestAddLogBFSDepthBoundary(t *testing.T) {
|
|
|
t.Fatalf("链尾 layer 应为 %d,实得 %d", MaxDivideDepth, got)
|
|
|
}
|
|
|
|
|
|
+ // audit D2:quota<=0 的节点不生成明细 → 给链上**每个节点**配上 1.00 额度,
|
|
|
+ // 否则只有根节点会入账(本用例要测的是 BFS 深度边界,不是 D2)
|
|
|
+ if err := db.Exec("UPDATE t_user_divide_into SET quota = ?", "1.00").Error; err != nil {
|
|
|
+ t.Fatalf("统一配置额度失败: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
if err := svc.AddLog(ctx, tail, nodeID(901), 777, ""); err != nil {
|
|
|
t.Fatalf("%d 层链的 BFS 入账应在限内成功,实得 %v", MaxDivideDepth, err)
|
|
|
}
|