Files
cairnquire/apps/server/internal/docs/service_test.go
2026-07-27 11:52:13 -04:00

393 lines
12 KiB
Go

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 TestMoveDocumentPreservesIdentityAndHistory(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)
}
before, err := service.repo.GetDocumentByPath(ctx, "hello.md")
if err != nil {
t.Fatalf("GetDocumentByPath() error = %v", err)
}
var versionsBefore int
if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsBefore); err != nil {
t.Fatalf("count versions before move: %v", err)
}
if err := service.repo.SaveAttachment(ctx, AttachmentRecord{
Hash: "attachment-hash", OriginalName: "diagram.png", ContentType: "image/png",
SizeBytes: 10, CreatedAt: time.Now().UTC(), DocumentPath: before.Path,
}); err != nil {
t.Fatalf("save attachment: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, created_at) VALUES ('user-move', 'move@example.com', 'Mover', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create permission user: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
VALUES ('permission-move', 'user-move', 'document', ?, 'write', ?)
`, before.Path, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create document permission: %v", err)
}
var moveChange DocumentChange
service.OnChange(func(change DocumentChange) {
moveChange = change
})
moved, err := service.MoveDocument(ctx, "hello.md", "guides/welcome.md", page.Hash)
if err != nil {
t.Fatalf("MoveDocument() error = %v", err)
}
if moved.ID != before.ID {
t.Fatalf("moved ID = %q, want %q", moved.ID, before.ID)
}
if moved.Path != "guides/welcome.md" {
t.Fatalf("moved path = %q, want guides/welcome.md", moved.Path)
}
if moveChange.Type != "move" || moveChange.DocumentID != moved.ID || moveChange.OldPath != "hello.md" || moveChange.Path != moved.Path {
t.Fatalf("move change = %#v", moveChange)
}
if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("old file still exists or stat failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "guides", "welcome.md"))
if err != nil {
t.Fatalf("read moved file: %v", err)
}
if string(content) != page.Content {
t.Fatalf("moved content = %q, want %q", content, page.Content)
}
alias, err := service.repo.GetDocumentByPathOrAlias(ctx, "hello.md")
if err != nil {
t.Fatalf("resolve old path alias: %v", err)
}
if alias.ID != before.ID || alias.Path != moved.Path {
t.Fatalf("alias resolved to %#v, want ID %q at %q", alias, before.ID, moved.Path)
}
var versionsAfter int
if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsAfter); err != nil {
t.Fatalf("count versions after move: %v", err)
}
if versionsAfter != versionsBefore {
t.Fatalf("versions after move = %d, want %d", versionsAfter, versionsBefore)
}
attachment, err := service.repo.GetAttachment(ctx, "attachment-hash")
if err != nil {
t.Fatalf("get moved attachment metadata: %v", err)
}
if attachment.DocumentPath != moved.Path {
t.Fatalf("attachment document path = %q, want %q", attachment.DocumentPath, moved.Path)
}
var permissionPath string
if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-move'`).Scan(&permissionPath); err != nil {
t.Fatalf("get moved permission: %v", err)
}
if permissionPath != moved.Path {
t.Fatalf("permission resource path = %q, want %q", permissionPath, moved.Path)
}
}
func TestMoveDocumentRejectsStaleHashAndOccupiedDestination(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if _, err := service.LoadSourcePage(ctx, "hello"); err != nil {
t.Fatalf("LoadSourcePage() error = %v", err)
}
_, err := service.MoveDocument(ctx, "hello.md", "moved.md", "stale")
var conflict *DocumentMoveConflictError
if !errors.As(err, &conflict) {
t.Fatalf("stale MoveDocument() error = %v, want DocumentMoveConflictError", err)
}
if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); err != nil {
t.Fatalf("source file changed after stale move: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "occupied.md"), []byte("# Occupied\n"), 0o644); err != nil {
t.Fatalf("create occupied destination: %v", err)
}
page, err := service.LoadSourcePage(ctx, "hello")
if err != nil {
t.Fatalf("reload source page: %v", err)
}
_, err = service.MoveDocument(ctx, "hello.md", "occupied.md", page.Hash)
if !errors.As(err, &conflict) {
t.Fatalf("occupied MoveDocument() error = %v, want DocumentMoveConflictError", err)
}
}
func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
service, _ := setupDocsTestService(t)
ctx := context.Background()
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("initial SyncSourceDir() error = %v", err)
}
var changed []DocumentChange
service.OnChange(func(change DocumentChange) {
changed = append(changed, change)
})
if err := service.ArchiveDocument(ctx, "hello.md"); err != nil {
t.Fatalf("ArchiveDocument() error = %v", err)
}
if len(changed) != 1 {
t.Fatalf("changes after archive = %d, want 1", len(changed))
}
changed = nil
changes, err := service.SyncSourceDir(ctx)
if err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
if len(changes) != 0 {
t.Fatalf("sync changes = %#v, want none for archived document", changes)
}
if len(changed) != 0 {
t.Fatalf("broadcast changes = %#v, want none for archived document", changed)
}
}
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
}