timeparam_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. // `parseTimeParam` 单测 —— 台账 F31 的**主判据**(不是集成用例的副产品)。
  2. //
  3. // 为什么必须单独测这个纯函数,而不是靠 handler 集成用例:
  4. //
  5. // 集成用例跑在 SQLite 上,时间列按**文本**比较(`...T00:00:00Z` vs `...T00:00:00+08:00`),
  6. // 时区写错时窗口会以**各种偶然形状**错乱 —— 实测把本函数回滚成 UTC 后,
  7. // 账单用例确实红了,但**分账流水用例仍绿**(碰巧落在文本比较的安全侧)。
  8. // 也就是说"集成用例红/绿"无法稳定反映本函数的偏移是否正确。
  9. // 本文件把判据钉在**返回值本身**:偏移必须是 +08:00、绝对时刻必须等于业务时区零点。
  10. //
  11. // 反向约束(防假绿):每条断言都要能在**回滚修复后变红**。
  12. //
  13. // 回滚方式:`time.ParseInLocation("2006-01-02", trimmed, tz.Location())` → `time.Parse(...)`。
  14. // 此时 "2026-04-01" → UTC 零点(Unix 少 28800 秒),下面三项断言全部红。
  15. package handler
  16. import (
  17. "testing"
  18. "time"
  19. "yuansiwei/unified-backend/internal/infra/tz"
  20. )
  21. // bizOffsetSeconds 中国标准时间偏移(秒)。中国无夏令时,恒 +08:00。
  22. const bizOffsetSeconds = 8 * 60 * 60
  23. // TestParseTimeParamDateUsesBusinessTimezone 纯日期入参必须解析成**业务时区**的当日零点。
  24. //
  25. // 缺陷形态(F31):`time.Parse("2006-01-02", s)` 返回 **UTC 零点**,
  26. // 而 DB 连接是 `loc=Asia/Shanghai` —— 同一个日期字符串算出来的绝对时刻差 8 小时,
  27. // 窗口整体右移,`to` 当天的 0~8 点流水被算进"下一天"(或反之)。
  28. // 影响面:`parseTimeParam` 的 24 处调用(账单 / 分账流水 / 课酬 / 活动等所有 from-to 窗口)。
  29. func TestParseTimeParamDateUsesBusinessTimezone(t *testing.T) {
  30. cases := []struct {
  31. name string
  32. raw string
  33. want time.Time
  34. }{
  35. {"普通日", "2026-04-01", time.Date(2026, 4, 1, 0, 0, 0, 0, tz.Location())},
  36. {"首日", "2026-01-01", time.Date(2026, 1, 1, 0, 0, 0, 0, tz.Location())},
  37. {"末日", "2026-12-31", time.Date(2026, 12, 31, 0, 0, 0, 0, tz.Location())},
  38. // 闰日专测:若有人用"加 8 小时"之类的偏移实现,跨月/跨年边界会露馅
  39. {"闰日", "2024-02-29", time.Date(2024, 2, 29, 0, 0, 0, 0, tz.Location())},
  40. // 前后空白必须被 trim(Http query 常见 `from=2026-04-01%20`)
  41. {"带空白", " 2026-07-15 ", time.Date(2026, 7, 15, 0, 0, 0, 0, tz.Location())},
  42. }
  43. for _, c := range cases {
  44. t.Run(c.name, func(t *testing.T) {
  45. got, ok, err := parseTimeParam(c.raw, "to")
  46. if err != nil || !ok {
  47. t.Fatalf("解析 %q 失败:ok=%v err=%v", c.raw, ok, err)
  48. }
  49. // ① 绝对时刻一致(这条是主判据:UTC 零点会比业务时区零点少 28800 秒)
  50. if !got.Equal(c.want) {
  51. t.Fatalf("解析 %q = %s(Unix %d),期望 %s(Unix %d),差 %d 秒",
  52. c.raw, got.Format(time.RFC3339), got.Unix(),
  53. c.want.Format(time.RFC3339), c.want.Unix(), got.Unix()-c.want.Unix())
  54. }
  55. // ② 偏移必须是 +08:00(这条把"日期对了但时区丢了"单独揪出来)
  56. if _, offset := got.Zone(); offset != bizOffsetSeconds {
  57. t.Fatalf("解析 %q 的时区偏移 = %d 秒,期望 %d 秒(+08:00)。"+
  58. "若为 0 说明用回了 time.Parse(UTC),即 F31 的 8 小时偏移",
  59. c.raw, offset, bizOffsetSeconds)
  60. }
  61. // ③ 与 UTC 零点必须**不相等** —— 直接对准 F31 的缺陷形态
  62. // (①② 已覆盖,但这条的失败文案能让后来者立刻看懂是什么坏了)
  63. utcZero := time.Date(c.want.Year(), c.want.Month(), c.want.Day(), 0, 0, 0, 0, time.UTC)
  64. if got.Equal(utcZero) {
  65. t.Fatalf("解析 %q 得到的是 UTC 零点(%s),这是 F31 的缺陷形态:窗口整体偏 8 小时",
  66. c.raw, utcZero.Format(time.RFC3339))
  67. }
  68. })
  69. }
  70. }
  71. // TestParseTimeParamRFC3339KeepsOwnOffset 显式带偏移的 RFC3339 入参**不得**被改写成本地时区。
  72. //
  73. // 为什么:`from=2026-03-01T00:00:00Z` 是调用方**明确声明**的时刻,
  74. // 再"归一"到 +08:00 会改变其绝对时刻(那是改数据,不是改表示)。
  75. // 本用例守的是"① 只影响纯日期分支"这条边界 —— 否则修 F31 时容易顺手把 RFC3339 也归一。
  76. func TestParseTimeParamRFC3339KeepsOwnOffset(t *testing.T) {
  77. cases := []struct {
  78. name string
  79. raw string
  80. wantUnix int64
  81. wantOffset int
  82. }{
  83. {
  84. name: "UTC 显式", raw: "2026-03-01T00:00:00Z",
  85. wantUnix: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).Unix(), wantOffset: 0,
  86. },
  87. {
  88. name: "东八显式", raw: "2026-03-01T00:00:00+08:00",
  89. wantUnix: time.Date(2026, 3, 1, 0, 0, 0, 0, tz.Location()).Unix(), wantOffset: bizOffsetSeconds,
  90. },
  91. }
  92. for _, c := range cases {
  93. t.Run(c.name, func(t *testing.T) {
  94. got, ok, err := parseTimeParam(c.raw, "from")
  95. if err != nil || !ok {
  96. t.Fatalf("解析 %q 失败:ok=%v err=%v", c.raw, ok, err)
  97. }
  98. if got.Unix() != c.wantUnix {
  99. t.Fatalf("解析 %q 的 Unix = %d,期望 %d(RFC3339 的绝对时刻不得被改写)",
  100. c.raw, got.Unix(), c.wantUnix)
  101. }
  102. if _, offset := got.Zone(); offset != c.wantOffset {
  103. t.Fatalf("解析 %q 的偏移 = %d 秒,期望 %d 秒(保留调用方声明)",
  104. c.raw, offset, c.wantOffset)
  105. }
  106. })
  107. }
  108. }
  109. // TestParseTimeParamRejectsBadInput 空串与非规范格式的分支语义。
  110. //
  111. // 「空串 → ok=false 且**无错**」与「非法 → 报错」必须分开:
  112. // 前者是"未提供"(由调用方决定必填与否,例如端点 43 的 from/to 可省略),
  113. // 后者的 2002 一旦被吞成"未提供",窗口就会静默变成全表。
  114. func TestParseTimeParamRejectsBadInput(t *testing.T) {
  115. if got, ok, err := parseTimeParam(" ", "from"); ok || err != nil || !got.IsZero() {
  116. t.Fatalf("空串应返回 (零值, false, nil),实得 (%v, %v, %v)", got, ok, err)
  117. }
  118. badInputs := []string{
  119. "2026-4-1", // 非补零(Go 布局严格要求两位)
  120. "2026/04/01", // 分隔符错
  121. "20260401", // 紧凑格式
  122. "2026-13-01", // 月份越界
  123. "2026-02-30", // 日期不存在
  124. "not-a-time", // 完全非法
  125. "2026-04-01T00:00:00", // 缺偏移的 RFC3339(无时区信息,不接受)
  126. }
  127. for _, raw := range badInputs {
  128. got, ok, err := parseTimeParam(raw, "from")
  129. if err == nil {
  130. t.Fatalf("非法输入 %q 应报错,实得 ok=%v got=%v(静默接受会让窗口条件失效)",
  131. raw, ok, got)
  132. }
  133. if ok {
  134. t.Fatalf("非法输入 %q 的 ok 应为 false,实得 true(调用方会把它当有效边界)", raw)
  135. }
  136. }
  137. }