mapping_test.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. package fixture
  2. import (
  3. "database/sql"
  4. "testing"
  5. "time"
  6. )
  7. // 本文件单测**纯映射规则**(不连库):取整、空值语义、时间、D1 id、确定性 ULID。
  8. // 库相关的端到端核对由 cmd/ac15fixture verify 完成(需要真库,不进单测)。
  9. func TestDecimalToCents(t *testing.T) {
  10. cases := []struct {
  11. in string
  12. want int64
  13. }{
  14. {"0.000", 0},
  15. {"0", 0},
  16. {"0.788", 79}, // 第 3 位 8 进位
  17. {"4999.212", 499921}, // 第 3 位 2 舍去
  18. {"-2160.489", -216049}, // 负数同向(half away from zero)
  19. {"-12.015", -1202}, // 恰好 x.xx5:进位
  20. {"12.015", 1202},
  21. {"5000.000", 500000},
  22. {"-160.036", -16004},
  23. {"333.328", 33333},
  24. {" 7.5 ", 750}, // 空格与补齐
  25. {"-0.004", 0}, // 舍到 0
  26. {"-0.005", -1}, // 进位到 -0.01
  27. }
  28. for _, c := range cases {
  29. got, err := DecimalToCents(c.in)
  30. if err != nil {
  31. t.Fatalf("DecimalToCents(%q) 出错:%v", c.in, err)
  32. }
  33. if got != c.want {
  34. t.Errorf("DecimalToCents(%q) = %d, want %d", c.in, got, c.want)
  35. }
  36. }
  37. // 拒绝静默截断:4 位小数必须报错
  38. if _, err := DecimalToCents("1.2345"); err == nil {
  39. t.Error("DecimalToCents(1.2345) 应报错(拒绝静默截断),实际未报错")
  40. }
  41. }
  42. func TestCentsToDecimal(t *testing.T) {
  43. cases := []struct {
  44. in int64
  45. want string
  46. }{
  47. {0, "0.00"},
  48. {79, "0.79"},
  49. {-216049, "-2160.49"},
  50. {499921, "4999.21"},
  51. {-16004, "-160.04"},
  52. {-1, "-0.01"},
  53. }
  54. for _, c := range cases {
  55. if got := CentsToDecimal(c.in); got != c.want {
  56. t.Errorf("CentsToDecimal(%d) = %q, want %q", c.in, got, c.want)
  57. }
  58. }
  59. }
  60. // TestRoundTrip 保证「字符串 → 分 → 字符串」在 2 位小数上稳定(幂等)。
  61. func TestRoundTrip(t *testing.T) {
  62. for _, s := range []string{"0.00", "0.79", "-2160.49", "4999.21", "5000.00"} {
  63. c := MustCents(s)
  64. if got := CentsToDecimal(c); got != s {
  65. t.Errorf("round trip %q → %d → %q", s, c, got)
  66. }
  67. }
  68. }
  69. // TestFixtureCanonicalUserIDDeterministic 证明 fixture ULID 是确定性的:
  70. // 同一 legacy id 每次跑得到同一个值,不同 legacy id 不撞车。
  71. func TestFixtureCanonicalUserIDDeterministic(t *testing.T) {
  72. a1 := FixtureCanonicalUserID(53691)
  73. a2 := FixtureCanonicalUserID(53691)
  74. if a1 != a2 {
  75. t.Fatalf("同一 legacy id 得到不同 ULID:%s vs %s", a1, a2)
  76. }
  77. if len(a1) != 26 {
  78. t.Fatalf("ULID 长度应为 26,实际 %d(%q)", len(a1), a1)
  79. }
  80. seen := map[string]bool{}
  81. for _, id := range []int64{53691, 20052, 34369, 45014, 17230, 17735} {
  82. u := FixtureCanonicalUserID(id)
  83. if seen[u] {
  84. t.Fatalf("不同 legacy id 撞到同一 ULID:%s", u)
  85. }
  86. seen[u] = true
  87. }
  88. }
  89. func ptrNStr(s string) sql.NullString { return sql.NullString{String: s, Valid: true} }
  90. func nullNStr() sql.NullString { return sql.NullString{} }
  91. // TestMapExpenseLogNullSemantics 是空值语义的单测:
  92. // create_user_id=0 → NULL、request_id → NULL、balance_after → NULL、id 原样保留。
  93. //
  94. // ⚠ legacy 真实数据里 create_userid=0 的行数是 **0**(见报告 §5),
  95. // 所以这条规则只能靠单测 + 变异实验证明,不能靠真实数据证明。
  96. func TestMapExpenseLogNullSemantics(t *testing.T) {
  97. legacy := LegacyExpenseLog{
  98. ID: 33451,
  99. UserID: 53691,
  100. Type: 100,
  101. LogID: ptrNStr("fa5d7262e03636c4b69475d731f7388e"),
  102. Money: ptrNStr("5000.000"),
  103. CreateUserID: sql.NullInt64{Int64: 0, Valid: true}, // 系统/未知创建人
  104. CreateTime: sql.NullTime{Time: time.Date(2019, 9, 7, 12, 25, 49, 0, time.Local), Valid: true},
  105. Remark: ptrNStr("充值缴费"),
  106. }
  107. resolver := func(id int64) (string, bool) { return "", false }
  108. got, warns, err := MapExpenseLog(legacy, "01ABC", resolver)
  109. if err != nil {
  110. t.Fatalf("MapExpenseLog 出错:%v", err)
  111. }
  112. if got.ID != 33451 {
  113. t.Errorf("D1:id 应原样保留 33451,实际 %d", got.ID)
  114. }
  115. if got.CreateUserID.Valid {
  116. t.Errorf("create_user_id=0 应映射为 NULL,实际落成 %q", got.CreateUserID.String)
  117. }
  118. if got.RequestID.Valid {
  119. t.Error("request_id 应恒为 NULL(legacy 无此列)")
  120. }
  121. if got.BalanceAfter.Valid {
  122. t.Error("balance_after 应恒为 NULL(legacy 无快照)")
  123. }
  124. if got.MoneyCents != 500000 {
  125. t.Errorf("money 5000.000 → 500000 分,实际 %d", got.MoneyCents)
  126. }
  127. if got.CreateTime.Nanosecond() != 0 {
  128. t.Errorf("create_time 毫秒部分应为 0(DATETIME(3) 补 .000),实际 %d", got.CreateTime.Nanosecond())
  129. }
  130. if len(warns) != 0 {
  131. t.Errorf("不应有告警,实际 %v", warns)
  132. }
  133. }
  134. // TestMapExpenseLogResolvesCreator 证明「legacy int create_userid → ULID」走的是 resolver。
  135. func TestMapExpenseLogResolvesCreator(t *testing.T) {
  136. legacy := LegacyExpenseLog{
  137. ID: 1,
  138. Type: 201,
  139. Money: ptrNStr("-3000.000"),
  140. CreateUserID: sql.NullInt64{Int64: 34369, Valid: true},
  141. CreateTime: sql.NullTime{Time: time.Date(2019, 9, 7, 12, 37, 12, 0, time.Local), Valid: true},
  142. }
  143. resolver := func(id int64) (string, bool) {
  144. if id == 34369 {
  145. return "01RESOLVED0000000000000000", true
  146. }
  147. return "", false
  148. }
  149. got, _, err := MapExpenseLog(legacy, "01ABC", resolver)
  150. if err != nil {
  151. t.Fatalf("MapExpenseLog 出错:%v", err)
  152. }
  153. if !got.CreateUserID.Valid || got.CreateUserID.String != "01RESOLVED0000000000000000" {
  154. t.Errorf("create_user_id 应解析成 ULID,实际 %+v", got.CreateUserID)
  155. }
  156. }
  157. // TestMapExpenseLogRejectsNullCreateTime 证明「create_time 为 NULL 时拒绝用当前时间填充」。
  158. func TestMapExpenseLogRejectsNullCreateTime(t *testing.T) {
  159. legacy := LegacyExpenseLog{ID: 1, Money: ptrNStr("1.000"), CreateTime: sql.NullTime{}}
  160. if _, _, err := MapExpenseLog(legacy, "01ABC", func(int64) (string, bool) { return "", false }); err == nil {
  161. t.Error("create_time 为 NULL 时应报错(不得用当前时间填充),实际未报错")
  162. }
  163. }
  164. // TestMapExpenseRoundsPerRow 证明金额是**逐列**取整到分(不是取整求和)。
  165. func TestMapExpenseRoundsPerRow(t *testing.T) {
  166. l := LegacyExpense{
  167. UserID: 53691,
  168. Balance: ptrNStr("0.788"),
  169. Spend: ptrNStr("4999.212"),
  170. TotalIncome: ptrNStr("5000.000"),
  171. TotalExpenditure: ptrNStr("4999.212"),
  172. TotalRecommendedAmount: ptrNStr("0.000"),
  173. SpecialExpenses: ptrNStr("0.000"),
  174. }
  175. got, warns, err := MapExpense(l, "01ABC")
  176. if err != nil {
  177. t.Fatalf("MapExpense 出错:%v", err)
  178. }
  179. if got.BalanceCents != 79 {
  180. t.Errorf("balance 0.788 → 0.79(79 分),实际 %d", got.BalanceCents)
  181. }
  182. if got.SpendCents != 499921 {
  183. t.Errorf("spend 4999.212 → 4999.21,实际 %d", got.SpendCents)
  184. }
  185. if got.TotalIncomeCents != 500000 {
  186. t.Errorf("total_income 5000.000 → 5000.00,实际 %d", got.TotalIncomeCents)
  187. }
  188. if len(warns) != 0 {
  189. t.Errorf("不应有告警,实际 %v", warns)
  190. }
  191. }
  192. // TestMapExpenseNullToZero 证明 legacy NULL 金额落 0 且**必须产生告警**。
  193. func TestMapExpenseNullToZero(t *testing.T) {
  194. l := LegacyExpense{
  195. UserID: 1,
  196. Balance: nullNStr(),
  197. Spend: ptrNStr("0.000"),
  198. TotalIncome: ptrNStr("0.000"),
  199. TotalExpenditure: ptrNStr("0.000"),
  200. TotalRecommendedAmount: nullNStr(),
  201. SpecialExpenses: nullNStr(),
  202. }
  203. got, warns, err := MapExpense(l, "01ABC")
  204. if err != nil {
  205. t.Fatalf("MapExpense 出错:%v", err)
  206. }
  207. if got.BalanceCents != 0 || got.TotalRecommendedCents != 0 {
  208. t.Error("NULL 金额应落 0")
  209. }
  210. if len(warns) != 3 {
  211. t.Errorf("3 个 NULL 列应产生 3 条告警,实际 %d 条:%v", len(warns), warns)
  212. }
  213. }
  214. // TestTruncateRunes 证明 remark 按字符截断(不会把汉字截成半个)。
  215. func TestTruncateRunes(t *testing.T) {
  216. s := "充值缴费abc"
  217. if got := truncateRunes(s, 4); got != "充值缴费" {
  218. t.Errorf("按字符截断到 4 应得 %q,实际 %q", "充值缴费", got)
  219. }
  220. if got := truncateRunes("abc", 10); got != "abc" {
  221. t.Errorf("未超长应原样返回,实际 %q", got)
  222. }
  223. }