From fcf0a07fcc9dc6490ad0a938f0a1f26efe7d6608 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 27 Jun 2026 12:36:31 +0800 Subject: [PATCH] Document and test sync API contract --- README.md | 42 ++++++++++--- internal/server/handlers_api.go | 2 +- internal/server/server_test.go | 106 ++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4cb7d44..feff182 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Standalone sync server for Verstak2 platform. This server provides synchronization between devices running Verstak2. It handles: - Device registration and authentication -- Operational transform-based sync +- Operation log sync with server sequence numbers and conflict detection - Blob storage for attachments - User management with email confirmation @@ -39,17 +39,45 @@ This server provides synchronization between devices running Verstak2. It handle cmd/server/ - Entry point internal/server/ - Server implementation - server.go - Core server logic - - handlers.go - HTTP handlers + - routes.go - HTTP routing + - handlers_api.go - Sync, client, health, and blob handlers + - handlers_auth.go - User auth API handlers + - handlers_admin.go - Admin web/API handlers - schema.go - Database schema ``` ## API Endpoints -- `POST /api/push` - Push operations to server -- `GET /api/pull` - Pull operations from server -- `POST /api/device/pair` - Pair device with token -- `POST /api/user/register` - Register new user -- `POST /api/user/login` - User login +Desktop sync client: + +- `POST /api/client/pair` - Pair a desktop client with username/password and return a device token +- `POST /api/auth/test` - Validate username/password from the desktop client +- `GET /api/client/me` - Return current authenticated client/device details +- `POST /api/client/revoke-current` - Revoke the current desktop device token +- `POST /api/client/revoke-device` - Revoke another device owned by the same user +- `POST /api/v1/sync/push` - Push local operations to the server operation log +- `POST /api/v1/sync/pull` - Pull operations since a server sequence number +- `POST /api/v1/blobs/` - Store a multipart `file` blob and return its SHA-256 hash +- `GET /api/v1/blobs/{sha256}` - Download a stored blob by SHA-256 hash + +User API: + +- `POST /api/v1/auth/register` - Register a user +- `GET /api/v1/auth/confirm?token=...` - Confirm email +- `POST /api/v1/auth/login` - User login +- `POST /api/v1/auth/forgot` - Request password reset +- `POST /api/v1/auth/reset` - Reset password +- `GET /api/v1/user/devices` - List devices for the current user session + +Operational endpoints: + +- `GET /api/v1/health` - Server health and basic storage status +- `/admin/...` - Admin web UI and admin JSON endpoints +- `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI + +Sync operations are generic records with `entity_type`, `entity_id`, `op_type`, +`payload_json`, `device_id`, and sequencing metadata. The server stores and +orders operations; Verstak desktop owns the v2 payload semantics. ## Development diff --git a/internal/server/handlers_api.go b/internal/server/handlers_api.go index e43164f..ffc30b7 100644 --- a/internal/server/handlers_api.go +++ b/internal/server/handlers_api.go @@ -468,7 +468,7 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) { PayloadJSON string `json:"payload_json"` CreatedAt string `json:"created_at"` } - var ops []opDTO + ops := []opDTO{} for rows.Next() { var o opDTO if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f94dcfe..ba06bed 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,9 +1,14 @@ package server import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" + "time" ) func TestNewServer(t *testing.T) { @@ -52,3 +57,104 @@ func TestConfigSetAdmin(t *testing.T) { t.Fatal("CheckAdmin should return false for unknown user") } } + +func TestSyncPushPullStoresSequencedOps(t *testing.T) { + dir := t.TempDir() + s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + defer s.Close() + s.SetupRoutes() + + now := time.Now().UTC().Format(time.RFC3339) + if _, err := s.db.Exec( + "INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", + "device-a", "Device A", "api-key", now, now, + ); err != nil { + t.Fatalf("insert device: %v", err) + } + ts := httptest.NewServer(s.mux) + defer ts.Close() + + pushBody := map[string]interface{}{ + "device_id": "device-a", + "ops": []map[string]interface{}{ + { + "op_id": "op-1", + "entity_type": "file", + "entity_id": "Docs/one.txt", + "op_type": "create", + "payload_json": `{"path":"Docs/one.txt","content":"hello"}`, + "created_at": "2026-06-27T00:00:00Z", + "client_sequence": 1, + }, + }, + } + pushResp := postJSON(t, ts.URL+"/api/v1/sync/push", "api-key", pushBody) + if got := int(pushResp["count"].(float64)); got != 1 { + t.Fatalf("push count = %d, want 1: %#v", got, pushResp) + } + accepted := pushResp["accepted"].([]interface{}) + if len(accepted) != 1 || accepted[0] != "op-1" { + t.Fatalf("accepted = %#v", accepted) + } + + pullResp := postJSON(t, ts.URL+"/api/v1/sync/pull", "api-key", map[string]interface{}{ + "since_sequence": 0, + }) + if got := int(pullResp["server_sequence"].(float64)); got != 1 { + t.Fatalf("server_sequence = %d, want 1: %#v", got, pullResp) + } + ops := pullResp["ops"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("ops len = %d, want 1: %#v", len(ops), ops) + } + op := ops[0].(map[string]interface{}) + if op["op_id"] != "op-1" || + op["device_id"] != "device-a" || + op["entity_type"] != "file" || + op["entity_id"] != "Docs/one.txt" || + op["op_type"] != "create" || + op["payload_json"] != `{"path":"Docs/one.txt","content":"hello"}` || + int(op["server_sequence"].(float64)) != 1 { + t.Fatalf("pulled op = %#v", op) + } + + pullAfterResp := postJSON(t, ts.URL+"/api/v1/sync/pull", "api-key", map[string]interface{}{ + "since_sequence": 1, + }) + if got := int(pullAfterResp["server_sequence"].(float64)); got != 1 { + t.Fatalf("server_sequence after = %d, want 1", got) + } + if ops := pullAfterResp["ops"].([]interface{}); len(ops) != 0 { + t.Fatalf("ops after seq len = %d, want 0: %#v", len(ops), ops) + } +} + +func postJSON(t *testing.T, url, token string, body interface{}) map[string]interface{} { + t.Helper() + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(body); err != nil { + t.Fatalf("encode request: %v", err) + } + req, err := http.NewRequest(http.MethodPost, url, &b) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post %s: %v", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("post %s status = %d", url, resp.StatusCode) + } + var out map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + return out +}