// compact_persist_test.go -- 断路器持久化测试. package context import ( "os" "path/filepath" "testing" "time" ) // ───────────────────────────────────────────────────────────────────── // cwdHash // ───────────────────────────────────────────────────────────────────── func TestCwdHash_Length(t *testing.T) { h := cwdHash("/home/user/project") if len(h) != 16 { t.Errorf("期望 16 字符,got %d: %q", len(h), h) } } func TestCwdHash_Deterministic(t *testing.T) { if cwdHash("/a/b/c") != cwdHash("/a/b/c") { t.Error("同一路径应产生相同 hash") } } func TestCwdHash_Distinct(t *testing.T) { if cwdHash("/a/b/c") == cwdHash("/a/b/d") { t.Error("不同路径应产生不同 hash") } } // ───────────────────────────────────────────────────────────────────── // FilePersister // ───────────────────────────────────────────────────────────────────── func newTestPersister(t *testing.T) *FilePersister { t.Helper() dir := t.TempDir() p, err := NewFilePersister(dir, "/test/project") if err != nil { t.Fatalf("NewFilePersister 失败: %v", err) } return p } // TestFilePersister_LoadEmpty 首次加载应返回 nil(无历史状态). func TestFilePersister_LoadEmpty(t *testing.T) { p := newTestPersister(t) state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state != nil { t.Errorf("首次加载应返回 nil,got %+v", state) } } // TestFilePersister_SaveLoad 保存后加载应还原状态. func TestFilePersister_SaveLoad(t *testing.T) { p := newTestPersister(t) now := time.Now().Truncate(time.Millisecond) // JSON 精度到 ms err := p.Save(BreakerState{Failures: 2, LastFailedAt: now}) if err != nil { t.Fatalf("Save 失败: %v", err) } state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state == nil { t.Fatal("Load 应返回非 nil") } if state.Failures != 2 { t.Errorf("Failures: 期望 2,got %d", state.Failures) } if !state.LastFailedAt.Equal(now) { t.Errorf("LastFailedAt: 期望 %v,got %v", now, state.LastFailedAt) } } // TestFilePersister_LoadZeroFailures Reset 后写入 failures=0,加载应返回 nil. func TestFilePersister_LoadZeroFailures(t *testing.T) { p := newTestPersister(t) // 先写有效状态 _ = p.Save(BreakerState{Failures: 3, LastFailedAt: time.Now()}) // Reset 后写零 _ = p.Save(BreakerState{Failures: 0}) state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state != nil { t.Errorf("failures=0 应视为空状态,got %+v", state) } } // TestFilePersister_TTLExpired 超过 TTL 的状态应被丢弃. func TestFilePersister_TTLExpired(t *testing.T) { p := newTestPersister(t) // 写入超过 TTL 的时间 expired := time.Now().Add(-(breakerStateTTL + time.Second)) _ = p.Save(BreakerState{Failures: 3, LastFailedAt: expired}) state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state != nil { t.Errorf("TTL 过期的状态应被丢弃,got %+v", state) } } // TestFilePersister_TTLNotExpired 未超过 TTL 的状态应正常加载. func TestFilePersister_TTLNotExpired(t *testing.T) { p := newTestPersister(t) recent := time.Now().Add(-30 * time.Minute) // 30 分钟前,未超过 1 小时 _ = p.Save(BreakerState{Failures: 2, LastFailedAt: recent}) state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state == nil { t.Error("未超过 TTL 的状态不应被丢弃") } } // TestFilePersister_AtomicWrite 验证原子写入:损坏的临时文件不影响已有状态. func TestFilePersister_AtomicWrite(t *testing.T) { p := newTestPersister(t) // 写入有效状态 _ = p.Save(BreakerState{Failures: 1, LastFailedAt: time.Now()}) // 模拟损坏的临时文件残留(真实崩溃场景) _ = os.WriteFile(p.path+".tmp", []byte("invalid json{{{"), 0o600) // 正常的 Save 应覆盖损坏的临时文件 _ = p.Save(BreakerState{Failures: 2, LastFailedAt: time.Now()}) state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state == nil || state.Failures != 2 { t.Errorf("原子写入后应加载最新状态,got %+v", state) } } // TestFilePersister_PerProject 不同 cwd 应使用不同文件. func TestFilePersister_PerProject(t *testing.T) { dir := t.TempDir() p1, _ := NewFilePersister(dir, "/project/alpha") p2, _ := NewFilePersister(dir, "/project/beta") if p1.path == p2.path { t.Error("不同 cwd 应对应不同文件路径") } // p1 写入失败状态 _ = p1.Save(BreakerState{Failures: 3, LastFailedAt: time.Now()}) // p2 不受影响 state, _ := p2.Load() if state != nil { t.Errorf("p1 的状态不应影响 p2,got %+v", state) } } // TestFilePersister_DefaultDir 空 baseDir 应使用 ~/.flyto/compact_breaker/. func TestFilePersister_DefaultDir(t *testing.T) { // 跳过:需要写 home 目录,CI 环境可能无权限 t.Skip("此测试需要 home 目录写权限,在 CI 中跳过") } // TestFilePersister_MkdirAll 目录不存在时自动创建. func TestFilePersister_MkdirAll(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "deep", "dir") p, err := NewFilePersister(dir, "/test") if err != nil { t.Fatalf("NewFilePersister 应自动创建目录,got err: %v", err) } _ = p.Save(BreakerState{Failures: 1, LastFailedAt: time.Now()}) state, err := p.Load() if err != nil || state == nil { t.Errorf("深层目录 save/load 失败: err=%v state=%v", err, state) } } // ───────────────────────────────────────────────────────────────────── // NoopPersister // ───────────────────────────────────────────────────────────────────── func TestNoopPersister_AlwaysEmpty(t *testing.T) { p := &NoopPersister{} _ = p.Save(BreakerState{Failures: 3, LastFailedAt: time.Now()}) state, err := p.Load() if err != nil { t.Fatalf("NoopPersister.Load 不应返回 error: %v", err) } if state != nil { t.Errorf("NoopPersister 应总是返回 nil state,got %+v", state) } } // ───────────────────────────────────────────────────────────────────── // Compressor + SetPersister 集成测试 // ───────────────────────────────────────────────────────────────────── // TestCompressor_SetPersister_RestoresState 验证 SetPersister 加载历史失败计数. func TestCompressor_SetPersister_RestoresState(t *testing.T) { dir := t.TempDir() p, _ := NewFilePersister(dir, "/test/proj") // 模拟上次进程记录了 2 次失败 _ = p.Save(BreakerState{Failures: 2, LastFailedAt: time.Now().Add(-1 * time.Minute)}) c := NewCompressor(1000, nil) c.SetPersister(p) // 断路器应已恢复到 failures=2 if c.circuitBreaker.Failures() != 2 { t.Errorf("期望 failures=2,got %d", c.circuitBreaker.Failures()) } // maxFailures=3,还差1次,断路器应仍然开放 if !c.circuitBreaker.ShouldAttempt() { t.Error("failures=2 < maxFailures=3,断路器应开放") } } // TestCompressor_SetPersister_TripsOnLoad 失败计数已达上限时,断路器加载后直接关闭. func TestCompressor_SetPersister_TripsOnLoad(t *testing.T) { dir := t.TempDir() p, _ := NewFilePersister(dir, "/test/proj") // 上次进程已触发断路器(failures=3) _ = p.Save(BreakerState{Failures: 3, LastFailedAt: time.Now().Add(-1 * time.Minute)}) c := NewCompressor(1000, nil) c.SetPersister(p) // 断路器应关闭 if c.circuitBreaker.ShouldAttempt() { t.Error("failures=3 >= maxFailures=3,断路器加载后应关闭") } } // TestCompressor_SetPersister_ExpiredStateIgnored TTL 过期的状态不应恢复到断路器. func TestCompressor_SetPersister_ExpiredStateIgnored(t *testing.T) { dir := t.TempDir() p, _ := NewFilePersister(dir, "/test/proj") // 写入超过 TTL 的状态 expired := time.Now().Add(-(breakerStateTTL + time.Minute)) _ = p.Save(BreakerState{Failures: 3, LastFailedAt: expired}) c := NewCompressor(1000, nil) c.SetPersister(p) // 过期状态应被忽略,断路器从零开始 if c.circuitBreaker.Failures() != 0 { t.Errorf("过期状态应被忽略,期望 failures=0,got %d", c.circuitBreaker.Failures()) } if !c.circuitBreaker.ShouldAttempt() { t.Error("过期状态忽略后,断路器应开放") } } // TestCompressor_SetPersister_NilPersister nil persister 不影响功能. func TestCompressor_SetPersister_NilPersister(t *testing.T) { c := NewCompressor(1000, nil) c.SetPersister(nil) // 不应 panic c.circuitBreaker.RecordFailure(false) c.saveBreakerState() // nil persister → 静默跳过 if c.circuitBreaker.Failures() != 1 { t.Error("nil persister 不应影响内存断路器") } } // TestCompressor_SaveBreakerState_SavedOnFailure 失败时 persister.Save 被调用. func TestCompressor_SaveBreakerState_SavedOnFailure(t *testing.T) { dir := t.TempDir() p, _ := NewFilePersister(dir, "/test/proj") c := NewCompressor(1000, nil) c.SetPersister(p) // 模拟 RecordFailure + Save c.circuitBreaker.RecordFailure(false) c.saveBreakerState() // 验证持久化文件已写入 state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state == nil || state.Failures != 1 { t.Errorf("失败后应持久化 failures=1,got %+v", state) } } // TestCompressor_SaveBreakerState_ClearedOnReset Reset 后持久化为零,下次 Load 返回 nil. func TestCompressor_SaveBreakerState_ClearedOnReset(t *testing.T) { dir := t.TempDir() p, _ := NewFilePersister(dir, "/test/proj") c := NewCompressor(1000, nil) c.SetPersister(p) // 先记录失败 c.circuitBreaker.RecordFailure(false) c.saveBreakerState() // 然后 Reset c.circuitBreaker.Reset() c.saveBreakerState() // 持久化应清零 state, err := p.Load() if err != nil { t.Fatalf("Load 失败: %v", err) } if state != nil { t.Errorf("Reset 后持久化应返回 nil,got %+v", state) } }