batch2_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. package fixture
  2. import (
  3. "database/sql"
  4. "testing"
  5. "time"
  6. )
  7. // 本文件单测**第二批的纯映射规则**(不连库):biz_no 校验、空值语义、金额取整、时间口径。
  8. // 端到端核对由 cmd/ac15fixture verify2 / idem2 完成(需要真库,不进单测)。
  9. const validBizNo = "f117520d3fde2c0db866e5bdb010d8af"
  10. func TestNormalizeBizNo(t *testing.T) {
  11. // 正常:32 位小写 hex 原样返回
  12. got, err := NormalizeBizNo(validBizNo)
  13. if err != nil {
  14. t.Fatalf("正常 biz_no 不应报错:%v", err)
  15. }
  16. if got != validBizNo {
  17. t.Errorf("biz_no 必须原样返回,得到 %q", got)
  18. }
  19. // 三类必须拒绝且**不截断、不改写**
  20. bad := []struct{ name, in string }{
  21. {"空串", ""},
  22. {"31 位(短)", validBizNo[:31]},
  23. {"33 位(长)", validBizNo + "a"},
  24. {"大写 hex", "F117520D3FDE2C0DB866E5BDB010D8AF"},
  25. {"非 hex 字符", "z117520d3fde2c0db866e5bdb010d8af"},
  26. {"带横线 UUID", "f117520d-3fde-2c0d-b866-e5bdb010d8af"},
  27. }
  28. for _, b := range bad {
  29. if _, err := NormalizeBizNo(b.in); err == nil {
  30. t.Errorf("%s:%q 应被拒绝(不允许截断/改写),实际通过", b.name, b.in)
  31. }
  32. }
  33. }
  34. func TestMapBalanceRequestBizNoAndNulls(t *testing.T) {
  35. l := LegacyBalanceRequest{
  36. BizNo: validBizNo,
  37. Type: 202,
  38. UserID: 31348,
  39. SchoolID: 63317,
  40. CampusID: 5,
  41. Money: sql.NullString{String: "1737.360", Valid: true},
  42. TransferAccountsType: 0,
  43. TransferAccountsTime: sql.NullTime{}, // NULL → NULL
  44. OrderNo: sql.NullString{}, // NULL → NULL
  45. RequestUserID: 45268,
  46. RequestTime: sql.NullTime{Time: time.Date(2020, 9, 29, 19, 37, 15, 0, time.Local), Valid: true},
  47. ResponseUserID: sql.NullInt64{Int64: 0, Valid: true}, // 0 → NULL
  48. ResponseTime: sql.NullTime{}, // NULL → NULL
  49. ResponseMsg: sql.NullString{String: "", Valid: true},
  50. Aproved: -2,
  51. Item: sql.NullString{String: `{"schoolid":"63317"}`, Valid: true},
  52. Imgs: sql.NullString{},
  53. Remark: sql.NullString{String: "", Valid: true},
  54. }
  55. resolve := func(id int64) (string, bool) {
  56. if id == 31348 {
  57. return "01USER000000000000000003", true
  58. }
  59. if id == 45268 {
  60. return "01USER000000000000000452", true
  61. }
  62. return "", false
  63. }
  64. got, warns, err := MapBalanceRequest(l, resolve)
  65. if err != nil {
  66. t.Fatalf("MapBalanceRequest 出错:%v", err)
  67. }
  68. if got.BizNo != validBizNo {
  69. t.Errorf("biz_no 应原样保留,得到 %q", got.BizNo)
  70. }
  71. if got.MoneyCents != 173736 {
  72. t.Errorf("money 1737.360 → 173736 分,实际 %d", got.MoneyCents)
  73. }
  74. if got.ResponseUserID.Valid {
  75. t.Errorf("response_userid=0 应映射为 NULL,实际 %q", got.ResponseUserID.String)
  76. }
  77. if got.TransferAccountsTime.Valid || got.ResponseTime.Valid || got.OrderNo.Valid {
  78. t.Error("legacy NULL 的三列应落 NULL")
  79. }
  80. if got.ToInstitutionID.Valid || got.ToCampusID.Valid {
  81. t.Error("legacy 无转入机构/校区列,应落 NULL")
  82. }
  83. if !got.ResponseMsg.Valid || got.ResponseMsg.String != "" {
  84. t.Error("response_msg 的空串应保留空串(不转 NULL)")
  85. }
  86. if got.Approved != -2 {
  87. t.Errorf("approved 应原样保留 -2,实际 %d", got.Approved)
  88. }
  89. // created_at = request_time;response_time 为 NULL 时 updated_at 退回 request_time
  90. if !got.CreatedAt.Equal(l.RequestTime.Time) || !got.UpdatedAt.Equal(l.RequestTime.Time) {
  91. t.Errorf("created_at/updated_at 应由 request_time 派生,实际 %v / %v", got.CreatedAt, got.UpdatedAt)
  92. }
  93. if len(warns) != 0 {
  94. t.Errorf("不应有告警,实际 %v", warns)
  95. }
  96. }
  97. // TestMapBalanceRequestBizNoRejected 证明「biz_no 长度不为 32 时直接报错,不截断」。
  98. func TestMapBalanceRequestBizNoRejected(t *testing.T) {
  99. l := LegacyBalanceRequest{BizNo: "abc", UserID: 1, RequestUserID: 1,
  100. RequestTime: sql.NullTime{Time: time.Now(), Valid: true}}
  101. if _, _, err := MapBalanceRequest(l, func(int64) (string, bool) { return "01X", true }); err == nil {
  102. t.Error("biz_no 长度不为 32 应报错(拒绝截断),实际未报错")
  103. }
  104. }
  105. // TestMapDivideIntoRootSemantics 证明「根节点 parent_userid=0 → NULL,root_userid=自身」。
  106. func TestMapDivideIntoRootSemantics(t *testing.T) {
  107. root := LegacyDivideInto{
  108. UserID: 395, Type: 1,
  109. Quota: sql.NullString{String: "300.00", Valid: true},
  110. TotalIncome: sql.NullString{String: "1200.00", Valid: true},
  111. TotalIncomePayed: sql.NullString{String: "900.00", Valid: true},
  112. RootUserID: sql.NullInt64{Int64: 395, Valid: true},
  113. ParentUserID: sql.NullInt64{Int64: 0, Valid: true},
  114. LeftVal: 1, RightVal: 16, Layer: 1,
  115. }
  116. resolve := func(id int64) (string, bool) { return FixtureCanonicalUserID(id), id > 0 }
  117. got, _, err := MapDivideInto(root, resolve)
  118. if err != nil {
  119. t.Fatalf("MapDivideInto 出错:%v", err)
  120. }
  121. if got.ParentUserID.Valid {
  122. t.Errorf("根节点 parent_userid=0 应落 NULL,实际 %q", got.ParentUserID.String)
  123. }
  124. if !got.RootUserID.Valid || got.RootUserID.String != FixtureCanonicalUserID(395) {
  125. t.Error("根节点 root_userid 应解析成自身 ULID")
  126. }
  127. if got.QuotaCents != 30000 || got.TotalIncomeCents != 120000 || got.TotalIncomePayedCents != 90000 {
  128. t.Errorf("金额换算错误:%d/%d/%d", got.QuotaCents, got.TotalIncomeCents, got.TotalIncomePayedCents)
  129. }
  130. // legacy 无时间列 → 必须落 epoch 哨兵,绝不是当前时间
  131. if !got.CreatedAt.Equal(epochSentinel()) || !got.UpdatedAt.Equal(epochSentinel()) {
  132. t.Errorf("legacy 无时间列时应落 epoch 哨兵,实际 %v / %v", got.CreatedAt, got.UpdatedAt)
  133. }
  134. }
  135. // TestMapDivideIntoChildParent 证明子节点 parent_userid 解析成 ULID。
  136. func TestMapDivideIntoChildParent(t *testing.T) {
  137. child := LegacyDivideInto{
  138. UserID: 43210, Type: 1,
  139. RootUserID: sql.NullInt64{Int64: 395, Valid: true},
  140. ParentUserID: sql.NullInt64{Int64: 395, Valid: true},
  141. LeftVal: 6, RightVal: 7, Layer: 2,
  142. }
  143. got, warns, err := MapDivideInto(child, func(id int64) (string, bool) { return FixtureCanonicalUserID(id), true })
  144. if err != nil {
  145. t.Fatalf("MapDivideInto 出错:%v", err)
  146. }
  147. if got.ParentUserID.String != FixtureCanonicalUserID(395) {
  148. t.Errorf("子节点 parent_user_id 应为 395 的 ULID,实际 %q", got.ParentUserID.String)
  149. }
  150. // quota/total_income 全 NULL → 落 0.00 且必须告警
  151. if got.QuotaCents != 0 || got.TotalIncomeCents != 0 {
  152. t.Error("NULL 金额应落 0")
  153. }
  154. if len(warns) != 3 {
  155. t.Errorf("3 个 NULL 金额列应产生 3 条告警,实际 %d", len(warns))
  156. }
  157. }
  158. // TestMapDivideIntoLogD1 证明流水表 D1(id 原样)+ 学生引用解析。
  159. func TestMapDivideIntoLogD1(t *testing.T) {
  160. l := LegacyDivideIntoLog{
  161. ID: 26, Type: 1, UserID: 395, StudentID: 43210,
  162. OrderNo: sql.NullString{String: validBizNo, Valid: true},
  163. Money: sql.NullString{String: "300.00", Valid: true},
  164. CreateTime: sql.NullTime{Time: time.Date(2018, 7, 10, 17, 3, 39, 0, time.Local), Valid: true},
  165. Remark: sql.NullString{String: "推广分成", Valid: true},
  166. }
  167. got, _, err := MapDivideIntoLog(l, func(id int64) (string, bool) { return FixtureCanonicalUserID(id), true })
  168. if err != nil {
  169. t.Fatalf("MapDivideIntoLog 出错:%v", err)
  170. }
  171. if got.ID != 26 {
  172. t.Errorf("D1:id 应原样保留 26,实际 %d", got.ID)
  173. }
  174. if got.MoneyCents != 30000 {
  175. t.Errorf("money 300.00 → 30000 分,实际 %d", got.MoneyCents)
  176. }
  177. if got.CreateTime.Nanosecond() != 0 {
  178. t.Error("create_time 毫秒应为 0")
  179. }
  180. }
  181. // TestMapExpenditureLogBizNoAndRefs 证明 biz_no 原样 + create_userid 0 → NULL。
  182. func TestMapExpenditureLogBizNoAndRefs(t *testing.T) {
  183. l := LegacyExpenditureLog{
  184. BizNo: validBizNo, SchoolID: 63317, CampusID: 2, ExpenditureID: 23,
  185. Cost: sql.NullString{String: "4952.953", Valid: true},
  186. CreateUserID: sql.NullInt64{Int64: 0, Valid: true},
  187. CreateTime: sql.NullTime{Time: time.Date(2019, 10, 7, 16, 39, 45, 0, time.Local), Valid: true},
  188. Year: sql.NullInt64{Int64: 2019, Valid: true},
  189. Month: sql.NullInt64{Int64: 10, Valid: true},
  190. }
  191. got, warns, err := MapExpenditureLog(l, func(int64) (string, bool) { return "", false })
  192. if err != nil {
  193. t.Fatalf("MapExpenditureLog 出错:%v", err)
  194. }
  195. if got.BizNo != validBizNo {
  196. t.Errorf("biz_no 应原样保留,得到 %q", got.BizNo)
  197. }
  198. if got.CostCents != 495295 {
  199. t.Errorf("cost 4952.953 → 4952.95(495295 分),实际 %d", got.CostCents)
  200. }
  201. if got.CreateUserID.Valid {
  202. t.Error("create_userid=0 应落 NULL")
  203. }
  204. if got.ExpenditureID != 23 {
  205. t.Errorf("expenditure_id 应恒等映射 23,实际 %d", got.ExpenditureID)
  206. }
  207. if len(warns) != 0 {
  208. t.Errorf("不应有告警,实际 %v", warns)
  209. }
  210. }
  211. // TestMapExpenditureLogRejectsNullCreateTime 证明 target NOT NULL 的时间列不会被当前时间填充。
  212. func TestMapExpenditureLogRejectsNullCreateTime(t *testing.T) {
  213. l := LegacyExpenditureLog{BizNo: validBizNo, Cost: sql.NullString{String: "1.000", Valid: true}}
  214. if _, _, err := MapExpenditureLog(l, func(int64) (string, bool) { return "", false }); err == nil {
  215. t.Error("create_time 为 NULL 时应报错(不得用当前时间填充),实际未报错")
  216. }
  217. }
  218. // TestRequireUserRefRejectsUnmapped 证明目标态 NOT NULL 的引用人列解析不到时**报错而不是 NULL**。
  219. func TestRequireUserRefRejectsUnmapped(t *testing.T) {
  220. if _, err := requireUserRef(999999, func(int64) (string, bool) { return "", false }, "user_id"); err == nil {
  221. t.Error("无映射行的 NOT NULL 引用列应报错,实际未报错")
  222. }
  223. if _, err := requireUserRef(0, func(int64) (string, bool) { return "x", true }, "user_id"); err == nil {
  224. t.Error("legacy 0 的 NOT NULL 引用列应报错,实际未报错")
  225. }
  226. }
  227. // TestMapExpenditureDict 证明字典 D1 + 名称超长拒绝截断。
  228. func TestMapExpenditureDict(t *testing.T) {
  229. d := LegacyExpenditure{ID: 23, Type: 1, ParentID: 0, Name: "水电费"}
  230. got, _, err := MapExpenditure(d)
  231. if err != nil {
  232. t.Fatalf("MapExpenditure 出错:%v", err)
  233. }
  234. if got.ID != 23 || got.InstitutionID != 0 {
  235. t.Errorf("字典应 D1 保留 id=23 且 institution_id=0,实际 %d/%d", got.ID, got.InstitutionID)
  236. }
  237. long := LegacyExpenditure{ID: 1, Name: string(make([]rune, 501))}
  238. if _, _, err := MapExpenditure(long); err == nil {
  239. t.Error("name 超 500 字符应报错(拒绝截断字典名称),实际未报错")
  240. }
  241. }