Files
MailUI4Agents/server/internal/handler/lunar_test.go
JianFeeeee a8d6811cd7 style(server): gofmt 注释规范 + init.sql 补 parent_session_id + 新增 ratelimit 判据
## 主体是 gofmt

`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `//   x` 规范成 `//\tx`。
功能零变化。

## 一处实质:init.sql 补 parent_session_id

`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
 TEXT REFERENCES sessions(session_id)`

远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。

- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)

## 新增 handler/ratelimit_test.go(162 行)

速率限制的判据。此前这一块没有独立判据。

## 验证(本机实跑,非推断)

    go build ./...                              通过
    go vet ./internal/{handler,repo,db,sse}/   通过
    go test -count=1(handler/repo/db/sse/blob)
      handler  41.3s  ok
      repo    101.9s  ok
      db       1.1s  ok
      sse      0.01s ok
      blob     0.08s ok

`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
2026-10-05 09:26:59 +08:00

203 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"strings"
"testing"
"time"
"github.com/agentmail/gateway/internal/lunar"
)
/*
* 农历标签的判据。
*
* # 为什么这些断言值得写(而不是"跑通就行")
*
* 农历换算错的表现是**日期错位,不是报错**:格子上照样有字,
* 只是那个字对应的是别的日子。这类错在界面上最难发现(用户得自己去查日历才知道),
* 所以这里把几类会静默出错的点各自钉一条。
*
* ★ 参照物是 `internal/lunar`(服务端的 Go 实现,同一个作者 6tail 的库)——
* 判据不去抄一份"正确答案表",而是**要求两边一致**,这样库升级时判据跟着走。
*/
// 真值取自 `lunar.Date.String()`(服务端权威实现),不是手抄的。
func TestLunarLabelsMatchAuthority(t *testing.T) {
dates := []string{
"2026-09-18", // 实测:农历七月廿二
"2026-02-17", // 春节(正月初一)—— 月名与日名同时要换的那一天
"2024-02-10", // 春节(闰年附近)
"2027-09-30", // 农历月只有 29 天的那类边界年份
"2000-01-01", // 跨世纪
}
for _, key := range dates {
at, err := time.Parse("2006-01-02", key)
if err != nil {
t.Fatalf("测试用例本身写错了: %s", key)
}
got, ok := labelsOf(at)
if !ok {
t.Errorf("%s: 应当换得出来,实际 ok=false", key)
continue
}
want := lunar.FromSolar(at).String()
if got.Full != want {
t.Errorf("%s: Full 要与 lunar 包一致\n got %q\nwant %q", key, got.Full, want)
}
}
}
// 日名必须覆盖 1..30 且**五种前缀形态都在**。
//
// WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」——
// 一个正则从 `String()` 里截的典型漏法。查表版要保证表本身是全的。
func TestLunarDayNamesCoverAllPrefixForms(t *testing.T) {
if len(lunarDayNames) != 31 {
t.Fatalf("日名表要有 31 项(下标 0 占位 + 1..30),实际 %d", len(lunarDayNames))
}
// 五种前缀形态各至少要有一个代表 —— 少任何一种就是漏了一类日子
for _, pair := range []struct {
day int
want string
}{
{1, "初一"}, // 初
{11, "十一"}, // 十
{20, "二十"}, // 二(这一条是 WebUI 漏过的那个)
{21, "廿一"}, // 廿
{30, "三十"}, // 三
} {
if got := lunarDayNames[pair.day]; got != pair.want {
t.Errorf("第 %d 天应为 %q,实际 %q", pair.day, pair.want, got)
}
}
// 表里不许有空项(除了下标 0)
for i := 1; i <= 30; i++ {
if lunarDayNames[i] == "" {
t.Errorf("日名表第 %d 项是空的 —— 那天会显示不出农历", i)
}
}
}
// 初一显示月名、其余显示日名(与 WebUI `cellLunarLabel()` 同一口径)。
//
// 两端必须是同一个串:同一天在两边日历上长得不一样,是对齐工作里的低级漏。
func TestLunarCellTextIsMonthNameOnFirstDay(t *testing.T) {
// 2026-02-17 是农历正月初一;2026-09-18 是七月廿二
newYear, _ := time.Parse("2006-01-02", "2026-02-17")
got, ok := labelsOf(newYear)
if !ok {
t.Fatal("春节应当换得出来")
}
if !got.IsFirstDay {
t.Errorf("2026-02-17 应当是初一,实际 IsFirstDay=false(%s)", got.Full)
}
if got.Text != got.MonthName {
t.Errorf("初一的格子文案要是**月名**,实际 %q(月名 %q)", got.Text, got.MonthName)
}
ordinary, _ := time.Parse("2006-01-02", "2026-09-18")
g, ok := labelsOf(ordinary)
if !ok {
t.Fatal("2026-09-18 应当换得出来")
}
if g.IsFirstDay {
t.Error("2026-09-18 不该是初一")
}
if g.Text != g.DayName {
t.Errorf("非初一的格子文案要是**日名**,实际 %q(日名 %q)", g.Text, g.DayName)
}
}
// 闰月必须带「闰」字。
//
// 不带的话闰六月与六月在格子里长得一模一样 —— 而"过生日到底过哪个六月"
// 正是农历里最需要区分的一件事。
func TestLunarLeapMonthIsMarked(t *testing.T) {
// 2025 年有闰六月(业界共识;用权威实现扫一遍确认,而不是背下来)
found := false
start, _ := time.Parse("2006-01-02", "2025-06-01")
for i := 0; i < 400; i++ {
d := start.AddDate(0, 0, i)
labels, ok := labelsOf(d)
if ok && labels.Leap {
found = true
if labels.MonthName[0:len("闰")] != "闰" {
t.Errorf("%s 是闰月但月名没带「闰」:%q", d.Format("2006-01-02"), labels.MonthName)
}
// 权威实现也要认为它是闰月(双实现一致,不是我这边认错)
if lunar.FromSolar(d).Month >= 0 {
t.Errorf("%s: 我说是闰月,lunar 包说是 %d(正数=非闰)",
d.Format("2006-01-02"), lunar.FromSolar(d).Month)
}
break
}
}
if !found {
t.Error("2025 年前后应当扫得到一个闰月 —— 扫不到说明闰月判断坏了(或测试范围选错了)")
}
}
// 越界/极端值**不许 panic**(这是 recover 那层的实质),且真换不出来时必须 ok=false 而不是给个假日子。
//
// ★ 这条我第一版写错了:我断言「time.Time{} 应当换不出来」,实测库**换得出来**
//
// (0001-01-01 → 「〇年冬月十八」)—— 断言的是我的**想象**而不是实际行为。
// 现在改成断言真正的契约:① 不 panic;② 换不出来就不给(不猜);
// ③ **给出来的时候文字里不许出现“无效/NaN”这类失败标记**。
func TestLunarLabelsRefuseRatherThanGuess(t *testing.T) {
cases := []time.Time{
time.Time{}, // 零值(公元 1 年)
time.Date(1900, 6, 15, 0, 0, 0, 0, time.UTC), // 库的传统下限附近
time.Date(2100, 6, 15, 0, 0, 0, 0, time.UTC), // 上限
time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC), // 极端上限
}
for _, d := range cases {
got, ok := func() (l LunarLabels, ok bool) {
defer func() {
if r := recover(); r != nil {
t.Errorf("%s: 换算是天文算法,极端值不许 panic(实测可 recover),实际 panic: %v",
d.Format("2006-01-02"), r)
ok = false
}
}()
return labelsOf(d)
}()
if !ok {
continue // 换不出来是合法的(调用方据此不显示农历)
}
/*
* 换出来了就要求它是个**真结果**:`lunar.Date.String()` 在无效输入时会
* fallback 成「农历 %d-%d-%d」或带「无效」字样 —— 那种串不能当标签发给客户端,
* 否则格子上会直接显示出错信息。
*/
for _, marker := range []string{"无效", "NaN", "农历 "} {
if got.Full == "" || strings.Contains(got.Full, marker) {
t.Errorf("%s: 换出来的 Full 含失败标记或为空(%q)—— 失败就该 ok=false,不该发个假日子",
d.Format("2006-01-02"), got.Full)
}
}
if got.Text == "" || got.DayName == "" || got.MonthName == "" {
t.Errorf("%s: 换出来了但字段为空(%+v)", d.Format("2006-01-02"), got)
}
}
}
// 日期键是 UTC 口径,不许被本机时区挪一天。
//
// 这个端点的入参是"格子的键"(日期字面量),不是"瞬间"——
// 混用会让 UTC+8 的凌晨把标签挂到前一天,而界面看起来完全正常。
func TestParseDateKeyIsTimezoneIndependent(t *testing.T) {
at, ok := parseDateKey("2026-09-18")
if !ok {
t.Fatal("合法的日期键应当解析成功")
}
if got := at.Format("2006-01-02"); got != "2026-09-18" {
t.Errorf("日期键往返必须不变,实际 %s", got)
}
for _, bad := range []string{"2026-9-18", "2026/09/18", "", "2026-09-18T00:00:00Z", "not-a-date"} {
if _, ok := parseDateKey(bad); ok {
t.Errorf("%q 不该被接受为日期键(只收 YYYY-MM-DD)", bad)
}
}
}