package docs import ( "context" "database/sql" "errors" "fmt" "log/slog" "os" "path/filepath" "reflect" "testing" "time" "github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/markdown" "github.com/tim/cairnquire/apps/server/internal/store" ) func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) { tests := []struct { name string path string want []string }{ { name: "root", path: "", want: []string{"index.md", "getting-started.md"}, }, { name: "clean folder or page path", path: "guide", want: []string{"guide.md", "guide/index.md"}, }, { name: "explicit markdown path", path: "guide/index.md", want: []string{"guide/index.md"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := normalizeRequestPathCandidates(tt.path) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("normalizeRequestPathCandidates(%q) = %#v, want %#v", tt.path, got, tt.want) } }) } } func TestSaveSourcePageWithBaseHashRejectsStaleEditor(t *testing.T) { service, sourceDir := setupDocsTestService(t) ctx := context.Background() page, err := service.LoadSourcePage(ctx, "hello") if err != nil { t.Fatalf("LoadSourcePage() error = %v", err) } serverContent := "# Hello\n\nServer edit" if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil { t.Fatalf("write server edit: %v", err) } _, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nClient edit", page.Hash) var conflict *DocumentConflictError if !errors.As(err, &conflict) { t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err) } if conflict.BaseHash != page.Hash { t.Fatalf("conflict base hash = %q, want %q", conflict.BaseHash, page.Hash) } if conflict.CurrentContent != serverContent { t.Fatalf("conflict current content = %q, want %q", conflict.CurrentContent, serverContent) } 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("stale save overwrote content: %q", string(content)) } } func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) { service, sourceDir := setupDocsTestService(t) ctx := context.Background() page, err := service.LoadSourcePage(ctx, "hello") if err != nil { t.Fatalf("LoadSourcePage() error = %v", err) } nextContent := "# Hello\n\nClient edit" updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", nextContent, page.Hash) if err != nil { t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err) } if updated.Hash == page.Hash { t.Fatal("expected hash to change after save") } content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md")) if err != nil { t.Fatalf("read hello.md: %v", err) } if string(content) != nextContent { t.Fatalf("saved content = %q, want %q", string(content), nextContent) } } func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) { service, sourceDir := setupDocsTestService(t) ctx := context.Background() page, err := service.LoadSourcePage(ctx, "hello") if err != nil { t.Fatalf("LoadSourcePage() error = %v", err) } serverContent := "# Hello\n\nServer edit" if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil { t.Fatalf("write server edit: %v", err) } _, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nQueued offline edit", page.Hash) var conflict *DocumentConflictError if !errors.As(err, &conflict) { t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err) } resolvedContent := conflict.CurrentContent + "\n\nResolved local addition" updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", resolvedContent, conflict.CurrentHash) if err != nil { t.Fatalf("SaveSourcePageWithBaseHash() latest hash error = %v", err) } if updated.Hash == conflict.CurrentHash { t.Fatal("expected resolved save to produce a new hash") } content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md")) if err != nil { t.Fatalf("read hello.md: %v", err) } if string(content) != resolvedContent { t.Fatalf("resolved content = %q, want %q", string(content), resolvedContent) } } func TestSaveSourcePageCreatesNewDocument(t *testing.T) { service, sourceDir := setupDocsTestService(t) ctx := context.Background() content := "# Inbox Note\n\nCreated from clipboard" page, err := service.SaveSourcePageWithBaseHash(ctx, "inbox/note", content, "") if err != nil { t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err) } if page.Path != "inbox/note.md" { t.Fatalf("page path = %q, want inbox/note.md", page.Path) } written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "note.md")) if err != nil { t.Fatalf("read new document: %v", err) } if string(written) != content { t.Fatalf("new document content = %q, want %q", string(written), content) } } func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) { service, _ := setupDocsTestService(t) ctx := context.Background() _, err := service.SaveSourcePageWithBaseHash(ctx, "../outside", "# Outside\n", "") if err == nil { t.Fatal("expected traversal path to be rejected") } } func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) { service, sourceDir := setupDocsTestService(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() changes, err := service.SyncSourceDir(ctx) if err != nil { t.Fatalf("SyncSourceDir() error = %v", err) } if elapsed := time.Since(start); elapsed > 10*time.Second { t.Fatalf("SyncSourceDir() took %s, want <= 10s", elapsed) } if len(changes) != 101 { t.Fatalf("changes = %d, want 101", len(changes)) } } func setupDocsTestService(t *testing.T) (*Service, string) { t.Helper() sourceDir := t.TempDir() storeDir := t.TempDir() if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nOriginal"), 0o644); err != nil { t.Fatalf("create hello.md: %v", err) } dbPath := filepath.Join(t.TempDir(), "test.db") db, err := sql.Open("libsql", "file:"+dbPath) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { db.Close() }) ctx := context.Background() if err := database.ApplyMigrations(ctx, db); err != nil { t.Fatalf("apply migrations: %v", err) } contentStore, err := store.New(storeDir) if err != nil { t.Fatalf("create content store: %v", err) } repo := NewRepository(db) return NewService(sourceDir, contentStore, markdown.NewRenderer(), repo, slog.Default()), sourceDir }