312 lines
9.2 KiB
Go
312 lines
9.2 KiB
Go
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
|
|
}
|