// sync_test.go - SyncAdapter 接口,策略枚举,NoopSyncAdapter 和 fileStore 同步集成测试. package memory import ( "context" "errors" "fmt" "sync" "testing" "time" ) // ───────────────────────────────────────────────────────────────────────────── // ConflictPolicy // ───────────────────────────────────────────────────────────────────────────── func TestConflictPolicy_String(t *testing.T) { cases := []struct { policy ConflictPolicy want string }{ {ConflictLocalWins, "local_wins"}, {ConflictServerWins, "server_wins"}, {ConflictMerge, "merge"}, {ConflictFail, "fail"}, {ConflictPolicy(99), "unknown"}, } for _, c := range cases { if got := c.policy.String(); got != c.want { t.Errorf("ConflictPolicy(%d).String() = %q, want %q", c.policy, got, c.want) } } } // ───────────────────────────────────────────────────────────────────────────── // SyncConfig 工厂函数 // ───────────────────────────────────────────────────────────────────────────── func TestDefaultSyncConfig(t *testing.T) { cfg := DefaultSyncConfig() if cfg.ConflictPolicy != ConflictLocalWins { t.Errorf("DefaultSyncConfig.ConflictPolicy = %v, want LocalWins", cfg.ConflictPolicy) } if cfg.PullPolicy != PullOnSessionStart { t.Errorf("DefaultSyncConfig.PullPolicy = %v, want PullOnSessionStart", cfg.PullPolicy) } if cfg.PullTTL != 0 { t.Errorf("DefaultSyncConfig.PullTTL = %v, want 0", cfg.PullTTL) } } func TestAPISyncConfig(t *testing.T) { ttl := 5 * time.Minute cfg := APISyncConfig(ttl) if cfg.ConflictPolicy != ConflictServerWins { t.Errorf("APISyncConfig.ConflictPolicy = %v, want ServerWins", cfg.ConflictPolicy) } if cfg.PullPolicy != PullWithTTL { t.Errorf("APISyncConfig.PullPolicy = %v, want PullWithTTL", cfg.PullPolicy) } if cfg.PullTTL != ttl { t.Errorf("APISyncConfig.PullTTL = %v, want %v", cfg.PullTTL, ttl) } } // ───────────────────────────────────────────────────────────────────────────── // NoopSyncAdapter // ───────────────────────────────────────────────────────────────────────────── func TestNoopSyncAdapter_IsAvailable(t *testing.T) { var n NoopSyncAdapter if n.IsAvailable() { t.Error("NoopSyncAdapter.IsAvailable() should be false") } } func TestNoopSyncAdapter_Pull(t *testing.T) { var n NoopSyncAdapter pulled, err := n.Pull(context.Background(), "/tmp") if err != nil { t.Errorf("NoopSyncAdapter.Pull() unexpected error: %v", err) } if pulled != 0 { t.Errorf("NoopSyncAdapter.Pull() pulled = %d, want 0", pulled) } } func TestNoopSyncAdapter_Push(t *testing.T) { var n NoopSyncAdapter pushed, err := n.Push(context.Background(), "/tmp", ConflictLocalWins) if err != nil { t.Errorf("NoopSyncAdapter.Push() unexpected error: %v", err) } if pushed != 0 { t.Errorf("NoopSyncAdapter.Push() pushed = %d, want 0", pushed) } } // ───────────────────────────────────────────────────────────────────────────── // ErrSyncConflict // ───────────────────────────────────────────────────────────────────────────── func TestErrSyncConflict_Is(t *testing.T) { err := fmt.Errorf("wrapped: %w", ErrSyncConflict) if !errors.Is(err, ErrSyncConflict) { t.Error("errors.Is(wrapped, ErrSyncConflict) should be true") } } // ───────────────────────────────────────────────────────────────────────────── // syncState - PullPolicy 逻辑 // ───────────────────────────────────────────────────────────────────────────── // mockAdapter 是一个计数 Pull/Push 调用次数的测试适配器. type mockAdapter struct { mu sync.Mutex pullCount int pushCount int available bool } func (m *mockAdapter) Pull(_ context.Context, _ string) (int, error) { m.mu.Lock() defer m.mu.Unlock() m.pullCount++ return 1, nil } func (m *mockAdapter) Push(_ context.Context, _ string, _ ConflictPolicy) (int, error) { m.mu.Lock() defer m.mu.Unlock() m.pushCount++ return 1, nil } func (m *mockAdapter) IsAvailable() bool { m.mu.Lock() defer m.mu.Unlock() return m.available } func (m *mockAdapter) getPullCount() int { m.mu.Lock() defer m.mu.Unlock() return m.pullCount } func TestSyncState_PullOnSessionStart_OnlyPullsOnce(t *testing.T) { adapter := &mockAdapter{available: true} cfg := DefaultSyncConfig() // PullOnSessionStart var st syncState // 第一次调用应该触发 Pull if !st.shouldPull(adapter, cfg) { t.Error("first shouldPull() should return true") } // 第二次调用不应再触发 if st.shouldPull(adapter, cfg) { t.Error("second shouldPull() should return false") } // 第三次也不应触发 if st.shouldPull(adapter, cfg) { t.Error("third shouldPull() should return false") } } func TestSyncState_PullOnSessionStart_Reset(t *testing.T) { adapter := &mockAdapter{available: true} cfg := DefaultSyncConfig() var st syncState st.shouldPull(adapter, cfg) // 首次,设为已 pulled st.resetForTest() // 重置 if !st.shouldPull(adapter, cfg) { t.Error("after reset, shouldPull() should return true again") } } func TestSyncState_PullNever(t *testing.T) { adapter := &mockAdapter{available: true} cfg := SyncConfig{PullPolicy: PullNever} var st syncState for i := 0; i < 5; i++ { if st.shouldPull(adapter, cfg) { t.Errorf("PullNever: shouldPull() should always return false (call %d)", i) } } } func TestSyncState_PullAlways(t *testing.T) { adapter := &mockAdapter{available: true} cfg := SyncConfig{PullPolicy: PullAlways} var st syncState for i := 0; i < 3; i++ { if !st.shouldPull(adapter, cfg) { t.Errorf("PullAlways: shouldPull() should always return true (call %d)", i) } } } func TestSyncState_PullWithTTL_RespectsCooldown(t *testing.T) { adapter := &mockAdapter{available: true} cfg := SyncConfig{ PullPolicy: PullWithTTL, PullTTL: 100 * time.Millisecond, } var st syncState // 首次:TTL 未记录,应该 Pull if !st.shouldPull(adapter, cfg) { t.Error("first call with TTL should return true") } // 立刻再次调用:TTL 内,不应 Pull if st.shouldPull(adapter, cfg) { t.Error("immediate second call should return false (within TTL)") } // 等 TTL 过期 time.Sleep(110 * time.Millisecond) // TTL 已过:应该 Pull if !st.shouldPull(adapter, cfg) { t.Error("after TTL expiry, shouldPull() should return true") } } func TestSyncState_AdapterUnavailable_NeverPulls(t *testing.T) { adapter := &mockAdapter{available: false} cfg := SyncConfig{PullPolicy: PullAlways} // 策略是 Always,但 adapter 不可用 var st syncState if st.shouldPull(adapter, cfg) { t.Error("unavailable adapter should never trigger Pull") } } func TestSyncState_NilAdapter_NeverPulls(t *testing.T) { cfg := DefaultSyncConfig() var st syncState if st.shouldPull(nil, cfg) { t.Error("nil adapter should never trigger Pull") } } func TestSyncState_PullOnSessionStart_ConcurrentSafe(t *testing.T) { // 验证并发调用 shouldPull 时只有一个 goroutine 返回 true(幂等性) adapter := &mockAdapter{available: true} cfg := DefaultSyncConfig() var st syncState results := make([]bool, 10) var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(idx int) { defer wg.Done() results[idx] = st.shouldPull(adapter, cfg) }(i) } wg.Wait() trueCount := 0 for _, r := range results { if r { trueCount++ } } if trueCount != 1 { t.Errorf("concurrent shouldPull: exactly 1 goroutine should get true, got %d", trueCount) } } // ───────────────────────────────────────────────────────────────────────────── // fileStore 集成:WithSyncAdapter + maybePull + maybePush // ───────────────────────────────────────────────────────────────────────────── func TestFileStore_WithSyncAdapter_PullOnList(t *testing.T) { t.TempDir() adapter := &mockAdapter{available: true} store := NewFileStoreWithOptions("", WithSyncAdapter(adapter, DefaultSyncConfig()), ).(*fileStore) // List 应该触发一次 Pull _, _ = store.List(context.Background()) if adapter.getPullCount() != 1 { t.Errorf("List() should trigger Pull once, got %d", adapter.getPullCount()) } // 再次 List:PullOnSessionStart 不再 Pull _, _ = store.List(context.Background()) if adapter.getPullCount() != 1 { t.Errorf("second List() should not trigger Pull again, got %d", adapter.getPullCount()) } } func TestFileStore_WithSyncAdapter_PullNever_NoAutoPull(t *testing.T) { adapter := &mockAdapter{available: true} store := NewFileStoreWithOptions("", WithSyncAdapter(adapter, SyncConfig{ PullPolicy: PullNever, ConflictPolicy: ConflictLocalWins, }), ).(*fileStore) _, _ = store.List(context.Background()) _, _ = store.List(context.Background()) if adapter.getPullCount() != 0 { t.Errorf("PullNever: Pull should not be called, got %d", adapter.getPullCount()) } } func TestFileStore_WithSyncAdapter_UnavailableAdapter_NoOp(t *testing.T) { adapter := &mockAdapter{available: false} store := NewFileStoreWithOptions("", WithSyncAdapter(adapter, DefaultSyncConfig()), ).(*fileStore) _, _ = store.List(context.Background()) if adapter.getPullCount() != 0 { t.Errorf("unavailable adapter: Pull should not be called, got %d", adapter.getPullCount()) } } func TestFileStore_NoSyncAdapter_BackwardCompatible(t *testing.T) { // 不设置 SyncAdapter 的 store 应该完全正常(向后兼容) store := NewFileStore("") ctx := context.Background() entries, err := store.List(ctx) // 可能返回空列表,但不应 panic 或 error if err != nil { // 目录不存在是正常情况(返回 nil, nil) _ = entries } }