package sync import ( "context" "database/sql" "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 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") } }