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:
parent
4a96aa3468
commit
50e7e95844
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue