Files
AskaEth fbbfcc95ee fix(persist,channel): 同类定宽时间戳修复 — checkpoint 恢复与渠道会话列表排序
- persist: formatTime 定宽;loadLatestSQL 补 rowid 兜底;New 时归一化历史行(避免 LoadLatest 挑到更旧检查点)
- channel/session: 写入定宽;listSessionSQL 补 rowid 兜底;核实无 INSERT 吃 DDL 默认值,故只加注释不改默认值
- 新增回归测试并做变异验证(改回 RFC3339Nano 即 FAIL)
2026-10-03 16:08:51 +08:00

119 lines
4.0 KiB
Go

package persist
import (
"context"
"testing"
"time"
)
// insertLegacyCheckpointSQL writes a checkpoints row with caller-supplied raw
// timestamps, standing in for a row persisted before the fixed-width layout.
const insertLegacyCheckpointSQL = `INSERT INTO checkpoints
(id, agent_id, step, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`
func TestFormatTime_定宽_正常(t *testing.T) {
t.Parallel()
tests := []struct {
name string
at time.Time
want string
}{
{"零小数秒", time.Date(2026, 9, 7, 3, 20, 5, 0, time.UTC), "2026-09-07T03:20:05.000000000Z"},
{"毫秒", time.Date(2026, 9, 7, 3, 20, 5, 830000000, time.UTC), "2026-09-07T03:20:05.830000000Z"},
{"纳秒", time.Date(2026, 9, 7, 3, 20, 5, 830978900, time.UTC), "2026-09-07T03:20:05.830978900Z"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatTime(tt.at)
if got != tt.want {
t.Errorf("formatTime = %q, want %q", got, tt.want)
}
if len(got) != storedTimeLen {
t.Errorf("formatTime length = %d, want %d", len(got), storedTimeLen)
}
})
}
}
func TestFormatTime_非UTC_正常(t *testing.T) {
t.Parallel()
zone := time.FixedZone("UTC+8", 8*60*60)
// 11:20:05.830+08:00 is 03:20:05.830Z, so the stored text is UTC.
at := time.Date(2026, 9, 7, 11, 20, 5, 830000000, zone)
if got, want := formatTime(at), "2026-09-07T03:20:05.830000000Z"; got != want {
t.Errorf("formatTime = %q, want %q", got, want)
}
}
// TestNew_归一化历史时间戳_正常 writes a legacy variable-width row directly,
// re-opens the Store to trigger normalization, and checks both the repaired text
// and its idempotence on a further open.
func TestNew_归一化历史时间戳_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
// RFC3339Nano drops the trailing fractional zeros, so this legacy value is
// only 23 bytes wide and loses the text-ordering guarantee.
const legacy = "2026-09-07T03:20:05.83Z"
if _, err := s.db.ExecContext(ctx, insertLegacyCheckpointSQL,
"legacy", "agent-a", 1, `{}`, legacy, legacy); err != nil {
t.Fatalf("insert legacy row: %v", err)
}
if _, err := New(ctx, s.db, testLogger()); err != nil {
t.Fatalf("New (normalize): %v", err)
}
var created, updated string
if err := s.db.QueryRowContext(ctx,
`SELECT created_at, updated_at FROM checkpoints WHERE id = ?`, "legacy").
Scan(&created, &updated); err != nil {
t.Fatalf("read normalized timestamps: %v", err)
}
const want = "2026-09-07T03:20:05.830000000Z"
if created != want || updated != want {
t.Errorf("normalized timestamps = %q/%q, want %q", created, updated, want)
}
// A further open must be a no-op: the length condition selects nothing once
// the row is fixed width.
if _, err := New(ctx, s.db, testLogger()); err != nil {
t.Fatalf("New (second pass): %v", err)
}
var again string
if err := s.db.QueryRowContext(ctx,
`SELECT created_at FROM checkpoints WHERE id = ?`, "legacy").Scan(&again); err != nil {
t.Fatalf("read created_at after second open: %v", err)
}
if again != created {
t.Errorf("second open changed created_at: %q -> %q", created, again)
}
}
// TestNew_无法解析的时间戳_跳过 pins the "leave it alone" rule: one bad legacy
// value must not keep the Store from opening, and must not be rewritten.
func TestNew_无法解析的时间戳_跳过(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
if _, err := s.db.ExecContext(ctx, insertLegacyCheckpointSQL,
"broken", "agent-a", 1, `{}`, "not-a-time", "not-a-time"); err != nil {
t.Fatalf("insert unparseable row: %v", err)
}
if _, err := New(ctx, s.db, testLogger()); err != nil {
t.Fatalf("New with unparseable timestamp: %v", err)
}
var raw string
if err := s.db.QueryRowContext(ctx,
`SELECT created_at FROM checkpoints WHERE id = ?`, "broken").Scan(&raw); err != nil {
t.Fatalf("read created_at: %v", err)
}
if raw != "not-a-time" {
t.Errorf("created_at = %q, want the unparseable value left as is", raw)
}
}