test(sync): add end-to-end two-client sync smoke test

TestE2ESync starts a real server process, registers user, pairs two devices,
pushes a node op from client A, pulls on client B, verifies payload content,
and confirms /api/auth/test does not create devices.
This commit is contained in:
mirivlad 2026-06-02 08:02:19 +08:00
parent 4a96aa3468
commit 50e7e95844
1 changed files with 311 additions and 0 deletions

View File

@ -0,0 +1,311 @@
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.
candidates := []string{
"verstak-server",
"../../../verstak-server",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
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
}