Add conflict resolution diff UI and app branding
This commit is contained in:
231
apps/server/internal/sync/service_test.go
Normal file
231
apps/server/internal/sync/service_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user