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") } }