// `parseTimeParam` 单测 —— 台账 F31 的**主判据**(不是集成用例的副产品)。 // // 为什么必须单独测这个纯函数,而不是靠 handler 集成用例: // // 集成用例跑在 SQLite 上,时间列按**文本**比较(`...T00:00:00Z` vs `...T00:00:00+08:00`), // 时区写错时窗口会以**各种偶然形状**错乱 —— 实测把本函数回滚成 UTC 后, // 账单用例确实红了,但**分账流水用例仍绿**(碰巧落在文本比较的安全侧)。 // 也就是说"集成用例红/绿"无法稳定反映本函数的偏移是否正确。 // 本文件把判据钉在**返回值本身**:偏移必须是 +08:00、绝对时刻必须等于业务时区零点。 // // 反向约束(防假绿):每条断言都要能在**回滚修复后变红**。 // // 回滚方式:`time.ParseInLocation("2006-01-02", trimmed, tz.Location())` → `time.Parse(...)`。 // 此时 "2026-04-01" → UTC 零点(Unix 少 28800 秒),下面三项断言全部红。 package handler import ( "testing" "time" "yuansiwei/unified-backend/internal/infra/tz" ) // bizOffsetSeconds 中国标准时间偏移(秒)。中国无夏令时,恒 +08:00。 const bizOffsetSeconds = 8 * 60 * 60 // TestParseTimeParamDateUsesBusinessTimezone 纯日期入参必须解析成**业务时区**的当日零点。 // // 缺陷形态(F31):`time.Parse("2006-01-02", s)` 返回 **UTC 零点**, // 而 DB 连接是 `loc=Asia/Shanghai` —— 同一个日期字符串算出来的绝对时刻差 8 小时, // 窗口整体右移,`to` 当天的 0~8 点流水被算进"下一天"(或反之)。 // 影响面:`parseTimeParam` 的 24 处调用(账单 / 分账流水 / 课酬 / 活动等所有 from-to 窗口)。 func TestParseTimeParamDateUsesBusinessTimezone(t *testing.T) { cases := []struct { name string raw string want time.Time }{ {"普通日", "2026-04-01", time.Date(2026, 4, 1, 0, 0, 0, 0, tz.Location())}, {"首日", "2026-01-01", time.Date(2026, 1, 1, 0, 0, 0, 0, tz.Location())}, {"末日", "2026-12-31", time.Date(2026, 12, 31, 0, 0, 0, 0, tz.Location())}, // 闰日专测:若有人用"加 8 小时"之类的偏移实现,跨月/跨年边界会露馅 {"闰日", "2024-02-29", time.Date(2024, 2, 29, 0, 0, 0, 0, tz.Location())}, // 前后空白必须被 trim(Http query 常见 `from=2026-04-01%20`) {"带空白", " 2026-07-15 ", time.Date(2026, 7, 15, 0, 0, 0, 0, tz.Location())}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, ok, err := parseTimeParam(c.raw, "to") if err != nil || !ok { t.Fatalf("解析 %q 失败:ok=%v err=%v", c.raw, ok, err) } // ① 绝对时刻一致(这条是主判据:UTC 零点会比业务时区零点少 28800 秒) if !got.Equal(c.want) { t.Fatalf("解析 %q = %s(Unix %d),期望 %s(Unix %d),差 %d 秒", c.raw, got.Format(time.RFC3339), got.Unix(), c.want.Format(time.RFC3339), c.want.Unix(), got.Unix()-c.want.Unix()) } // ② 偏移必须是 +08:00(这条把"日期对了但时区丢了"单独揪出来) if _, offset := got.Zone(); offset != bizOffsetSeconds { t.Fatalf("解析 %q 的时区偏移 = %d 秒,期望 %d 秒(+08:00)。"+ "若为 0 说明用回了 time.Parse(UTC),即 F31 的 8 小时偏移", c.raw, offset, bizOffsetSeconds) } // ③ 与 UTC 零点必须**不相等** —— 直接对准 F31 的缺陷形态 // (①② 已覆盖,但这条的失败文案能让后来者立刻看懂是什么坏了) utcZero := time.Date(c.want.Year(), c.want.Month(), c.want.Day(), 0, 0, 0, 0, time.UTC) if got.Equal(utcZero) { t.Fatalf("解析 %q 得到的是 UTC 零点(%s),这是 F31 的缺陷形态:窗口整体偏 8 小时", c.raw, utcZero.Format(time.RFC3339)) } }) } } // TestParseTimeParamRFC3339KeepsOwnOffset 显式带偏移的 RFC3339 入参**不得**被改写成本地时区。 // // 为什么:`from=2026-03-01T00:00:00Z` 是调用方**明确声明**的时刻, // 再"归一"到 +08:00 会改变其绝对时刻(那是改数据,不是改表示)。 // 本用例守的是"① 只影响纯日期分支"这条边界 —— 否则修 F31 时容易顺手把 RFC3339 也归一。 func TestParseTimeParamRFC3339KeepsOwnOffset(t *testing.T) { cases := []struct { name string raw string wantUnix int64 wantOffset int }{ { name: "UTC 显式", raw: "2026-03-01T00:00:00Z", wantUnix: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).Unix(), wantOffset: 0, }, { name: "东八显式", raw: "2026-03-01T00:00:00+08:00", wantUnix: time.Date(2026, 3, 1, 0, 0, 0, 0, tz.Location()).Unix(), wantOffset: bizOffsetSeconds, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, ok, err := parseTimeParam(c.raw, "from") if err != nil || !ok { t.Fatalf("解析 %q 失败:ok=%v err=%v", c.raw, ok, err) } if got.Unix() != c.wantUnix { t.Fatalf("解析 %q 的 Unix = %d,期望 %d(RFC3339 的绝对时刻不得被改写)", c.raw, got.Unix(), c.wantUnix) } if _, offset := got.Zone(); offset != c.wantOffset { t.Fatalf("解析 %q 的偏移 = %d 秒,期望 %d 秒(保留调用方声明)", c.raw, offset, c.wantOffset) } }) } } // TestParseTimeParamRejectsBadInput 空串与非规范格式的分支语义。 // // 「空串 → ok=false 且**无错**」与「非法 → 报错」必须分开: // 前者是"未提供"(由调用方决定必填与否,例如端点 43 的 from/to 可省略), // 后者的 2002 一旦被吞成"未提供",窗口就会静默变成全表。 func TestParseTimeParamRejectsBadInput(t *testing.T) { if got, ok, err := parseTimeParam(" ", "from"); ok || err != nil || !got.IsZero() { t.Fatalf("空串应返回 (零值, false, nil),实得 (%v, %v, %v)", got, ok, err) } badInputs := []string{ "2026-4-1", // 非补零(Go 布局严格要求两位) "2026/04/01", // 分隔符错 "20260401", // 紧凑格式 "2026-13-01", // 月份越界 "2026-02-30", // 日期不存在 "not-a-time", // 完全非法 "2026-04-01T00:00:00", // 缺偏移的 RFC3339(无时区信息,不接受) } for _, raw := range badInputs { got, ok, err := parseTimeParam(raw, "from") if err == nil { t.Fatalf("非法输入 %q 应报错,实得 ok=%v got=%v(静默接受会让窗口条件失效)", raw, ok, got) } if ok { t.Fatalf("非法输入 %q 的 ok 应为 false,实得 true(调用方会把它当有效边界)", raw) } } }