Files
cairnquire/apps/server/internal/sync/service_test.go

232 lines
6.1 KiB
Go

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")
}
}