sync_test.go
1 // test/e2e/sync_test.go 2 package e2e 3 4 import ( 5 "context" 6 "encoding/json" 7 "net/http" 8 "net/http/httptest" 9 "os" 10 "path/filepath" 11 "testing" 12 "time" 13 14 "github.com/Kocoro-lab/ShanClaw/internal/client" 15 "github.com/Kocoro-lab/ShanClaw/internal/session" 16 "github.com/Kocoro-lab/ShanClaw/internal/sync" 17 ) 18 19 // TestE2ESync_OfflineHappyPath wires the full sync pipeline (scanner → batcher 20 // → uploader → marker) against a mock Cloud server that accepts everything. 21 // First run uploads two seeded sessions across two dirs; second run is a noop 22 // because the marker advanced past every candidate. 23 func TestE2ESync_OfflineHappyPath(t *testing.T) { 24 home := t.TempDir() 25 sd := filepath.Join(home, "sessions") 26 if err := os.MkdirAll(sd, 0o755); err != nil { 27 t.Fatalf("mkdir sessions: %v", err) 28 } 29 30 now := time.Now().UTC().Truncate(time.Second) 31 32 // Seed two sessions across two dirs: default + ops-bot agent. 33 for _, spec := range []struct{ agent, id string }{ 34 {"", "default-1"}, 35 {"ops-bot", "ops-1"}, 36 } { 37 dir := sd 38 if spec.agent != "" { 39 dir = filepath.Join(home, "agents", spec.agent, "sessions") 40 if err := os.MkdirAll(dir, 0o755); err != nil { 41 t.Fatalf("mkdir agent sessions %q: %v", spec.agent, err) 42 } 43 } 44 idx, err := session.OpenIndex(dir) 45 if err != nil { 46 t.Fatalf("OpenIndex %q: %v", dir, err) 47 } 48 s := &session.Session{ 49 ID: spec.id, 50 CreatedAt: now, 51 UpdatedAt: now, 52 } 53 if err := idx.UpsertSession(s); err != nil { 54 t.Fatalf("UpsertSession %q: %v", spec.id, err) 55 } 56 idx.Close() 57 body, err := json.Marshal(s) 58 if err != nil { 59 t.Fatalf("marshal session %q: %v", spec.id, err) 60 } 61 if err := os.WriteFile(filepath.Join(dir, spec.id+".json"), body, 0o644); err != nil { 62 t.Fatalf("write session json %q: %v", spec.id, err) 63 } 64 } 65 66 // Mock Cloud server: accept every session it receives. 67 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 var req client.SyncBatchRequest 69 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 70 http.Error(w, err.Error(), http.StatusBadRequest) 71 return 72 } 73 ids := []string{} 74 for _, env := range req.Sessions { 75 var probe struct { 76 ID string `json:"id"` 77 } 78 if err := json.Unmarshal(env.Session, &probe); err == nil && probe.ID != "" { 79 ids = append(ids, probe.ID) 80 } 81 } 82 _ = json.NewEncoder(w).Encode(client.SyncBatchResponse{Accepted: ids}) 83 })) 84 defer srv.Close() 85 86 cfg := sync.Config{ 87 Enabled: true, 88 BatchMaxSessions: 25, 89 BatchMaxBytes: 5 * 1024 * 1024, 90 SingleSessionMaxBytes: 4 * 1024 * 1024, 91 DaemonInterval: 24 * time.Hour, 92 DaemonStartupDelay: 60 * time.Second, 93 FailedMaxAttemptsTransient: 5, 94 LockTimeout: 30 * time.Second, 95 } 96 97 deps := sync.Deps{ 98 Cfg: cfg, 99 HomeDir: home, 100 ClientVer: "shanclaw/e2e", 101 Uploader: &sync.CloudUploader{Client: client.NewGatewayClient(srv.URL, "test-key")}, 102 Loader: func(dir, id string) ([]byte, error) { 103 return os.ReadFile(filepath.Join(dir, id+".json")) 104 }, 105 Audit: noopAudit{}, 106 Now: func() time.Time { return now }, 107 } 108 109 if err := sync.Run(context.Background(), deps); err != nil { 110 t.Fatalf("Run: %v", err) 111 } 112 113 m, err := sync.ReadMarker(filepath.Join(home, "sync_marker.json")) 114 if err != nil { 115 t.Fatalf("ReadMarker: %v", err) 116 } 117 if m.LastSyncOutcome != sync.OutcomeOK { 118 t.Errorf("outcome: got %q, want %q", m.LastSyncOutcome, sync.OutcomeOK) 119 } 120 if m.LastSyncCount != 2 { 121 t.Errorf("count: got %d, want 2", m.LastSyncCount) 122 } 123 124 // Second run should be a noop — marker advanced past every candidate. 125 if err := sync.Run(context.Background(), deps); err != nil { 126 t.Fatalf("Run #2: %v", err) 127 } 128 m2, err := sync.ReadMarker(filepath.Join(home, "sync_marker.json")) 129 if err != nil { 130 t.Fatalf("ReadMarker #2: %v", err) 131 } 132 if m2.LastSyncOutcome != sync.OutcomeNoop { 133 t.Errorf("second-run outcome: got %q, want %q", m2.LastSyncOutcome, sync.OutcomeNoop) 134 } 135 } 136 137 // noopAudit satisfies sync.AuditLogger for tests that don't care about events. 138 type noopAudit struct{} 139 140 func (noopAudit) Log(string, map[string]any) {}