Document and test sync API contract
This commit is contained in:
parent
c793084fa4
commit
fcf0a07fcc
42
README.md
42
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue