package sync import ( "context" "database/sql" "fmt" "log/slog" "os" "path/filepath" "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, 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 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, 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) } clientChange := Change{ Type: ChangeUpdate, Path: "hello.md", Hash: "clienthash-different-file", Size: 128, Modified: time.Now().UTC(), } result, err := service.ApplyDelta(ctx, snap.ID, 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 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, []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, 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 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, 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) } }