455 lines
13 KiB
Go
455 lines
13 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
|
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
|
"github.com/tim/cairnquire/apps/server/internal/store"
|
|
)
|
|
|
|
func setupTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
db, err := sql.Open("libsql", "file:"+dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open test database: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
|
t.Fatalf("enable foreign keys: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := database.ApplyMigrations(ctx, db); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
func setupTestService(t *testing.T) (*Service, string) {
|
|
t.Helper()
|
|
|
|
sourceDir := t.TempDir()
|
|
storeDir := t.TempDir()
|
|
|
|
db := setupTestDB(t)
|
|
|
|
// Insert a test user to satisfy foreign key constraints
|
|
if _, err := db.ExecContext(context.Background(), `
|
|
INSERT INTO users (id, email, display_name, created_at)
|
|
VALUES ('user:test', 'test@example.com', 'Test User', ?)
|
|
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
t.Fatalf("insert test user: %v", err)
|
|
}
|
|
|
|
// Create some test markdown files
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nWorld"), 0o644); err != nil {
|
|
t.Fatalf("create hello.md: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "guide.md"), []byte("# Guide\n\nSteps"), 0o644); err != nil {
|
|
t.Fatalf("create guide.md: %v", err)
|
|
}
|
|
|
|
contentStore, err := store.New(storeDir)
|
|
if err != nil {
|
|
t.Fatalf("create content store: %v", err)
|
|
}
|
|
|
|
renderer := markdown.NewRenderer()
|
|
repo := docs.NewRepository(db)
|
|
docService := docs.NewService(sourceDir, contentStore, renderer, repo, slog.Default())
|
|
syncRepo := NewRepository(db)
|
|
|
|
service := NewService(syncRepo, docService, contentStore, sourceDir, slog.Default())
|
|
return service, sourceDir
|
|
}
|
|
|
|
func TestInitSyncCreatesSnapshot(t *testing.T) {
|
|
service, _ := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
if snap.ID == "" {
|
|
t.Fatal("expected snapshot ID to be set")
|
|
}
|
|
if snap.DeviceID != "device-1" {
|
|
t.Fatalf("expected device ID device-1, got %s", snap.DeviceID)
|
|
}
|
|
if len(snap.Files) != 2 {
|
|
t.Fatalf("expected 2 files, got %d", len(snap.Files))
|
|
}
|
|
}
|
|
|
|
func TestApplyDeltaDetectsServerChanges(t *testing.T) {
|
|
service, sourceDir := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
// Modify a file on the server
|
|
time.Sleep(10 * time.Millisecond)
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nUpdated"), 0o644); err != nil {
|
|
t.Fatalf("update hello.md: %v", err)
|
|
}
|
|
|
|
// Client sends empty delta
|
|
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: nil})
|
|
if err != nil {
|
|
t.Fatalf("ApplyDelta() error = %v", err)
|
|
}
|
|
|
|
if len(result.ServerDelta) != 1 {
|
|
t.Fatalf("expected 1 server delta, got %d", len(result.ServerDelta))
|
|
}
|
|
|
|
change := result.ServerDelta[0]
|
|
if change.Type != ChangeUpdate {
|
|
t.Fatalf("expected update change, got %s", change.Type)
|
|
}
|
|
if change.Path != "hello.md" {
|
|
t.Fatalf("expected path hello.md, got %s", change.Path)
|
|
}
|
|
if len(result.Conflicts) != 0 {
|
|
t.Fatalf("expected 0 conflicts, got %d", len(result.Conflicts))
|
|
}
|
|
}
|
|
|
|
func TestApplyDeltaMarshalsEmptyCollectionsAsArrays(t *testing.T) {
|
|
service, _ := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
|
if err != nil {
|
|
t.Fatalf("ApplyDelta() error = %v", err)
|
|
}
|
|
|
|
payload, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatalf("marshal delta result: %v", err)
|
|
}
|
|
for _, expected := range []string{`"serverDelta":[]`, `"conflicts":[]`} {
|
|
if !strings.Contains(string(payload), expected) {
|
|
t.Fatalf("delta JSON = %s, want %s", payload, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
|
service, sourceDir := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
// Both server and client change the same file
|
|
time.Sleep(10 * time.Millisecond)
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nServer Update"), 0o644); err != nil {
|
|
t.Fatalf("update hello.md: %v", err)
|
|
}
|
|
|
|
// Client also changes the file (different content = different hash)
|
|
clientChange := Change{
|
|
Type: ChangeUpdate,
|
|
Path: "hello.md",
|
|
Hash: "clienthash123456789012345678901234567890123456789012345678901234",
|
|
Size: 100,
|
|
Modified: time.Now().UTC(),
|
|
}
|
|
|
|
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
|
if err != nil {
|
|
t.Fatalf("ApplyDelta() error = %v", err)
|
|
}
|
|
|
|
if len(result.Conflicts) != 1 {
|
|
t.Fatalf("expected 1 conflict, got %d", len(result.Conflicts))
|
|
}
|
|
|
|
conflict := result.Conflicts[0]
|
|
if conflict.Path != "hello.md" {
|
|
t.Fatalf("expected conflict path hello.md, got %s", conflict.Path)
|
|
}
|
|
if conflict.Strategy != ResolutionLastWriteWins {
|
|
t.Fatalf("expected strategy last-write-wins, got %s", conflict.Strategy)
|
|
}
|
|
}
|
|
|
|
func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
|
service, sourceDir := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "guide.md"), []byte("# Guide\n\nServer-side guide update"), 0o644); err != nil {
|
|
t.Fatalf("update guide.md: %v", err)
|
|
}
|
|
|
|
clientContent := "# Hello\n\nClient-side hello update"
|
|
clientChange := Change{
|
|
Type: ChangeUpdate,
|
|
Path: "hello.md",
|
|
Hash: sha256Hex(clientContent),
|
|
Content: clientContent,
|
|
Size: int64(len(clientContent)),
|
|
Modified: time.Now().UTC(),
|
|
}
|
|
|
|
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
|
if err != nil {
|
|
t.Fatalf("ApplyDelta() error = %v", err)
|
|
}
|
|
|
|
if len(result.Conflicts) != 0 {
|
|
t.Fatalf("expected no conflicts for different-file edits, got %#v", result.Conflicts)
|
|
}
|
|
if len(result.ServerDelta) != 1 {
|
|
t.Fatalf("expected one server delta for guide.md, got %d", len(result.ServerDelta))
|
|
}
|
|
if result.ServerDelta[0].Path != "guide.md" {
|
|
t.Fatalf("server delta path = %q, want guide.md", result.ServerDelta[0].Path)
|
|
}
|
|
}
|
|
|
|
func TestApplyDeltaAppliesClientCreateWithContent(t *testing.T) {
|
|
service, sourceDir := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
content := "# Inbox\n\nCreated from macOS"
|
|
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
|
Type: ChangeCreate,
|
|
Path: "inbox/from-mac.md",
|
|
Hash: sha256Hex(content),
|
|
Content: content,
|
|
Size: int64(len(content)),
|
|
Modified: time.Now().UTC(),
|
|
}}})
|
|
if err != nil {
|
|
t.Fatalf("ApplyDelta() error = %v", err)
|
|
}
|
|
if len(result.Conflicts) != 0 {
|
|
t.Fatalf("expected no conflicts, got %#v", result.Conflicts)
|
|
}
|
|
if result.NewSnapshotID == "" || result.NewSnapshotID == snap.ID {
|
|
t.Fatalf("new snapshot id = %q, old = %q", result.NewSnapshotID, snap.ID)
|
|
}
|
|
|
|
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "from-mac.md"))
|
|
if err != nil {
|
|
t.Fatalf("read synced file: %v", err)
|
|
}
|
|
if string(written) != content {
|
|
t.Fatalf("synced content = %q, want %q", string(written), content)
|
|
}
|
|
|
|
stored, err := service.GetContent(sha256Hex(content))
|
|
if err != nil {
|
|
t.Fatalf("GetContent() error = %v", err)
|
|
}
|
|
if string(stored) != content {
|
|
t.Fatalf("stored content = %q, want %q", string(stored), content)
|
|
}
|
|
}
|
|
|
|
func TestApplyDeltaRejectsHashMismatch(t *testing.T) {
|
|
service, _ := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
_, err = service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
|
Type: ChangeCreate,
|
|
Path: "bad.md",
|
|
Hash: "not-the-content-hash",
|
|
Content: "# Bad\n",
|
|
}}})
|
|
if err == nil {
|
|
t.Fatal("expected hash mismatch error")
|
|
}
|
|
}
|
|
|
|
func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
|
service, sourceDir := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
serverContent := "# Hello\n\nServer Update"
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
|
|
t.Fatalf("update hello.md: %v", err)
|
|
}
|
|
|
|
_, err = service.ResolveConflicts(ctx, snap.ID, "user:test", []Resolution{{
|
|
Path: "hello.md",
|
|
Strategy: ResolutionServerWins,
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("ResolveConflicts() error = %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
|
|
if err != nil {
|
|
t.Fatalf("read hello.md: %v", err)
|
|
}
|
|
if string(content) != serverContent {
|
|
t.Fatalf("resolved content = %q, want %q", string(content), serverContent)
|
|
}
|
|
}
|
|
|
|
func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
|
|
service, _ := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
resolutions := []Resolution{
|
|
{
|
|
Path: "hello.md",
|
|
Strategy: ResolutionServerWins,
|
|
},
|
|
}
|
|
|
|
newSnap, err := service.ResolveConflicts(ctx, snap.ID, "user:test", resolutions)
|
|
if err != nil {
|
|
t.Fatalf("ResolveConflicts() error = %v", err)
|
|
}
|
|
|
|
if newSnap.ID == "" {
|
|
t.Fatal("expected new snapshot ID to be set")
|
|
}
|
|
if newSnap.ID == snap.ID {
|
|
t.Fatal("expected new snapshot ID to be different from old")
|
|
}
|
|
if len(newSnap.Files) != 2 {
|
|
t.Fatalf("expected 2 files in new snapshot, got %d", len(newSnap.Files))
|
|
}
|
|
}
|
|
|
|
func TestGetContentReturnsFileBytes(t *testing.T) {
|
|
service, _ := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
|
|
if len(snap.Files) == 0 {
|
|
t.Fatal("expected at least one file")
|
|
}
|
|
|
|
hash := snap.Files[0].Hash
|
|
content, err := service.GetContent(hash)
|
|
if err != nil {
|
|
t.Fatalf("GetContent() error = %v", err)
|
|
}
|
|
|
|
if len(content) == 0 {
|
|
t.Fatal("expected non-empty content")
|
|
}
|
|
}
|
|
|
|
func TestGetContentRejectsInvalidHash(t *testing.T) {
|
|
service, _ := setupTestService(t)
|
|
|
|
if _, err := service.GetContent("short"); err == nil {
|
|
t.Fatal("expected invalid hash error")
|
|
}
|
|
}
|
|
|
|
func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
|
service, sourceDir := setupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
for i := 0; i < 100; i++ {
|
|
name := filepath.Join(sourceDir, fmt.Sprintf("note-%03d.md", i))
|
|
content := fmt.Sprintf("# Note %03d\n\nDeterministic body %03d\n", i, i)
|
|
if err := os.WriteFile(name, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
start := time.Now()
|
|
snap, err := service.InitSync(ctx, "device-100", "user:test")
|
|
if err != nil {
|
|
t.Fatalf("InitSync() error = %v", err)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
|
t.Fatalf("InitSync() took %s, want <= 10s", elapsed)
|
|
}
|
|
if len(snap.Files) != 102 {
|
|
t.Fatalf("snapshot files = %d, want 102", len(snap.Files))
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "note-050.md"), []byte("# Note 050\n\nUpdated body\n"), 0o644); err != nil {
|
|
t.Fatalf("update note-050.md: %v", err)
|
|
}
|
|
|
|
start = time.Now()
|
|
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
|
if err != nil {
|
|
t.Fatalf("ApplyDelta() error = %v", err)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
|
t.Fatalf("ApplyDelta() took %s, want <= 10s", elapsed)
|
|
}
|
|
if len(result.ServerDelta) != 1 {
|
|
t.Fatalf("server delta count = %d, want 1", len(result.ServerDelta))
|
|
}
|
|
if result.ServerDelta[0].Path != "note-050.md" {
|
|
t.Fatalf("server delta path = %q, want note-050.md", result.ServerDelta[0].Path)
|
|
}
|
|
}
|
|
|
|
func sha256Hex(content string) string {
|
|
sum := sha256.Sum256([]byte(content))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|