tz_test.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. // 时区固定验收(team-lead 口径):进程 Asia/Shanghai + DSN loc 一致 + JSON 恒 `+08:00`。
  2. //
  3. // 两条验收判据(缺一即视为未完成):
  4. // 1. 含 `time.Time` 的响应 JSON 匹配 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`;
  5. // 2. **改机器/容器时区也不变**——本文件用 `TZ=UTC` / `TZ=America/New_York` 复现"CI 容器/异地部署",
  6. // 断言偏移仍恒为 `+08:00`(这正是修复前会真实错 8 小时的场景)。
  7. package tz
  8. import (
  9. "encoding/json"
  10. "regexp"
  11. "strings"
  12. "testing"
  13. "time"
  14. )
  15. // rfc3339Plus8 契约要求的 JSON 时间形状(RFC3339 + 固定 +08:00 偏移)。
  16. var rfc3339Plus8 = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`)
  17. // TestApplyFixesProcessZone Apply 后进程时区恒为 Asia/Shanghai,且不受机器 TZ 影响。
  18. //
  19. // 说明:`TZ` 只在进程启动时影响 `time.Local` 的初值,运行期改 `TZ` 不会自动生效 ——
  20. // 这里刻意先改 `TZ` 再 `Apply()`,模拟"容器把 TZ 设成 UTC 后进程启动/重设时区"的最坏情况。
  21. func TestApplyFixesProcessZone(t *testing.T) {
  22. for _, machineTZ := range []string{"UTC", "America/New_York", "Europe/Berlin"} {
  23. t.Run("TZ="+machineTZ, func(t *testing.T) {
  24. t.Setenv("TZ", machineTZ) // 模拟 CI 容器/异地机器
  25. original := time.Local
  26. t.Cleanup(func() { time.Local = original })
  27. loc := Apply()
  28. if got := time.Local.String(); got != LocationName {
  29. t.Fatalf("Apply 后 time.Local = %q,期望 %q(不能跟随机器 TZ=%s)", got, LocationName, machineTZ)
  30. }
  31. if loc.String() != LocationName {
  32. t.Fatalf("Apply 返回值 = %q,期望 %q", loc.String(), LocationName)
  33. }
  34. // 偏移必须是 +08:00(28800 秒)
  35. if _, offset := time.Now().In(loc).Zone(); offset != fixedZoneOffset {
  36. t.Fatalf("TZ=%s 下时区偏移 = %d 秒,期望 %d(+08:00)", machineTZ, offset, fixedZoneOffset)
  37. }
  38. })
  39. }
  40. }
  41. // TestApplyIdempotent 重复 Apply 无副作用(多处装配/多单测共享时安全)。
  42. func TestApplyIdempotent(t *testing.T) {
  43. original := time.Local
  44. t.Cleanup(func() { time.Local = original })
  45. first := Apply()
  46. second := Apply()
  47. if first.String() != second.String() || second.String() != LocationName {
  48. t.Fatalf("Apply 不幂等:%q / %q", first, second)
  49. }
  50. }
  51. // TestJSONOffsetAlwaysPlus8 验收判据①:含 time.Time 的 JSON 恒带 `+08:00`。
  52. //
  53. // 覆盖两条真实路径:
  54. // - `time.Now()`(未显式指定时区 → 取 time.Local,即 Apply 后的 Asia/Shanghai);
  55. // - 从 DB 读出的时间经 `In(tz.Location())` 归一后序列化(仓储/Handler 的推荐写法)。
  56. func TestJSONOffsetAlwaysPlus8(t *testing.T) {
  57. original := time.Local
  58. t.Cleanup(func() { time.Local = original })
  59. t.Setenv("TZ", "UTC")
  60. Apply()
  61. type payload struct {
  62. Now time.Time `json:"now"`
  63. FromDB time.Time `json:"fromDb"`
  64. Explicit time.Time `json:"explicit"`
  65. }
  66. // 模拟"DB 里存的是无时区 datetime(3),驱动按 loc=Asia/Shanghai 扫成 +08:00"
  67. dbTime := time.Date(2026, 3, 1, 12, 30, 45, 123_000_000, Location())
  68. raw, err := json.Marshal(payload{
  69. Now: time.Now(),
  70. FromDB: dbTime.In(Location()),
  71. Explicit: time.Date(2026, 12, 31, 23, 59, 59, 0, Location()),
  72. })
  73. if err != nil {
  74. t.Fatalf("序列化失败: %v", err)
  75. }
  76. var decoded map[string]string
  77. if err := json.Unmarshal(raw, &decoded); err != nil {
  78. t.Fatalf("反序列化失败: %v", err)
  79. }
  80. for _, field := range []string{"now", "fromDb", "explicit"} {
  81. value, ok := decoded[field]
  82. if !ok {
  83. t.Fatalf("JSON 缺少字段 %s: %s", field, raw)
  84. }
  85. if !rfc3339Plus8.MatchString(value) {
  86. t.Fatalf("字段 %s = %q,不匹配契约 ^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\+08:00$(TZ=UTC 下必须仍是 +08:00)", field, value)
  87. }
  88. }
  89. // 边界日:跨年零点必须是本地日历日(UTC 会退到 12-31 15:59:59,账期错位)
  90. if got := decoded["explicit"]; got != "2026-12-31T23:59:59+08:00" {
  91. t.Fatalf("边界时间 = %q,期望 2026-12-31T23:59:59+08:00", got)
  92. }
  93. }
  94. // TestLocationNeverFallsBackToUTC 时区加载的纵深防御:Location 名恒为 Asia/Shanghai、偏移恒 +8h。
  95. //
  96. // 即使容器内缺 tzdata(极端裁剪镜像),loadLocation 也只会回退到**固定 +08:00 区**,
  97. // 绝不会回退 UTC(那会把账期整体错 8 小时)。
  98. func TestLocationNeverFallsBackToUTC(t *testing.T) {
  99. loc := Location()
  100. if loc.String() != LocationName {
  101. t.Fatalf("Location = %q,期望 %q(不得回退 UTC)", loc.String(), LocationName)
  102. }
  103. if _, offset := time.Date(2026, 6, 1, 0, 0, 0, 0, loc).Zone(); offset != fixedZoneOffset {
  104. t.Fatalf("偏移 = %d 秒,期望 %d(中国无夏令时,全年 +08:00)", offset, fixedZoneOffset)
  105. }
  106. }
  107. // TestNormalizeDSN DSN 时区收口:覆盖 loc=Local / 缺失 / 已正确三种输入。
  108. func TestNormalizeDSN(t *testing.T) {
  109. cases := []struct {
  110. name string
  111. in string
  112. want string
  113. }{
  114. {
  115. name: "loc=Local 必须被改写(随机器漂移的根因)",
  116. in: "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local",
  117. want: "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
  118. },
  119. {
  120. name: "loc=UTC 同样改写",
  121. in: "u:p@tcp(h:3306)/db?parseTime=False&loc=UTC",
  122. want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai",
  123. },
  124. {
  125. name: "无查询串 → 自动补 ? 与两个参数",
  126. in: "u:p@tcp(h:3306)/db",
  127. want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai",
  128. },
  129. {
  130. name: "已正确 → 幂等(其余参数与顺序不动)",
  131. in: "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s",
  132. want: "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s",
  133. },
  134. {
  135. name: "空 DSN 原样返回(由调用方 fail-fast)",
  136. in: " ",
  137. want: "",
  138. },
  139. }
  140. for _, tc := range cases {
  141. t.Run(tc.name, func(t *testing.T) {
  142. got := NormalizeDSN(tc.in)
  143. if got != tc.want {
  144. t.Fatalf("NormalizeDSN(%q)\n got = %q\nwant = %q", tc.in, got, tc.want)
  145. }
  146. // 归一结果必须幂等(重复调用不再变化)
  147. if again := NormalizeDSN(got); again != got {
  148. t.Fatalf("NormalizeDSN 不幂等:%q → %q", got, again)
  149. }
  150. if got != "" && strings.Contains(got, "loc=Local") {
  151. t.Fatalf("归一后仍含 loc=Local:%q", got)
  152. }
  153. })
  154. }
  155. }