Add Cloudflare email and collaboration workflows

This commit is contained in:
2026-07-22 12:59:16 -04:00
parent 09f51d1ef4
commit 4454e918c2
35 changed files with 1596 additions and 487 deletions

View File

@@ -0,0 +1,157 @@
package collaboration
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
)
func newCollaborationTestRepository(t *testing.T) (*Repository, *sql.DB) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "test.sqlite")})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
t.Fatalf("apply migrations: %v", err)
}
now := formatTime(time.Now().UTC())
for _, user := range []struct{ id, email, name string }{
{"user:author", "author@example.com", "Author"},
{"user:watcher", "watcher@example.com", "Watcher"},
} {
if _, err := db.SQL().ExecContext(ctx, `
INSERT INTO users (id, email, display_name, role, created_at) VALUES (?, ?, ?, 'editor', ?)
`, user.id, user.email, user.name, now); err != nil {
t.Fatalf("insert user: %v", err)
}
}
for _, document := range []struct{ id, path, title string }{
{"doc:guide/setup", "guide/setup.md", "Setup"},
{"doc:other", "other.md", "Other"},
} {
if _, err := db.SQL().ExecContext(ctx, `
INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at)
VALUES (?, ?, 'hash', ?, '[]', ?, ?)
`, document.id, document.path, document.title, now, now); err != nil {
t.Fatalf("insert document: %v", err)
}
}
return NewRepository(db.SQL()), db.SQL()
}
func TestWatcherTargetsAreIdempotentAndFolderMatchingUsesAncestors(t *testing.T) {
repo, db := newCollaborationTestRepository(t)
ctx := context.Background()
now := time.Now().UTC()
documentID := "doc:guide/setup"
folder := "guide"
for range 2 {
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", DocumentID: &documentID, CreatedAt: now}); err != nil {
t.Fatalf("add document watcher: %v", err)
}
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", FolderPath: &folder, CreatedAt: now}); err != nil {
t.Fatalf("add folder watcher: %v", err)
}
}
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM watchers`).Scan(&count); err != nil {
t.Fatalf("count watchers: %v", err)
}
if count != 2 {
t.Fatalf("watcher count = %d, want 2", count)
}
tests := []struct {
path string
want int
}{
{"guide/setup.md", 1},
{"guide/deep/setup.md", 1},
{"guidebook/setup.md", 0},
{"other.md", 0},
}
for _, test := range tests {
watchers, err := repo.ListWatchersByFolder(ctx, test.path)
if err != nil {
t.Fatalf("ListWatchersByFolder(%q): %v", test.path, err)
}
if len(watchers) != test.want {
t.Errorf("ListWatchersByFolder(%q) = %d, want %d", test.path, len(watchers), test.want)
}
}
}
func TestCommentReplyValidationHistoryAndThreadResolution(t *testing.T) {
repo, _ := newCollaborationTestRepository(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
service := NewServiceWithOptions(repo, nil, logger, Options{})
ctx := context.Background()
principal := auth.Principal{UserID: "user:author", DisplayName: "Author"}
anchor := "anchor"
root, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Root", AnchorHash: &anchor,
})
if err != nil {
t.Fatalf("create root: %v", err)
}
reply, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Reply", ParentID: &root.ID,
})
if err != nil {
t.Fatalf("create reply: %v", err)
}
if reply.AnchorHash == nil || *reply.AnchorHash != anchor {
t.Fatalf("reply anchor = %#v, want inherited %q", reply.AnchorHash, anchor)
}
grandchild, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Grandchild", ParentID: &reply.ID,
})
if err != nil {
t.Fatalf("create grandchild: %v", err)
}
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:other", VersionHash: "hash", Content: "Wrong document", ParentID: &root.ID,
}); err == nil {
t.Fatal("cross-document reply should fail")
}
if err := service.ResolveComment(ctx, principal, root.ID); err != nil {
t.Fatalf("resolve thread: %v", err)
}
active, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, false)
if err != nil {
t.Fatalf("list active: %v", err)
}
if len(active) != 0 {
t.Fatalf("active comments = %d, want 0", len(active))
}
history, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, true)
if err != nil {
t.Fatalf("list history: %v", err)
}
if len(history) != 3 {
t.Fatalf("history comments = %d, want 3", len(history))
}
for _, comment := range history {
if comment.ResolvedAt == nil {
t.Errorf("comment %s was not resolved", comment.ID)
}
}
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Too late", ParentID: &grandchild.ID,
}); err == nil {
t.Fatal("reply to resolved thread should fail")
}
}