test: comprehensive sync package unit tests (37 new tests)

- safe_path_test.go: path traversal protection (11 table-driven cases)
- blob_test.go: SHA-256 hashing, store/deduplicate/read blobs
- sync_test.go: Service CRUD ops, state, push/mark lifecycle
- client_test.go: Push/Pull/Blobs/Auth via httptest.Server
- sync_e2e_test.go: auto-build server binary on demand
This commit is contained in:
mirivlad 2026-06-03 09:16:38 +08:00
parent 7d81250ebd
commit ca280a59c0
7 changed files with 947 additions and 3 deletions

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,7 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-CWWXp5bW.js"></script>
<script type="module" crossorigin src="/assets/main-hwPUi_6_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BBKDbfa7.css">
</head>
<body>

View File

@ -0,0 +1,207 @@
package sync
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBlobDir(t *testing.T) {
got := BlobDir("/tmp/vault")
want := "/tmp/vault/.verstak/blobs"
if got != want {
t.Errorf("BlobDir() = %q, want %q", got, want)
}
}
func TestBlobPath(t *testing.T) {
sha := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
got := BlobPath("/tmp/blobs", sha)
want := "/tmp/blobs/ab/cd/abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
if got != want {
t.Errorf("BlobPath() = %q, want %q", got, want)
}
}
func TestHashBytes(t *testing.T) {
got := HashBytes([]byte("hello"))
// SHA-256 of "hello"
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got != want {
t.Errorf("HashBytes() = %q, want %q", got, want)
}
gotEmpty := HashBytes([]byte{})
wantEmpty := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
if gotEmpty != wantEmpty {
t.Errorf("HashBytes(empty) = %q, want %q", gotEmpty, wantEmpty)
}
}
func TestHashFile(t *testing.T) {
dir := t.TempDir()
// Write a test file.
path := filepath.Join(dir, "test.txt")
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
got, err := HashFile(path)
if err != nil {
t.Fatal(err)
}
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got != want {
t.Errorf("HashFile() = %q, want %q", got, want)
}
// Hash a non-existent file.
_, err = HashFile(filepath.Join(dir, "nonexistent"))
if err == nil {
t.Error("HashFile() expected error for non-existent file")
}
}
func TestStoreBlob(t *testing.T) {
dir := t.TempDir()
blobsDir := BlobDir(dir)
srcDir := t.TempDir()
// Write source file.
srcPath := filepath.Join(srcDir, "data.bin")
if err := os.WriteFile(srcPath, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
// Store the blob.
sha, err := StoreBlob(blobsDir, srcPath)
if err != nil {
t.Fatal(err)
}
wantSHA := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
if sha != wantSHA {
t.Errorf("StoreBlob() sha = %q, want %q", sha, wantSHA)
}
// Verify file exists at the expected path.
blobPath := BlobPath(blobsDir, sha)
if _, err := os.Stat(blobPath); err != nil {
t.Errorf("blob file not created at %s: %v", blobPath, err)
}
// Verify content.
data, err := os.ReadFile(blobPath)
if err != nil {
t.Fatal(err)
}
if string(data) != "hello world" {
t.Errorf("blob content = %q, want %q", string(data), "hello world")
}
}
func TestStoreBlob_Deduplicate(t *testing.T) {
dir := t.TempDir()
blobsDir := BlobDir(dir)
srcDir := t.TempDir()
// Create two files with the same content.
src1 := filepath.Join(srcDir, "a.txt")
src2 := filepath.Join(srcDir, "b.txt")
if err := os.WriteFile(src1, []byte("same content"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(src2, []byte("same content"), 0644); err != nil {
t.Fatal(err)
}
sha1, err := StoreBlob(blobsDir, src1)
if err != nil {
t.Fatal(err)
}
sha2, err := StoreBlob(blobsDir, src2)
if err != nil {
t.Fatal(err)
}
if sha1 != sha2 {
t.Errorf("StoreBlob() deduplication failed: sha1=%q sha2=%q", sha1, sha2)
}
// Count files in the blob store — should only be 1.
var count int
filepath.Walk(blobsDir, func(p string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() {
count++
}
return nil
})
if count != 1 {
t.Errorf("expected 1 blob file, got %d", count)
}
}
func TestStoreBlob_LargeFile(t *testing.T) {
dir := t.TempDir()
blobsDir := BlobDir(dir)
srcDir := t.TempDir()
// Create a 1MB file.
srcPath := filepath.Join(srcDir, "large.bin")
data := make([]byte, 1024*1024)
for i := range data {
data[i] = byte(i % 256)
}
if err := os.WriteFile(srcPath, data, 0644); err != nil {
t.Fatal(err)
}
sha, err := StoreBlob(blobsDir, srcPath)
if err != nil {
t.Fatal(err)
}
if len(sha) != 64 {
t.Errorf("expected 64-char SHA-256, got %d chars", len(sha))
}
blobPath := BlobPath(blobsDir, sha)
stored, err := os.ReadFile(blobPath)
if err != nil {
t.Fatal(err)
}
if len(stored) != len(data) {
t.Errorf("stored blob size = %d, want %d", len(stored), len(data))
}
}
func TestReadBlob(t *testing.T) {
dir := t.TempDir()
blobsDir := BlobDir(dir)
srcDir := t.TempDir()
srcPath := filepath.Join(srcDir, "data.txt")
if err := os.WriteFile(srcPath, []byte("read test"), 0644); err != nil {
t.Fatal(err)
}
sha, err := StoreBlob(blobsDir, srcPath)
if err != nil {
t.Fatal(err)
}
data, err := ReadBlob(blobsDir, sha)
if err != nil {
t.Fatal(err)
}
if string(data) != "read test" {
t.Errorf("ReadBlob() = %q, want %q", string(data), "read test")
}
// Read non-existent blob.
_, err = ReadBlob(blobsDir, strings.Repeat("a", 64))
if err == nil {
t.Error("ReadBlob() expected error for non-existent blob")
}
}

View File

@ -0,0 +1,385 @@
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)
}
}

View File

@ -0,0 +1,59 @@
package sync
import (
"testing"
)
func TestSafeVaultPath(t *testing.T) {
tests := []struct {
name string
vaultRoot string
relPath string
want string
wantErr bool
}{
{"empty path", "/tmp/vault", "", "", true},
{"absolute path", "/tmp/vault", "/etc/passwd", "", true},
{"simple escape", "/tmp/vault", "../../etc/passwd", "", true},
{"escape via prefix", "/tmp/vault", "../outside/foo", "", true},
{"clean escape", "/tmp/vault", "a/../../../etc/passwd", "", true},
{"simple file", "/tmp/vault", "file.txt", "file.txt", false},
{"nested file", "/tmp/vault", "a/b/c/file.txt", "a/b/c/file.txt", false},
{"with dots", "/tmp/vault", "a/b/../c/file.txt", "a/c/file.txt", false},
{"unicode path", "/tmp/vault", "проекты/файл.txt", "проекты/файл.txt", false},
{"root level dir", "/tmp/vault", "notes", "notes", false},
{"deeply nested", "/tmp/vault", "clients/acme/projects/website/docs", "clients/acme/projects/website/docs", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SafeVaultPath(tt.vaultRoot, tt.relPath)
if (err != nil) != tt.wantErr {
t.Errorf("SafeVaultPath() error = %v, wantErr = %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("SafeVaultPath() = %q, want %q", got, tt.want)
}
})
}
}
func TestSafeVaultPaths(t *testing.T) {
vaultRoot := "/tmp/vault"
err := SafeVaultPaths(vaultRoot, "a/b", "c/d", "e/f")
if err != nil {
t.Errorf("SafeVaultPaths() unexpected error: %v", err)
}
err = SafeVaultPaths(vaultRoot, "a/b", "../../etc/passwd")
if err == nil {
t.Error("SafeVaultPaths() expected error for escape path, got nil")
}
err = SafeVaultPaths(vaultRoot)
if err != nil {
t.Errorf("SafeVaultPaths() with no paths: unexpected error: %v", err)
}
}

View File

@ -252,7 +252,7 @@ func TestE2ESync(t *testing.T) {
func findServerBin(t *testing.T) string {
t.Helper()
// Check common locations.
// Check common locations first.
candidates := []string{
"verstak-server",
"../../../verstak-server",
@ -262,7 +262,19 @@ func findServerBin(t *testing.T) string {
return c
}
}
return ""
// Build it on demand.
t.Log("server binary not found — building from source...")
cmd := exec.Command("go", "build", "-o", "verstak-server", "../../../cmd/verstak-server")
cmd.Dir = t.TempDir()
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("build output: %s", string(output))
t.Logf("build error: %v (e2e sync test requires a built server binary)", err)
return ""
}
binPath := filepath.Join(cmd.Dir, "verstak-server")
t.Logf("server binary built at %s", binPath)
return binPath
}
func pickPort(t *testing.T) int {

View File

@ -0,0 +1,278 @@
package sync
import (
"path/filepath"
"testing"
"time"
"verstak/internal/core/storage"
)
func newServiceForTest(t *testing.T) (*Service, func()) {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("storage.Open: %v", err)
}
svc := NewService(db, "test-device-001")
return svc, func() { db.Close() }
}
func TestRecordOp(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
err := svc.RecordOp(EntityNode, "node-001", OpCreate, map[string]interface{}{
"title": "Test Node",
"type": "case",
})
if err != nil {
t.Fatalf("RecordOp: %v", err)
}
// Verify the op was recorded.
ops, err := svc.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
if len(ops) != 1 {
t.Fatalf("expected 1 unpushed op, got %d", len(ops))
}
if ops[0].EntityType != EntityNode {
t.Errorf("EntityType = %q, want %q", ops[0].EntityType, EntityNode)
}
if ops[0].EntityID != "node-001" {
t.Errorf("EntityID = %q, want %q", ops[0].EntityID, "node-001")
}
if ops[0].OpType != OpCreate {
t.Errorf("OpType = %q, want %q", ops[0].OpType, OpCreate)
}
if ops[0].DeviceID != "test-device-001" {
t.Errorf("DeviceID = %q, want %q", ops[0].DeviceID, "test-device-001")
}
}
func TestRecordOp_MultipleOps(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
for i := 0; i < 5; i++ {
id := string(rune('A' + i))
err := svc.RecordOp(EntityNode, "node-"+id, OpCreate, nil)
if err != nil {
t.Fatalf("RecordOp %d: %v", i, err)
}
}
ops, err := svc.GetUnpushedOps()
if err != nil {
t.Fatal(err)
}
if len(ops) != 5 {
t.Errorf("expected 5 unpushed ops, got %d", len(ops))
}
}
func TestRecordOp_WithNilPayload(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
err := svc.RecordOp(EntityAction, "action-001", OpDelete, nil)
if err != nil {
t.Fatalf("RecordOp with nil payload: %v", err)
}
ops, err := svc.GetUnpushedOps()
if err != nil {
t.Fatal(err)
}
if len(ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(ops))
}
}
func TestMarkPushed(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
if err := svc.RecordOp(EntityNode, "n1", OpCreate, nil); err != nil {
t.Fatal(err)
}
if err := svc.RecordOp(EntityNode, "n2", OpCreate, nil); err != nil {
t.Fatal(err)
}
ops, err := svc.GetUnpushedOps()
if err != nil {
t.Fatal(err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 unpushed ops, got %d", len(ops))
}
// Mark first as pushed.
if err := svc.MarkPushed([]string{ops[0].OpID}); err != nil {
t.Fatal(err)
}
remaining, err := svc.GetUnpushedOps()
if err != nil {
t.Fatal(err)
}
if len(remaining) != 1 {
t.Errorf("expected 1 remaining unpushed op, got %d", len(remaining))
}
if remaining[0].OpID != ops[1].OpID {
t.Errorf("remaining op should be the second one")
}
}
func TestMarkPushed_Empty(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
err := svc.MarkPushed([]string{})
if err != nil {
t.Errorf("MarkPushed with empty list: %v", err)
}
}
func TestMarkApplied(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
now := time.Now().UTC().Format(time.RFC3339)
op := Op{
OpID: "remote-op-001",
DeviceID: "other-device",
EntityType: EntityNode,
EntityID: "node-remote",
OpType: OpCreate,
PayloadJSON: `{"title":"Remote Node"}`,
CreatedAt: now,
}
if err := svc.RecordRemoteOp(op); err != nil {
t.Fatal(err)
}
if err := svc.MarkApplied([]string{"remote-op-001"}); err != nil {
t.Fatal(err)
}
}
func TestGetState_SetState(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
// Initial state should be empty.
url, key, seq, lastSync, err := svc.GetState()
if err != nil {
t.Fatal(err)
}
if url != "" || key != "" || seq != 0 || lastSync != "" {
t.Errorf("expected empty initial state, got url=%q key=%q seq=%d lastSync=%q",
url, key, seq, lastSync)
}
// Set state.
if err := svc.SetState("https://sync.example.com", "test-api-key"); err != nil {
t.Fatal(err)
}
url, key, seq, lastSync, err = svc.GetState()
if err != nil {
t.Fatal(err)
}
if url != "https://sync.example.com" {
t.Errorf("url = %q, want %q", url, "https://sync.example.com")
}
if key != "test-api-key" {
t.Errorf("api_key = %q, want %q", key, "test-api-key")
}
if seq != 0 {
t.Errorf("expected seq=0, got %d", seq)
}
// lastSync could be empty initially.
_ = lastSync
}
func TestSetState_Upsert(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
if err := svc.SetState("https://first.example.com", "key1"); err != nil {
t.Fatal(err)
}
// Upsert with new values.
if err := svc.SetState("https://second.example.com", "key2"); err != nil {
t.Fatal(err)
}
url, key, _, _, err := svc.GetState()
if err != nil {
t.Fatal(err)
}
if url != "https://second.example.com" {
t.Errorf("url after upsert = %q", url)
}
if key != "key2" {
t.Errorf("key after upsert = %q", key)
}
}
func TestSetLastPullSeq(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
if err := svc.SetState("https://example.com", "key"); err != nil {
t.Fatal(err)
}
if err := svc.SetLastPullSeq(42); err != nil {
t.Fatal(err)
}
_, _, seq, _, err := svc.GetState()
if err != nil {
t.Fatal(err)
}
if seq != 42 {
t.Errorf("last_pull_seq = %d, want 42", seq)
}
}
func TestGetDeviceID(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
if id := svc.GetDeviceID(); id != "test-device-001" {
t.Errorf("GetDeviceID() = %q, want %q", id, "test-device-001")
}
}
func TestRecordRemoteOp_Deduplicate(t *testing.T) {
svc, cleanup := newServiceForTest(t)
defer cleanup()
now := time.Now().UTC().Format(time.RFC3339)
op := Op{
OpID: "dup-op",
DeviceID: "dev-1",
EntityType: EntityNode,
EntityID: "node-1",
OpType: OpCreate,
PayloadJSON: `{}`,
CreatedAt: now,
}
// Insert the same op twice — second should be IGNORE.
if err := svc.RecordRemoteOp(op); err != nil {
t.Fatal(err)
}
if err := svc.RecordRemoteOp(op); err != nil {
t.Fatal(err)
}
}