| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- 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 字符应报错(拒绝截断字典名称),实际未报错")
- }
- }
|