/ test / e2e / sync_test.go
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) {}