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 }