package sync import ( "bytes" "database/sql" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "testing" "time" _ "github.com/mattn/go-sqlite3" ) // TestE2ESync starts a real server process and tests two-client sync. func TestE2ESync(t *testing.T) { if testing.Short() { t.Skip("skipping e2e sync test in short mode") } // Find the server binary (must be pre-built). serverBin := findServerBin(t) if serverBin == "" { t.Skip("verstak-server binary not found; build with 'go build -o verstak-server ./cmd/verstak-server'") } // Create temp directories. serverDir := t.TempDir() vaultA := t.TempDir() vaultB := t.TempDir() // Init vaults. os.MkdirAll(filepath.Join(vaultA, ".verstak"), 0750) os.MkdirAll(filepath.Join(vaultB, ".verstak"), 0750) // Pick a random port. serverPort := pickPort(t) // Start server. serverDataDir := filepath.Join(serverDir, "data") os.MkdirAll(serverDataDir, 0750) cmd := exec.Command(serverBin, "-port", fmt.Sprintf("%d", serverPort), "-data", serverDataDir, "-admin-user", "admin", "-admin-pass", "admin", ) cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatalf("start server: %v", err) } defer cmd.Process.Kill() // Wait for server. serverURL := fmt.Sprintf("http://127.0.0.1:%d", serverPort) waitForServer(t, serverURL, 10*time.Second) // Register user. regBody := fmt.Sprintf(`{"username":"testuser","email":"test@test.com","password":"password123"}`) resp, err := http.Post(serverURL+"/api/v1/auth/register", "application/json", strings.NewReader(regBody)) if err != nil { t.Fatalf("register: %v", err) } io.Copy(io.Discard, resp.Body) resp.Body.Close() // Confirm email by directly updating the server DB. dbPath := filepath.Join(serverDataDir, "server.db") confirmUser(t, dbPath, "testuser") // Test auth (the new endpoint). testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`) resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody)) if err != nil { t.Fatalf("auth test: %v", err) } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() t.Fatalf("auth test status %d: %s", resp.StatusCode, string(body)) } resp.Body.Close() // Pair device A. pairBody := fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client A","client_version":"test/v1"}`) resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody)) if err != nil { t.Fatalf("pair A: %v", err) } var pairResp struct { DeviceID string `json:"device_id"` DeviceToken string `json:"device_token"` UserID string `json:"user_id"` } if err := json.NewDecoder(resp.Body).Decode(&pairResp); err != nil { t.Fatalf("decode pair A: %v", err) } resp.Body.Close() deviceIDA := pairResp.DeviceID tokenA := pairResp.DeviceToken t.Logf("Client A: device=%s token=%s...%s", deviceIDA, tokenA[:16], tokenA[len(tokenA)-8:]) // Pair device B. pairBody = fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client B","client_version":"test/v1"}`) resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody)) if err != nil { t.Fatalf("pair B: %v", err) } var pairRespB struct { DeviceID string `json:"device_id"` DeviceToken string `json:"device_token"` } if err := json.NewDecoder(resp.Body).Decode(&pairRespB); err != nil { t.Fatalf("decode pair B: %v", err) } resp.Body.Close() deviceIDB := pairRespB.DeviceID tokenB := pairRespB.DeviceToken t.Logf("Client B: device=%s token=%s...%s", deviceIDB, tokenB[:16], tokenB[len(tokenB)-8:]) // Now create a node on Client A by posting a sync op. // Simulating what the GUI does: push a "node create" op. nodeID := "test-node-001" now := time.Now().UTC().Format(time.RFC3339) pushPayload := map[string]interface{}{ "device_id": deviceIDA, "ops": []map[string]interface{}{ { "op_id": "op-node-create-001", "entity_type": "node", "entity_id": nodeID, "op_type": "create", "payload_json": fmt.Sprintf( `{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`, nodeID, now, now), "client_sequence": 1, "last_seen_server_seq": 0, "created_at": now, }, }, "idempotency_key": "e2e-test-push-1", } pushBody, _ := json.Marshal(pushPayload) req, _ := http.NewRequest("POST", serverURL+"/api/v1/sync/push", bytes.NewReader(pushBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tokenA) resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatalf("push A: %v", err) } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() t.Fatalf("push A status %d: %s", resp.StatusCode, string(body)) } var pushRespA struct { Accepted []string `json:"accepted"` Conflicts []interface{} `json:"conflicts"` } json.NewDecoder(resp.Body).Decode(&pushRespA) resp.Body.Close() t.Logf("Push A accepted: %v", pushRespA.Accepted) // Pull from Client B — should get the node op. pullReq := map[string]interface{}{"since_sequence": 0} pullBody, _ := json.Marshal(pullReq) req, _ = http.NewRequest("POST", serverURL+"/api/v1/sync/pull", bytes.NewReader(pullBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tokenB) resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatalf("pull B: %v", err) } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() t.Fatalf("pull B status %d: %s", resp.StatusCode, string(body)) } var pullRespB struct { ServerSequence int `json:"server_sequence"` Ops []struct { OpID string `json:"op_id"` EntityType string `json:"entity_type"` EntityID string `json:"entity_id"` OpType string `json:"op_type"` PayloadJSON string `json:"payload_json"` } `json:"ops"` } json.NewDecoder(resp.Body).Decode(&pullRespB) resp.Body.Close() if len(pullRespB.Ops) == 0 { t.Fatal("Client B pulled 0 ops, expected at least 1") } t.Logf("Client B pulled %d ops (server seq=%d)", len(pullRespB.Ops), pullRespB.ServerSequence) foundNodeOp := false for _, op := range pullRespB.Ops { if op.EntityType == "node" && op.EntityID == nodeID && op.OpType == "create" { foundNodeOp = true // Verify payload contents. var payload map[string]interface{} json.Unmarshal([]byte(op.PayloadJSON), &payload) if payload["title"] != "Test Project" { t.Errorf("expected title 'Test Project', got %v", payload["title"]) } if payload["type"] != "case" { t.Errorf("expected type 'case', got %v", payload["type"]) } if payload["section"] != "projects" { t.Errorf("expected section 'projects', got %v", payload["section"]) } t.Logf("Node payload verified: title=%v type=%v section=%v", payload["title"], payload["type"], payload["section"]) } } if !foundNodeOp { t.Errorf("node create op not found in pulled ops") } // Test the auth test endpoint doesn't create devices. // Count devices before via direct DB access. beforeCount := countDevices(t, dbPath) t.Logf("Devices before auth test calls: %d (should be 2: A + B)", beforeCount) // Call test auth multiple times. for i := 0; i < 3; i++ { testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`) resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody)) if err != nil { t.Fatalf("auth test iteration %d: %v", i, err) } resp.Body.Close() } // Count devices after — should be same. afterCount := countDevices(t, dbPath) if afterCount != beforeCount { t.Errorf("device count changed after auth test: before=%d after=%d (should be equal)", beforeCount, afterCount) } t.Log("E2E sync test passed!") } func findServerBin(t *testing.T) string { t.Helper() // Check common locations first. candidates := []string{ "verstak-server", "../../../verstak-server", } for _, c := range candidates { if _, err := os.Stat(c); err == nil { return c } } // Build it on demand. t.Log("server binary not found — building from source...") cmd := exec.Command("go", "build", "-o", "verstak-server", "../../../cmd/verstak-server") cmd.Dir = t.TempDir() output, err := cmd.CombinedOutput() if err != nil { t.Logf("build output: %s", string(output)) t.Logf("build error: %v (e2e sync test requires a built server binary)", err) return "" } binPath := filepath.Join(cmd.Dir, "verstak-server") t.Logf("server binary built at %s", binPath) return binPath } func pickPort(t *testing.T) int { t.Helper() // Try a few ports — for testing, a fixed port is OK since tests use temp dirs. return 18999 } func waitForServer(t *testing.T, url string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { resp, err := http.Get(url + "/api/v1/health") if err == nil { resp.Body.Close() return } time.Sleep(200 * time.Millisecond) } t.Fatalf("server not ready within %v at %s", timeout, url) } func confirmUser(t *testing.T, dbPath, username string) { t.Helper() db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatalf("open server db for confirm: %v", err) } defer db.Close() _, err = db.Exec("UPDATE server_users SET confirmed=1 WHERE username=?", username) if err != nil { t.Fatalf("confirm user: %v", err) } } func countDevices(t *testing.T, dbPath string) int { t.Helper() db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatalf("open server db for count: %v", err) } defer db.Close() var count int db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&count) return count }