package fixture import ( "database/sql" "testing" "time" ) // 本文件单测**第二批的纯映射规则**(不连库):biz_no 校验、空值语义、金额取整、时间口径。 // 端到端核对由 cmd/ac15fixture verify2 / idem2 完成(需要真库,不进单测)。 const validBizNo = "f117520d3fde2c0db866e5bdb010d8af" func TestNormalizeBizNo(t *testing.T) { // 正常:32 位小写 hex 原样返回 got, err := NormalizeBizNo(validBizNo) if err != nil { t.Fatalf("正常 biz_no 不应报错:%v", err) } if got != validBizNo { t.Errorf("biz_no 必须原样返回,得到 %q", got) } // 三类必须拒绝且**不截断、不改写** bad := []struct{ name, in string }{ {"空串", ""}, {"31 位(短)", validBizNo[:31]}, {"33 位(长)", validBizNo + "a"}, {"大写 hex", "F117520D3FDE2C0DB866E5BDB010D8AF"}, {"非 hex 字符", "z117520d3fde2c0db866e5bdb010d8af"}, {"带横线 UUID", "f117520d-3fde-2c0d-b866-e5bdb010d8af"}, } for _, b := range bad { if _, err := NormalizeBizNo(b.in); err == nil { t.Errorf("%s:%q 应被拒绝(不允许截断/改写),实际通过", b.name, b.in) } } } func TestMapBalanceRequestBizNoAndNulls(t *testing.T) { l := LegacyBalanceRequest{ BizNo: validBizNo, Type: 202, UserID: 31348, SchoolID: 63317, CampusID: 5, Money: sql.NullString{String: "1737.360", Valid: true}, TransferAccountsType: 0, TransferAccountsTime: sql.NullTime{}, // NULL → NULL OrderNo: sql.NullString{}, // NULL → NULL RequestUserID: 45268, RequestTime: sql.NullTime{Time: time.Date(2020, 9, 29, 19, 37, 15, 0, time.Local), Valid: true}, ResponseUserID: sql.NullInt64{Int64: 0, Valid: true}, // 0 → NULL ResponseTime: sql.NullTime{}, // NULL → NULL ResponseMsg: sql.NullString{String: "", Valid: true}, Aproved: -2, Item: sql.NullString{String: `{"schoolid":"63317"}`, Valid: true}, Imgs: sql.NullString{}, Remark: sql.NullString{String: "", Valid: true}, } resolve := func(id int64) (string, bool) { if id == 31348 { return "01USER000000000000000003", true } if id == 45268 { return "01USER000000000000000452", true } return "", false } got, warns, err := MapBalanceRequest(l, resolve) if err != nil { t.Fatalf("MapBalanceRequest 出错:%v", err) } if got.BizNo != validBizNo { t.Errorf("biz_no 应原样保留,得到 %q", got.BizNo) } if got.MoneyCents != 173736 { t.Errorf("money 1737.360 → 173736 分,实际 %d", got.MoneyCents) } if got.ResponseUserID.Valid { t.Errorf("response_userid=0 应映射为 NULL,实际 %q", got.ResponseUserID.String) } if got.TransferAccountsTime.Valid || got.ResponseTime.Valid || got.OrderNo.Valid { t.Error("legacy NULL 的三列应落 NULL") } if got.ToInstitutionID.Valid || got.ToCampusID.Valid { t.Error("legacy 无转入机构/校区列,应落 NULL") } if !got.ResponseMsg.Valid || got.ResponseMsg.String != "" { t.Error("response_msg 的空串应保留空串(不转 NULL)") } if got.Approved != -2 { t.Errorf("approved 应原样保留 -2,实际 %d", got.Approved) } // created_at = request_time;response_time 为 NULL 时 updated_at 退回 request_time if !got.CreatedAt.Equal(l.RequestTime.Time) || !got.UpdatedAt.Equal(l.RequestTime.Time) { t.Errorf("created_at/updated_at 应由 request_time 派生,实际 %v / %v", got.CreatedAt, got.UpdatedAt) } if len(warns) != 0 { t.Errorf("不应有告警,实际 %v", warns) } } // TestMapBalanceRequestBizNoRejected 证明「biz_no 长度不为 32 时直接报错,不截断」。 func TestMapBalanceRequestBizNoRejected(t *testing.T) { l := LegacyBalanceRequest{BizNo: "abc", UserID: 1, RequestUserID: 1, RequestTime: sql.NullTime{Time: time.Now(), Valid: true}} if _, _, err := MapBalanceRequest(l, func(int64) (string, bool) { return "01X", true }); err == nil { t.Error("biz_no 长度不为 32 应报错(拒绝截断),实际未报错") } } // TestMapDivideIntoRootSemantics 证明「根节点 parent_userid=0 → NULL,root_userid=自身」。 func TestMapDivideIntoRootSemantics(t *testing.T) { root := LegacyDivideInto{ UserID: 395, Type: 1, Quota: sql.NullString{String: "300.00", Valid: true}, TotalIncome: sql.NullString{String: "1200.00", Valid: true}, TotalIncomePayed: sql.NullString{String: "900.00", Valid: true}, RootUserID: sql.NullInt64{Int64: 395, Valid: true}, ParentUserID: sql.NullInt64{Int64: 0, Valid: true}, LeftVal: 1, RightVal: 16, Layer: 1, } resolve := func(id int64) (string, bool) { return FixtureCanonicalUserID(id), id > 0 } got, _, err := MapDivideInto(root, resolve) if err != nil { t.Fatalf("MapDivideInto 出错:%v", err) } if got.ParentUserID.Valid { t.Errorf("根节点 parent_userid=0 应落 NULL,实际 %q", got.ParentUserID.String) } if !got.RootUserID.Valid || got.RootUserID.String != FixtureCanonicalUserID(395) { t.Error("根节点 root_userid 应解析成自身 ULID") } if got.QuotaCents != 30000 || got.TotalIncomeCents != 120000 || got.TotalIncomePayedCents != 90000 { t.Errorf("金额换算错误:%d/%d/%d", got.QuotaCents, got.TotalIncomeCents, got.TotalIncomePayedCents) } // legacy 无时间列 → 必须落 epoch 哨兵,绝不是当前时间 if !got.CreatedAt.Equal(epochSentinel()) || !got.UpdatedAt.Equal(epochSentinel()) { t.Errorf("legacy 无时间列时应落 epoch 哨兵,实际 %v / %v", got.CreatedAt, got.UpdatedAt) } } // TestMapDivideIntoChildParent 证明子节点 parent_userid 解析成 ULID。 func TestMapDivideIntoChildParent(t *testing.T) { child := LegacyDivideInto{ UserID: 43210, Type: 1, RootUserID: sql.NullInt64{Int64: 395, Valid: true}, ParentUserID: sql.NullInt64{Int64: 395, Valid: true}, LeftVal: 6, RightVal: 7, Layer: 2, } got, warns, err := MapDivideInto(child, func(id int64) (string, bool) { return FixtureCanonicalUserID(id), true }) if err != nil { t.Fatalf("MapDivideInto 出错:%v", err) } if got.ParentUserID.String != FixtureCanonicalUserID(395) { t.Errorf("子节点 parent_user_id 应为 395 的 ULID,实际 %q", got.ParentUserID.String) } // quota/total_income 全 NULL → 落 0.00 且必须告警 if got.QuotaCents != 0 || got.TotalIncomeCents != 0 { t.Error("NULL 金额应落 0") } if len(warns) != 3 { t.Errorf("3 个 NULL 金额列应产生 3 条告警,实际 %d", len(warns)) } } // TestMapDivideIntoLogD1 证明流水表 D1(id 原样)+ 学生引用解析。 func TestMapDivideIntoLogD1(t *testing.T) { l := LegacyDivideIntoLog{ ID: 26, Type: 1, UserID: 395, StudentID: 43210, OrderNo: sql.NullString{String: validBizNo, Valid: true}, Money: sql.NullString{String: "300.00", Valid: true}, CreateTime: sql.NullTime{Time: time.Date(2018, 7, 10, 17, 3, 39, 0, time.Local), Valid: true}, Remark: sql.NullString{String: "推广分成", Valid: true}, } got, _, err := MapDivideIntoLog(l, func(id int64) (string, bool) { return FixtureCanonicalUserID(id), true }) if err != nil { t.Fatalf("MapDivideIntoLog 出错:%v", err) } if got.ID != 26 { t.Errorf("D1:id 应原样保留 26,实际 %d", got.ID) } if got.MoneyCents != 30000 { t.Errorf("money 300.00 → 30000 分,实际 %d", got.MoneyCents) } if got.CreateTime.Nanosecond() != 0 { t.Error("create_time 毫秒应为 0") } } // TestMapExpenditureLogBizNoAndRefs 证明 biz_no 原样 + create_userid 0 → NULL。 func TestMapExpenditureLogBizNoAndRefs(t *testing.T) { l := LegacyExpenditureLog{ BizNo: validBizNo, SchoolID: 63317, CampusID: 2, ExpenditureID: 23, Cost: sql.NullString{String: "4952.953", Valid: true}, CreateUserID: sql.NullInt64{Int64: 0, Valid: true}, CreateTime: sql.NullTime{Time: time.Date(2019, 10, 7, 16, 39, 45, 0, time.Local), Valid: true}, Year: sql.NullInt64{Int64: 2019, Valid: true}, Month: sql.NullInt64{Int64: 10, Valid: true}, } got, warns, err := MapExpenditureLog(l, func(int64) (string, bool) { return "", false }) if err != nil { t.Fatalf("MapExpenditureLog 出错:%v", err) } if got.BizNo != validBizNo { t.Errorf("biz_no 应原样保留,得到 %q", got.BizNo) } if got.CostCents != 495295 { t.Errorf("cost 4952.953 → 4952.95(495295 分),实际 %d", got.CostCents) } if got.CreateUserID.Valid { t.Error("create_userid=0 应落 NULL") } if got.ExpenditureID != 23 { t.Errorf("expenditure_id 应恒等映射 23,实际 %d", got.ExpenditureID) } if len(warns) != 0 { t.Errorf("不应有告警,实际 %v", warns) } } // TestMapExpenditureLogRejectsNullCreateTime 证明 target NOT NULL 的时间列不会被当前时间填充。 func TestMapExpenditureLogRejectsNullCreateTime(t *testing.T) { l := LegacyExpenditureLog{BizNo: validBizNo, Cost: sql.NullString{String: "1.000", Valid: true}} if _, _, err := MapExpenditureLog(l, func(int64) (string, bool) { return "", false }); err == nil { t.Error("create_time 为 NULL 时应报错(不得用当前时间填充),实际未报错") } } // TestRequireUserRefRejectsUnmapped 证明目标态 NOT NULL 的引用人列解析不到时**报错而不是 NULL**。 func TestRequireUserRefRejectsUnmapped(t *testing.T) { if _, err := requireUserRef(999999, func(int64) (string, bool) { return "", false }, "user_id"); err == nil { t.Error("无映射行的 NOT NULL 引用列应报错,实际未报错") } if _, err := requireUserRef(0, func(int64) (string, bool) { return "x", true }, "user_id"); err == nil { t.Error("legacy 0 的 NOT NULL 引用列应报错,实际未报错") } } // TestMapExpenditureDict 证明字典 D1 + 名称超长拒绝截断。 func TestMapExpenditureDict(t *testing.T) { d := LegacyExpenditure{ID: 23, Type: 1, ParentID: 0, Name: "水电费"} got, _, err := MapExpenditure(d) if err != nil { t.Fatalf("MapExpenditure 出错:%v", err) } if got.ID != 23 || got.InstitutionID != 0 { t.Errorf("字典应 D1 保留 id=23 且 institution_id=0,实际 %d/%d", got.ID, got.InstitutionID) } long := LegacyExpenditure{ID: 1, Name: string(make([]rune, 501))} if _, _, err := MapExpenditure(long); err == nil { t.Error("name 超 500 字符应报错(拒绝截断字典名称),实际未报错") } }