|
@@ -0,0 +1,165 @@
|
|
|
|
|
+// 时区固定验收(team-lead 口径):进程 Asia/Shanghai + DSN loc 一致 + JSON 恒 `+08:00`。
|
|
|
|
|
+//
|
|
|
|
|
+// 两条验收判据(缺一即视为未完成):
|
|
|
|
|
+// 1. 含 `time.Time` 的响应 JSON 匹配 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`;
|
|
|
|
|
+// 2. **改机器/容器时区也不变**——本文件用 `TZ=UTC` / `TZ=America/New_York` 复现"CI 容器/异地部署",
|
|
|
|
|
+// 断言偏移仍恒为 `+08:00`(这正是修复前会真实错 8 小时的场景)。
|
|
|
|
|
+package tz
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "regexp"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+ "time"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// rfc3339Plus8 契约要求的 JSON 时间形状(RFC3339 + 固定 +08:00 偏移)。
|
|
|
|
|
+var rfc3339Plus8 = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`)
|
|
|
|
|
+
|
|
|
|
|
+// TestApplyFixesProcessZone Apply 后进程时区恒为 Asia/Shanghai,且不受机器 TZ 影响。
|
|
|
|
|
+//
|
|
|
|
|
+// 说明:`TZ` 只在进程启动时影响 `time.Local` 的初值,运行期改 `TZ` 不会自动生效 ——
|
|
|
|
|
+// 这里刻意先改 `TZ` 再 `Apply()`,模拟"容器把 TZ 设成 UTC 后进程启动/重设时区"的最坏情况。
|
|
|
|
|
+func TestApplyFixesProcessZone(t *testing.T) {
|
|
|
|
|
+ for _, machineTZ := range []string{"UTC", "America/New_York", "Europe/Berlin"} {
|
|
|
|
|
+ t.Run("TZ="+machineTZ, func(t *testing.T) {
|
|
|
|
|
+ t.Setenv("TZ", machineTZ) // 模拟 CI 容器/异地机器
|
|
|
|
|
+ original := time.Local
|
|
|
|
|
+ t.Cleanup(func() { time.Local = original })
|
|
|
|
|
+
|
|
|
|
|
+ loc := Apply()
|
|
|
|
|
+ if got := time.Local.String(); got != LocationName {
|
|
|
|
|
+ t.Fatalf("Apply 后 time.Local = %q,期望 %q(不能跟随机器 TZ=%s)", got, LocationName, machineTZ)
|
|
|
|
|
+ }
|
|
|
|
|
+ if loc.String() != LocationName {
|
|
|
|
|
+ t.Fatalf("Apply 返回值 = %q,期望 %q", loc.String(), LocationName)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 偏移必须是 +08:00(28800 秒)
|
|
|
|
|
+ if _, offset := time.Now().In(loc).Zone(); offset != fixedZoneOffset {
|
|
|
|
|
+ t.Fatalf("TZ=%s 下时区偏移 = %d 秒,期望 %d(+08:00)", machineTZ, offset, fixedZoneOffset)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestApplyIdempotent 重复 Apply 无副作用(多处装配/多单测共享时安全)。
|
|
|
|
|
+func TestApplyIdempotent(t *testing.T) {
|
|
|
|
|
+ original := time.Local
|
|
|
|
|
+ t.Cleanup(func() { time.Local = original })
|
|
|
|
|
+ first := Apply()
|
|
|
|
|
+ second := Apply()
|
|
|
|
|
+ if first.String() != second.String() || second.String() != LocationName {
|
|
|
|
|
+ t.Fatalf("Apply 不幂等:%q / %q", first, second)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestJSONOffsetAlwaysPlus8 验收判据①:含 time.Time 的 JSON 恒带 `+08:00`。
|
|
|
|
|
+//
|
|
|
|
|
+// 覆盖两条真实路径:
|
|
|
|
|
+// - `time.Now()`(未显式指定时区 → 取 time.Local,即 Apply 后的 Asia/Shanghai);
|
|
|
|
|
+// - 从 DB 读出的时间经 `In(tz.Location())` 归一后序列化(仓储/Handler 的推荐写法)。
|
|
|
|
|
+func TestJSONOffsetAlwaysPlus8(t *testing.T) {
|
|
|
|
|
+ original := time.Local
|
|
|
|
|
+ t.Cleanup(func() { time.Local = original })
|
|
|
|
|
+ t.Setenv("TZ", "UTC")
|
|
|
|
|
+ Apply()
|
|
|
|
|
+
|
|
|
|
|
+ type payload struct {
|
|
|
|
|
+ Now time.Time `json:"now"`
|
|
|
|
|
+ FromDB time.Time `json:"fromDb"`
|
|
|
|
|
+ Explicit time.Time `json:"explicit"`
|
|
|
|
|
+ }
|
|
|
|
|
+ // 模拟"DB 里存的是无时区 datetime(3),驱动按 loc=Asia/Shanghai 扫成 +08:00"
|
|
|
|
|
+ dbTime := time.Date(2026, 3, 1, 12, 30, 45, 123_000_000, Location())
|
|
|
|
|
+
|
|
|
|
|
+ raw, err := json.Marshal(payload{
|
|
|
|
|
+ Now: time.Now(),
|
|
|
|
|
+ FromDB: dbTime.In(Location()),
|
|
|
|
|
+ Explicit: time.Date(2026, 12, 31, 23, 59, 59, 0, Location()),
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("序列化失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var decoded map[string]string
|
|
|
|
|
+ if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
|
|
|
+ t.Fatalf("反序列化失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, field := range []string{"now", "fromDb", "explicit"} {
|
|
|
|
|
+ value, ok := decoded[field]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ t.Fatalf("JSON 缺少字段 %s: %s", field, raw)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !rfc3339Plus8.MatchString(value) {
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 边界日:跨年零点必须是本地日历日(UTC 会退到 12-31 15:59:59,账期错位)
|
|
|
|
|
+ if got := decoded["explicit"]; got != "2026-12-31T23:59:59+08:00" {
|
|
|
|
|
+ t.Fatalf("边界时间 = %q,期望 2026-12-31T23:59:59+08:00", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestLocationNeverFallsBackToUTC 时区加载的纵深防御:Location 名恒为 Asia/Shanghai、偏移恒 +8h。
|
|
|
|
|
+//
|
|
|
|
|
+// 即使容器内缺 tzdata(极端裁剪镜像),loadLocation 也只会回退到**固定 +08:00 区**,
|
|
|
|
|
+// 绝不会回退 UTC(那会把账期整体错 8 小时)。
|
|
|
|
|
+func TestLocationNeverFallsBackToUTC(t *testing.T) {
|
|
|
|
|
+ loc := Location()
|
|
|
|
|
+ if loc.String() != LocationName {
|
|
|
|
|
+ t.Fatalf("Location = %q,期望 %q(不得回退 UTC)", loc.String(), LocationName)
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, offset := time.Date(2026, 6, 1, 0, 0, 0, 0, loc).Zone(); offset != fixedZoneOffset {
|
|
|
|
|
+ t.Fatalf("偏移 = %d 秒,期望 %d(中国无夏令时,全年 +08:00)", offset, fixedZoneOffset)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestNormalizeDSN DSN 时区收口:覆盖 loc=Local / 缺失 / 已正确三种输入。
|
|
|
|
|
+func TestNormalizeDSN(t *testing.T) {
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ in string
|
|
|
|
|
+ want string
|
|
|
|
|
+ }{
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "loc=Local 必须被改写(随机器漂移的根因)",
|
|
|
|
|
+ in: "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local",
|
|
|
|
|
+ want: "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "loc=UTC 同样改写",
|
|
|
|
|
+ in: "u:p@tcp(h:3306)/db?parseTime=False&loc=UTC",
|
|
|
|
|
+ want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "无查询串 → 自动补 ? 与两个参数",
|
|
|
|
|
+ in: "u:p@tcp(h:3306)/db",
|
|
|
|
|
+ want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "已正确 → 幂等(其余参数与顺序不动)",
|
|
|
|
|
+ in: "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s",
|
|
|
|
|
+ want: "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "空 DSN 原样返回(由调用方 fail-fast)",
|
|
|
|
|
+ in: " ",
|
|
|
|
|
+ want: "",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, tc := range cases {
|
|
|
|
|
+ t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
+ got := NormalizeDSN(tc.in)
|
|
|
|
|
+ if got != tc.want {
|
|
|
|
|
+ t.Fatalf("NormalizeDSN(%q)\n got = %q\nwant = %q", tc.in, got, tc.want)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 归一结果必须幂等(重复调用不再变化)
|
|
|
|
|
+ if again := NormalizeDSN(got); again != got {
|
|
|
|
|
+ t.Fatalf("NormalizeDSN 不幂等:%q → %q", got, again)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got != "" && strings.Contains(got, "loc=Local") {
|
|
|
|
|
+ t.Fatalf("归一后仍含 loc=Local:%q", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|