package sync import ( "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // mockSyncServer returns a handler that simulates the Verstak Sync Server. func mockSyncServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") handler(w, r) })) } func jsonOK(w http.ResponseWriter, v interface{}) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(v) } func jsonErr(w http.ResponseWriter, code int, msg string) { w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func TestClient_PairDevice(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "POST required") return } if r.URL.Path != "/api/client/pair" { jsonErr(w, 404, "not found") return } body, _ := io.ReadAll(r.Body) var req map[string]string json.Unmarshal(body, &req) if req["login"] != "testuser" || req["password"] != "pass" { jsonErr(w, 401, "invalid credentials") return } jsonOK(w, map[string]interface{}{ "device_id": "dev-test-001", "device_token": "tok_secret_abc", }) }) defer srv.Close() c := NewClient(srv.URL, "", "", "") devID, devToken, err := c.PairDevice(srv.URL, "testuser", "pass", "TestClient", "test/v1") if err != nil { t.Fatalf("PairDevice: %v", err) } if devID != "dev-test-001" { t.Errorf("device_id = %q, want %q", devID, "dev-test-001") } if devToken != "tok_secret_abc" { t.Errorf("device_token = %q, want %q", devToken, "tok_secret_abc") } } func TestClient_PairDevice_InvalidCredentials(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { jsonErr(w, 401, "invalid credentials") }) defer srv.Close() c := NewClient(srv.URL, "", "", "") _, _, err := c.PairDevice(srv.URL, "bad", "wrong", "Test", "v1") if err == nil { t.Fatal("expected error for invalid credentials") } if !strings.Contains(err.Error(), "401") { t.Errorf("expected 401 error, got: %v", err) } } func TestClient_Push(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/sync/push" { jsonErr(w, 404, "not found") return } // Verify authorization header. auth := r.Header.Get("Authorization") if auth != "Bearer test-token" { jsonErr(w, 401, "invalid token") return } jsonOK(w, map[string]interface{}{ "accepted": []string{"op-1"}, "count": 1, }) }) defer srv.Close() c := NewClient(srv.URL, "test-token", "dev-1", "/tmp/vault") resp, err := c.Push([]Op{ {OpID: "op-1", EntityType: EntityNode, EntityID: "node-1", OpType: OpCreate, PayloadJSON: `{}`}, }) if err != nil { t.Fatalf("Push: %v", err) } if len(resp.Accepted) != 1 || resp.Accepted[0] != "op-1" { t.Errorf("accepted = %v, want [op-1]", resp.Accepted) } if resp.Count != 1 { t.Errorf("count = %d, want 1", resp.Count) } } func TestClient_Push_Empty(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]interface{}{ "accepted": []string{}, "count": 0, }) }) defer srv.Close() c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") resp, err := c.Push([]Op{}) if err != nil { t.Fatalf("Push empty: %v", err) } if resp.Count != 0 { t.Errorf("count = %d, want 0", resp.Count) } } func TestClient_Pull(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/sync/pull" { jsonErr(w, 404, "not found") return } jsonOK(w, map[string]interface{}{ "server_sequence": 5, "ops": []map[string]interface{}{ { "op_id": "remote-1", "entity_type": EntityNode, "entity_id": "node-99", "op_type": OpCreate, "payload_json": `{"title":"Remote Node"}`, "created_at": "2025-01-01T00:00:00Z", }, }, }) }) defer srv.Close() c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") resp, err := c.Pull(0) if err != nil { t.Fatalf("Pull: %v", err) } if resp.ServerSequence != 5 { t.Errorf("server_sequence = %d, want 5", resp.ServerSequence) } if len(resp.Ops) != 1 { t.Fatalf("expected 1 op, got %d", len(resp.Ops)) } if resp.Ops[0].EntityID != "node-99" { t.Errorf("EntityID = %q, want %q", resp.Ops[0].EntityID, "node-99") } } func TestClient_Pull_SinceSequence(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req PullRequest json.Unmarshal(body, &req) // Return ops only if since_sequence matches expected. if req.SinceSequence != 10 { jsonOK(w, map[string]interface{}{ "server_sequence": 10, "ops": []interface{}{}, }) return } jsonOK(w, map[string]interface{}{ "server_sequence": 15, "ops": []map[string]interface{}{ {"op_id": "new-op", "entity_type": EntityNode, "entity_id": "n1", "op_type": OpUpdate, "payload_json": "{}", "created_at": "2025-01-01T00:00:00Z"}, }, }) }) defer srv.Close() c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") resp, err := c.Pull(10) if err != nil { t.Fatalf("Pull(10): %v", err) } if len(resp.Ops) != 1 { t.Errorf("expected 1 op for seq=10, got %d", len(resp.Ops)) } } func TestClient_UploadBlob(t *testing.T) { var receivedFile bool srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/blobs/" { jsonErr(w, 404, "not found") return } receivedFile = true jsonOK(w, map[string]interface{}{ "sha256": "test-sha-abc123", "size": 4, }) }) defer srv.Close() dir := t.TempDir() filePath := filepath.Join(dir, "upload.txt") if err := os.WriteFile(filePath, []byte("data"), 0644); err != nil { t.Fatal(err) } c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") sha, err := c.UploadBlob(filePath) if err != nil { t.Fatalf("UploadBlob: %v", err) } if sha != "test-sha-abc123" { t.Errorf("sha = %q, want %q", sha, "test-sha-abc123") } if !receivedFile { t.Error("UploadBlob did not reach server") } } func TestClient_UploadBlob_NonExistent(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { t.Error("server should not be called for non-existent file") }) defer srv.Close() c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") _, err := c.UploadBlob("/nonexistent/file.bin") if err == nil { t.Error("expected error for non-existent file") } } func TestClient_DownloadBlob(t *testing.T) { content := []byte("downloaded content") srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/blobs/abcsha123" { jsonErr(w, 404, "not found") return } w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusOK) w.Write(content) }) defer srv.Close() dir := t.TempDir() destPath := filepath.Join(dir, "downloaded.bin") c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") if err := c.DownloadBlob("abcsha123", destPath); err != nil { t.Fatalf("DownloadBlob: %v", err) } stored, err := os.ReadFile(destPath) if err != nil { t.Fatal(err) } if string(stored) != string(content) { t.Errorf("downloaded content = %q, want %q", string(stored), string(content)) } } func TestClient_DownloadBlob_NotFound(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { jsonErr(w, 404, "blob not found") }) defer srv.Close() c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") err := c.DownloadBlob("nosuchsha", "/tmp/dest") if err == nil { t.Error("expected error for non-existent blob") } } func TestClient_TestAuth(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/auth/test" { jsonErr(w, 404, "not found") return } body, _ := io.ReadAll(r.Body) var req map[string]string json.Unmarshal(body, &req) if req["username"] == "user" && req["password"] == "correct" { jsonOK(w, map[string]string{"status": "ok"}) } else { jsonErr(w, 401, "invalid credentials") } }) defer srv.Close() c := NewClient(srv.URL, "", "", "") // Success case. if err := c.TestAuth(srv.URL, "user", "correct"); err != nil { t.Errorf("TestAuth (correct): unexpected error: %v", err) } // Failure case. if err := c.TestAuth(srv.URL, "user", "wrong"); err == nil { t.Error("TestAuth (wrong): expected error") } } func TestClient_GetMe(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/client/me" { jsonErr(w, 404, "not found") return } jsonOK(w, map[string]interface{}{ "device_id": "dev-001", "device_name": "TestClient", "username": "testuser", "revoked_at": "", }) }) defer srv.Close() c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") info, err := c.GetMe() if err != nil { t.Fatalf("GetMe: %v", err) } if info.DeviceID != "dev-001" { t.Errorf("DeviceID = %q, want %q", info.DeviceID, "dev-001") } if info.Username != "testuser" { t.Errorf("Username = %q, want %q", info.Username, "testuser") } } func TestClient_BearerToken_Priority(t *testing.T) { c := NewClient("http://example.com", "api-key", "dev-1", "/tmp/vault") // Without DeviceToken, should use APIKey. if tok := c.bearerToken(); tok != "api-key" { t.Errorf("bearerToken() = %q, want %q", tok, "api-key") } c.DeviceToken = "device-token" if tok := c.bearerToken(); tok != "device-token" { t.Errorf("bearerToken() after setting DeviceToken = %q, want %q", tok, "device-token") } } func TestClient_HttpErrors(t *testing.T) { srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) { jsonErr(w, 500, "internal error") }) defer srv.Close() c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault") _, err := c.Push([]Op{{OpID: "o1", EntityType: EntityNode, EntityID: "n1", OpType: OpCreate, PayloadJSON: "{}"}}) if err == nil { t.Error("expected error for 500 response") } if !strings.Contains(err.Error(), "500") { t.Errorf("error should mention status code: %v", err) } }