A community based topic aggregation platform built on atproto

Merge branch 'feat/comment-query-api-phase2a'

+3871 -86
+22
cmd/server/main.go
··· 7 7 "Coves/internal/atproto/identity" 8 8 "Coves/internal/atproto/jetstream" 9 9 "Coves/internal/core/aggregators" 10 + "Coves/internal/core/comments" 10 11 "Coves/internal/core/communities" 11 12 "Coves/internal/core/communityFeeds" 12 13 "Coves/internal/core/discover" ··· 29 30 chiMiddleware "github.com/go-chi/chi/v5/middleware" 30 31 _ "github.com/lib/pq" 31 32 "github.com/pressly/goose/v3" 33 + 34 + commentsAPI "Coves/internal/api/handlers/comments" 32 35 33 36 postgresRepo "Coves/internal/db/postgres" 34 37 ) ··· 290 293 commentRepo := postgresRepo.NewCommentRepository(db) 291 294 log.Println("✅ Comment repository initialized (Jetstream indexing only)") 292 295 296 + // Initialize comment service (for query API) 297 + // Requires user and community repos for proper author/community hydration per lexicon 298 + commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 299 + log.Println("✅ Comment service initialized (with author/community hydration)") 300 + 293 301 // Initialize feed service 294 302 feedRepo := postgresRepo.NewCommunityFeedRepository(db) 295 303 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) ··· 417 425 418 426 routes.RegisterAggregatorRoutes(r, aggregatorService) 419 427 log.Println("Aggregator XRPC endpoints registered (query endpoints public)") 428 + 429 + // Comment query API - supports optional authentication for viewer state 430 + // Stricter rate limiting for expensive nested comment queries 431 + commentRateLimiter := middleware.NewRateLimiter(20, 1*time.Minute) 432 + commentServiceAdapter := commentsAPI.NewServiceAdapter(commentService) 433 + commentHandler := commentsAPI.NewGetCommentsHandler(commentServiceAdapter) 434 + r.Handle( 435 + "/xrpc/social.coves.community.comment.getComments", 436 + commentRateLimiter.Middleware( 437 + commentsAPI.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments), 438 + ), 439 + ) 440 + log.Println("✅ Comment query API registered (20 req/min rate limit)") 441 + log.Println(" - GET /xrpc/social.coves.community.comment.getComments") 420 442 421 443 r.Get("/health", func(w http.ResponseWriter, r *http.Request) { 422 444 w.WriteHeader(http.StatusOK)
+1125
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
··· 1 + # Comment System Implementation 2 + 3 + ## Overview 4 + 5 + This document details the complete implementation of the comment system for Coves, a forum-like atProto social media platform. The comment system follows the established vote system pattern, with comments living in user repositories and being indexed by the AppView via Jetstream firehose. 6 + 7 + **Implementation Date:** November 4-6, 2025 8 + **Status:** ✅ Phase 1 & 2A Complete - Production-Ready with All PR Fixes 9 + **Test Coverage:** 29 integration tests (18 indexing + 11 query), all passing 10 + **Last Updated:** November 6, 2025 (Final PR review fixes complete - lexicon compliance, data integrity, SQL correctness) 11 + 12 + --- 13 + 14 + ## Development Phases 15 + 16 + This implementation follows a phased approach for maintainability and proper scoping: 17 + 18 + ### ✅ Phase 1: Indexing Infrastructure (Current - COMPLETE) 19 + **What was built:** 20 + - Jetstream consumer for indexing comment CREATE/UPDATE/DELETE events 21 + - PostgreSQL schema with proper indexes and denormalized counts 22 + - Repository layer with comprehensive query methods 23 + - Atomic parent count updates (posts.comment_count, comments.reply_count) 24 + - Out-of-order event handling with reconciliation 25 + - Soft delete support preserving thread structure 26 + - Full integration test coverage (20 tests) 27 + 28 + **What works:** 29 + - Comments are indexed from Jetstream firehose as users create them 30 + - Threading relationships tracked (root + parent references) 31 + - Parent counts automatically maintained 32 + - Comment updates and deletes processed correctly 33 + - Out-of-order events reconciled automatically 34 + 35 + **What's NOT in this phase:** 36 + - ❌ No HTTP API endpoints for querying comments 37 + - ❌ No service layer (repository is sufficient for indexing) 38 + - ❌ No rate limiting or auth middleware 39 + - ❌ No API documentation 40 + 41 + ### ✅ Phase 2A: Query API - COMPLETE (November 5, 2025) 42 + 43 + **What was built:** 44 + - Lexicon definitions: `social.coves.community.comment.defs` and `getComments` 45 + - Database query methods with Lemmy hot ranking algorithm 46 + - Service layer with iterative loading strategy for nested replies 47 + - XRPC HTTP handler with optional authentication 48 + - Comprehensive integration test suite (11 test scenarios) 49 + 50 + **What works:** 51 + - Fetch comments on any post with sorting (hot/top/new) 52 + - Nested replies up to configurable depth (default 10, max 100) 53 + - Lemmy hot ranking: `log(greatest(2, score + 2)) / power(time_decay, 1.8)` 54 + - Cursor-based pagination for stable scrolling 55 + - Optional authentication for viewer state (stubbed for Phase 2B) 56 + - Timeframe filtering for "top" sort (hour/day/week/month/year/all) 57 + 58 + **Endpoints:** 59 + - `GET /xrpc/social.coves.community.comment.getComments` 60 + - Required: `post` (AT-URI) 61 + - Optional: `sort` (hot/top/new), `depth` (0-100), `limit` (1-100), `cursor`, `timeframe` 62 + - Returns: Array of `threadViewComment` with nested replies + post context 63 + - Supports Bearer token for authenticated requests (viewer state) 64 + 65 + **Files created (9):** 66 + 1. `internal/atproto/lexicon/social/coves/community/comment/defs.json` - View definitions 67 + 2. `internal/atproto/lexicon/social/coves/community/comment/getComments.json` - Query endpoint 68 + 3. `internal/core/comments/comment_service.go` - Business logic layer 69 + 4. `internal/core/comments/view_models.go` - API response types 70 + 5. `internal/api/handlers/comments/get_comments.go` - HTTP handler 71 + 6. `internal/api/handlers/comments/errors.go` - Error handling utilities 72 + 7. `internal/api/handlers/comments/middleware.go` - Auth middleware 73 + 8. `internal/api/handlers/comments/service_adapter.go` - Service layer adapter 74 + 9. `tests/integration/comment_query_test.go` - Integration tests 75 + 76 + **Files modified (7):** 77 + 1. `internal/db/postgres/comment_repo.go` - Added query methods (~450 lines), fixed INNER→LEFT JOIN, fixed window function SQL 78 + 2. `internal/core/comments/interfaces.go` - Added service interface 79 + 3. `internal/core/comments/comment.go` - Added CommenterHandle field 80 + 4. `internal/core/comments/errors.go` - Added IsValidationError helper 81 + 5. `cmd/server/main.go` - Wired up routes and service with all repositories 82 + 6. `tests/integration/comment_query_test.go` - Updated test helpers for new service signature 83 + 7. `docs/COMMENT_SYSTEM_IMPLEMENTATION.md` - This document 84 + 85 + **Total new code:** ~2,400 lines 86 + 87 + **Test coverage:** 88 + - 11 integration test scenarios covering: 89 + - Basic fetch, nested replies, depth limits 90 + - Hot/top/new sorting algorithms 91 + - Pagination with cursor stability 92 + - Empty threads, deleted comments 93 + - Invalid input handling 94 + - HTTP handler end-to-end 95 + - Repository layer tested (hot ranking formula, pagination) 96 + - Service layer tested (threading, depth limits) 97 + - Handler tested (input validation, error cases) 98 + - All tests passing ✅ 99 + 100 + ### 🔒 Production Hardening (PR Review Fixes - November 5, 2025) 101 + 102 + After initial implementation, a thorough PR review identified several critical issues that were addressed before production deployment: 103 + 104 + #### Critical Issues Fixed 105 + 106 + **1. N+1 Query Problem (99.7% reduction in queries)** 107 + - **Problem:** Nested reply loading made separate DB queries for each comment's children 108 + - **Impact:** Could execute 1,551 queries for a post with 50 comments at depth 3 109 + - **Solution:** Implemented batch loading with PostgreSQL window functions 110 + - Added `ListByParentsBatch()` method using `ROW_NUMBER() OVER (PARTITION BY parent_uri)` 111 + - Refactored `buildThreadViews()` to collect parent URIs per level and fetch in one query 112 + - **Result:** Reduced from 1,551 queries → 4 queries (1 per depth level) 113 + - **Files:** `internal/core/comments/interfaces.go`, `internal/db/postgres/comment_repo.go`, `internal/core/comments/comment_service.go` 114 + 115 + **2. Post Not Found Returns 500 Instead of 404** 116 + - **Problem:** When fetching comments for non-existent post, service returned wrapped `posts.ErrNotFound` which handler didn't recognize 117 + - **Impact:** Clients got HTTP 500 instead of proper HTTP 404 118 + - **Solution:** Added error translation in service layer 119 + ```go 120 + if posts.IsNotFound(err) { 121 + return nil, ErrRootNotFound // Recognized by comments.IsNotFound() 122 + } 123 + ``` 124 + - **File:** `internal/core/comments/comment_service.go:68-72` 125 + 126 + #### Important Issues Fixed 127 + 128 + **3. Missing Endpoint-Specific Rate Limiting** 129 + - **Problem:** Comment queries with deep nesting expensive but only protected by global 100 req/min limit 130 + - **Solution:** Added dedicated rate limiter at 20 req/min for comment endpoint 131 + - **File:** `cmd/server/main.go:429-439` 132 + 133 + **4. Unbounded Cursor Size (DoS Vector)** 134 + - **Problem:** No validation before base64 decoding - attacker could send massive cursor string 135 + - **Solution:** Added 1024-byte max size check before decoding 136 + - **File:** `internal/db/postgres/comment_repo.go:547-551` 137 + 138 + **5. Missing Query Timeout** 139 + - **Problem:** Deep nested queries could run indefinitely 140 + - **Solution:** Added 10-second context timeout to `GetComments()` 141 + - **File:** `internal/core/comments/comment_service.go:62-64` 142 + 143 + **6. Post View Not Populated (P0 Blocker)** 144 + - **Problem:** Lexicon marked `post` field as required but response always returned `null` 145 + - **Impact:** Violated schema contract, would break client deserialization 146 + - **Solution:** 147 + - Updated service to accept `posts.Repository` instead of `interface{}` 148 + - Added `buildPostView()` method to construct post views with author/community/stats 149 + - Fetch post before returning response 150 + - **Files:** `internal/core/comments/comment_service.go:33-36`, `:66-73`, `:224-274` 151 + 152 + **7. Missing Record Fields (P0 Blocker)** 153 + - **Problem:** Both `postView.record` and `commentView.record` fields were null despite lexicon marking them as required 154 + - **Impact:** Violated lexicon contract, would break strict client deserialization 155 + - **Solution:** 156 + - Added `buildPostRecord()` method to construct minimal PostRecord from Post entity 157 + - Added `buildCommentRecord()` method to construct minimal CommentRecord from Comment entity 158 + - Both methods populate required fields (type, reply refs, content, timestamps) 159 + - Added TODOs for Phase 2C to unmarshal JSON fields (embed, facets, labels) 160 + - **Files:** `internal/core/comments/comment_service.go:260-288`, `:366-386` 161 + 162 + **8. Handle/Name Format Violations (P0 & Important)** 163 + - **Problem:** 164 + - `postView.author.handle` contained DID instead of proper handle (violates `format:"handle"`) 165 + - `postView.community.name` contained DID instead of community name 166 + - **Impact:** Lexicon format constraints violated, poor UX showing DIDs instead of readable names 167 + - **Solution:** 168 + - Added `users.UserRepository` to service for author handle hydration 169 + - Added `communities.Repository` to service for community name hydration 170 + - Updated `buildPostView()` to fetch user and community records with DID fallback 171 + - Log warnings for missing records but don't fail entire request 172 + - **Files:** `internal/core/comments/comment_service.go:34-37`, `:292-325`, `cmd/server/main.go:297` 173 + 174 + **9. Data Loss from INNER JOIN (P1 Critical)** 175 + - **Problem:** Three query methods used `INNER JOIN users` which dropped comments when user not indexed yet 176 + - **Impact:** New user's first comments would disappear until user consumer caught up (violates out-of-order design) 177 + - **Solution:** 178 + - Changed `INNER JOIN users` → `LEFT JOIN users` in all three methods 179 + - Added `COALESCE(u.handle, c.commenter_did)` to gracefully fall back to DID 180 + - Preserves all comments while still hydrating handles when available 181 + - **Files:** `internal/db/postgres/comment_repo.go:396`, `:407`, `:415`, `:694-706`, `:761-836` 182 + 183 + **10. Window Function SQL Bug (P0 Critical)** 184 + - **Problem:** `ListByParentsBatch` used `ORDER BY hot_rank DESC` in window function, but PostgreSQL doesn't allow SELECT aliases in window ORDER BY 185 + - **Impact:** SQL error "column hot_rank does not exist" caused silent failure, dropping ALL nested replies in hot sort mode 186 + - **Solution:** 187 + - Created separate `windowOrderBy` variable that inlines full hot_rank formula 188 + - PostgreSQL evaluates window ORDER BY before SELECT, so must use full expression 189 + - Hot sort now works correctly with nested replies 190 + - **Files:** `internal/db/postgres/comment_repo.go:776`, `:808` 191 + - **Critical Note:** This affected default sorting mode (hot) and would have broken production UX 192 + 193 + #### Documentation Added 194 + 195 + **11. Hot Rank Caching Strategy** 196 + - Documented when and how to implement cached hot rank column 197 + - Specified observability metrics to monitor (p95 latency, CPU usage) 198 + - Documented trade-offs between cached vs on-demand computation 199 + 200 + **Test Coverage:** 201 + - All fixes verified with existing integration test suite 202 + - Added test cases for error handling scenarios 203 + - All integration tests passing (comment_query_test.go: 11 tests) 204 + 205 + **Rationale for phased approach:** 206 + 1. **Separation of concerns**: Indexing and querying are distinct responsibilities 207 + 2. **Testability**: Phase 1 can be fully tested without API layer 208 + 3. **Incremental delivery**: Indexing can run in production while API is developed 209 + 4. **Scope management**: Prevents feature creep and allows focused code review 210 + 211 + --- 212 + 213 + ## Hot Ranking Algorithm (Lemmy-Based) 214 + 215 + ### Formula 216 + 217 + ```sql 218 + log(greatest(2, score + 2)) / 219 + power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8) 220 + ``` 221 + 222 + ### Explanation 223 + 224 + **Components:** 225 + - `greatest(2, score + 2)`: Ensures log input never goes below 2 226 + - Prevents negative log values for heavily downvoted comments 227 + - Score of -5 → log(2), same as score of 0 228 + - Prevents brigading from creating "anti-viral" comments 229 + 230 + - `power(..., 1.8)`: Time decay exponent 231 + - Higher than posts (1.5) for faster comment aging 232 + - Comments should be fresher than posts 233 + 234 + - `+ 2` offsets: Prevent divide-by-zero for very new comments 235 + 236 + **Behavior:** 237 + - High score + old = lower rank (content ages naturally) 238 + - Low score + new = higher rank (fresh content gets visibility) 239 + - Negative scores don't break the formula (bounded at log(2)) 240 + 241 + ### Sort Modes 242 + 243 + **Hot (default):** 244 + ```sql 245 + ORDER BY hot_rank DESC, score DESC, created_at DESC 246 + ``` 247 + 248 + **Top (with timeframe):** 249 + ```sql 250 + WHERE created_at >= NOW() - INTERVAL '1 day' 251 + ORDER BY score DESC, created_at DESC 252 + ``` 253 + 254 + **New (chronological):** 255 + ```sql 256 + ORDER BY created_at DESC 257 + ``` 258 + 259 + ### Path-Based Ordering 260 + 261 + Comments are ordered within their tree level: 262 + ```sql 263 + ORDER BY 264 + path ASC, -- Maintains parent-child structure 265 + hot_rank DESC, -- Sorts siblings by rank 266 + score DESC, -- Tiebreaker 267 + created_at DESC -- Final tiebreaker 268 + ``` 269 + 270 + **Result:** Siblings compete with siblings, but children never outrank their parent. 271 + 272 + --- 273 + 274 + ## Architecture 275 + 276 + ### Data Flow 277 + 278 + ``` 279 + Client → User's PDS → Jetstream Firehose → Comment Consumer → PostgreSQL AppView 280 + 281 + Atomic updates to parent counts 282 + (posts.comment_count OR comments.reply_count) 283 + ``` 284 + 285 + ### Key Design Principles 286 + 287 + 1. **User-Owned Records**: Comments live in user repositories (like votes), not community repositories (like posts) 288 + 2. **atProto Native**: Uses `com.atproto.repo.createRecord/updateRecord/deleteRecord` 289 + 3. **Threading via Strong References**: Root + parent system allows unlimited nesting depth 290 + 4. **Out-of-Order Indexing**: No foreign key constraints to allow Jetstream events to arrive in any order 291 + 5. **Idempotent Operations**: Safe for Jetstream replays and duplicate events 292 + 6. **Atomic Count Updates**: Database transactions ensure consistency 293 + 7. **Soft Deletes**: Preserves thread structure when comments are deleted 294 + 295 + --- 296 + 297 + ## Implementation Details 298 + 299 + ### 1. Lexicon Definition 300 + 301 + **Location:** `internal/atproto/lexicon/social/coves/feed/comment.json` 302 + 303 + The lexicon was already defined and follows atProto best practices: 304 + 305 + ```json 306 + { 307 + "lexicon": 1, 308 + "id": "social.coves.feed.comment", 309 + "defs": { 310 + "main": { 311 + "type": "record", 312 + "key": "tid", 313 + "required": ["reply", "content", "createdAt"], 314 + "properties": { 315 + "reply": { 316 + "type": "ref", 317 + "ref": "#replyRef", 318 + "description": "Reference to the post and parent being replied to" 319 + }, 320 + "content": { 321 + "type": "string", 322 + "maxGraphemes": 3000, 323 + "maxLength": 30000 324 + }, 325 + "facets": { /* Rich text annotations */ }, 326 + "embed": { /* Images, quoted posts */ }, 327 + "langs": { /* ISO 639-1 language codes */ }, 328 + "labels": { /* Self-applied content labels */ }, 329 + "createdAt": { /* RFC3339 timestamp */ } 330 + } 331 + }, 332 + "replyRef": { 333 + "required": ["root", "parent"], 334 + "properties": { 335 + "root": { 336 + "type": "ref", 337 + "ref": "com.atproto.repo.strongRef", 338 + "description": "Strong reference to the original post" 339 + }, 340 + "parent": { 341 + "type": "ref", 342 + "ref": "com.atproto.repo.strongRef", 343 + "description": "Strong reference to immediate parent (post or comment)" 344 + } 345 + } 346 + } 347 + } 348 + } 349 + ``` 350 + 351 + **Threading Model:** 352 + - `root`: Always points to the original post that started the thread 353 + - `parent`: Points to the immediate parent (can be a post or another comment) 354 + - This enables unlimited nested threading while maintaining the root reference 355 + 356 + --- 357 + 358 + ### 2. Database Schema 359 + 360 + **Migration:** `internal/db/migrations/016_create_comments_table.sql` 361 + 362 + ```sql 363 + CREATE TABLE comments ( 364 + id BIGSERIAL PRIMARY KEY, 365 + uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.feed.comment/rkey) 366 + cid TEXT NOT NULL, -- Content ID 367 + rkey TEXT NOT NULL, -- Record key (TID) 368 + commenter_did TEXT NOT NULL, -- User who commented (from AT-URI repo field) 369 + 370 + -- Threading structure (reply references) 371 + root_uri TEXT NOT NULL, -- Strong reference to original post 372 + root_cid TEXT NOT NULL, -- CID of root post (version pinning) 373 + parent_uri TEXT NOT NULL, -- Strong reference to immediate parent 374 + parent_cid TEXT NOT NULL, -- CID of parent (version pinning) 375 + 376 + -- Content 377 + content TEXT NOT NULL, -- Comment text (max 3000 graphemes, 30000 bytes) 378 + content_facets JSONB, -- Rich text facets 379 + embed JSONB, -- Embedded content (images, quoted posts) 380 + content_labels JSONB, -- Self-applied labels (com.atproto.label.defs#selfLabels) 381 + langs TEXT[], -- Languages (ISO 639-1, max 3) 382 + 383 + -- Timestamps 384 + created_at TIMESTAMPTZ NOT NULL, -- Commenter's timestamp from record 385 + indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 386 + deleted_at TIMESTAMPTZ, -- Soft delete 387 + 388 + -- Stats (denormalized for performance) 389 + upvote_count INT NOT NULL DEFAULT 0, -- Comments CAN be voted on 390 + downvote_count INT NOT NULL DEFAULT 0, 391 + score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count 392 + reply_count INT NOT NULL DEFAULT 0 -- Number of direct replies 393 + ); 394 + ``` 395 + 396 + **Key Indexes:** 397 + ```sql 398 + -- Threading queries (most important for UX) 399 + CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC) 400 + WHERE deleted_at IS NULL; 401 + CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC) 402 + WHERE deleted_at IS NULL; 403 + CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC) 404 + WHERE deleted_at IS NULL; 405 + 406 + -- User queries 407 + CREATE INDEX idx_comments_commenter ON comments(commenter_did, created_at DESC); 408 + 409 + -- Vote targeting 410 + CREATE INDEX idx_comments_uri_active ON comments(uri) 411 + WHERE deleted_at IS NULL; 412 + ``` 413 + 414 + **Design Decisions:** 415 + - **No FK on `commenter_did`**: Allows out-of-order Jetstream indexing (comment events may arrive before user events) 416 + - **Soft delete pattern**: `deleted_at IS NULL` in indexes for performance 417 + - **Vote counts included**: The vote lexicon explicitly allows voting on comments (not just posts) 418 + - **StrongRef with CID**: Version pinning prevents confusion when parent content changes 419 + 420 + --- 421 + 422 + ### 3. Domain Layer 423 + 424 + #### Comment Entity 425 + **File:** `internal/core/comments/comment.go` 426 + 427 + ```go 428 + type Comment struct { 429 + ID int64 430 + URI string 431 + CID string 432 + RKey string 433 + CommenterDID string 434 + 435 + // Threading 436 + RootURI string 437 + RootCID string 438 + ParentURI string 439 + ParentCID string 440 + 441 + // Content 442 + Content string 443 + ContentFacets *string 444 + Embed *string 445 + ContentLabels *string 446 + Langs []string 447 + 448 + // Timestamps 449 + CreatedAt time.Time 450 + IndexedAt time.Time 451 + DeletedAt *time.Time 452 + 453 + // Stats 454 + UpvoteCount int 455 + DownvoteCount int 456 + Score int 457 + ReplyCount int 458 + } 459 + ``` 460 + 461 + #### Repository Interface 462 + **File:** `internal/core/comments/interfaces.go` 463 + 464 + ```go 465 + type Repository interface { 466 + Create(ctx context.Context, comment *Comment) error 467 + Update(ctx context.Context, comment *Comment) error 468 + GetByURI(ctx context.Context, uri string) (*Comment, error) 469 + Delete(ctx context.Context, uri string) error 470 + 471 + // Threading queries 472 + ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error) 473 + ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*Comment, error) 474 + CountByParent(ctx context.Context, parentURI string) (int, error) 475 + 476 + // User queries 477 + ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) 478 + } 479 + ``` 480 + 481 + #### Error Types 482 + **File:** `internal/core/comments/errors.go` 483 + 484 + Standard error types following the vote system pattern, with helper functions `IsNotFound()` and `IsConflict()`. 485 + 486 + --- 487 + 488 + ### 4. Repository Implementation 489 + 490 + **File:** `internal/db/postgres/comment_repo.go` 491 + 492 + #### Idempotent Create Pattern 493 + ```go 494 + func (r *postgresCommentRepo) Create(ctx context.Context, comment *Comment) error { 495 + query := ` 496 + INSERT INTO comments (...) 497 + VALUES (...) 498 + ON CONFLICT (uri) DO NOTHING 499 + RETURNING id, indexed_at 500 + ` 501 + 502 + err := r.db.QueryRowContext(ctx, query, ...).Scan(&comment.ID, &comment.IndexedAt) 503 + 504 + // ON CONFLICT DO NOTHING returns no rows if duplicate 505 + if err == sql.ErrNoRows { 506 + return nil // Already exists - OK for Jetstream replays 507 + } 508 + 509 + return err 510 + } 511 + ``` 512 + 513 + #### Update Preserving Vote Counts 514 + ```go 515 + func (r *postgresCommentRepo) Update(ctx context.Context, comment *Comment) error { 516 + query := ` 517 + UPDATE comments 518 + SET cid = $1, content = $2, content_facets = $3, 519 + embed = $4, content_labels = $5, langs = $6 520 + WHERE uri = $7 AND deleted_at IS NULL 521 + RETURNING id, indexed_at, created_at, 522 + upvote_count, downvote_count, score, reply_count 523 + ` 524 + 525 + // Vote counts and created_at are preserved (not in SET clause) 526 + err := r.db.QueryRowContext(ctx, query, ...).Scan(...) 527 + return err 528 + } 529 + ``` 530 + 531 + #### Soft Delete 532 + ```go 533 + func (r *postgresCommentRepo) Delete(ctx context.Context, uri string) error { 534 + query := ` 535 + UPDATE comments 536 + SET deleted_at = NOW() 537 + WHERE uri = $1 AND deleted_at IS NULL 538 + ` 539 + 540 + result, err := r.db.ExecContext(ctx, query, uri) 541 + // Idempotent: Returns success even if already deleted 542 + return err 543 + } 544 + ``` 545 + 546 + --- 547 + 548 + ### 5. Jetstream Consumer 549 + 550 + **File:** `internal/atproto/jetstream/comment_consumer.go` 551 + 552 + #### Event Handler 553 + ```go 554 + func (c *CommentEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error { 555 + if event.Kind != "commit" || event.Commit == nil { 556 + return nil 557 + } 558 + 559 + if event.Commit.Collection == "social.coves.feed.comment" { 560 + switch event.Commit.Operation { 561 + case "create": 562 + return c.createComment(ctx, event.Did, commit) 563 + case "update": 564 + return c.updateComment(ctx, event.Did, commit) 565 + case "delete": 566 + return c.deleteComment(ctx, event.Did, commit) 567 + } 568 + } 569 + 570 + return nil 571 + } 572 + ``` 573 + 574 + #### Atomic Count Updates 575 + ```go 576 + func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx, comment *Comment) error { 577 + tx, _ := c.db.BeginTx(ctx, nil) 578 + defer tx.Rollback() 579 + 580 + // 1. Insert comment (idempotent) 581 + err = tx.QueryRowContext(ctx, ` 582 + INSERT INTO comments (...) VALUES (...) 583 + ON CONFLICT (uri) DO NOTHING 584 + RETURNING id 585 + `).Scan(&commentID) 586 + 587 + if err == sql.ErrNoRows { 588 + tx.Commit() 589 + return nil // Already indexed 590 + } 591 + 592 + // 2. Update parent counts atomically 593 + // Try posts table first 594 + tx.ExecContext(ctx, ` 595 + UPDATE posts 596 + SET comment_count = comment_count + 1 597 + WHERE uri = $1 AND deleted_at IS NULL 598 + `, comment.ParentURI) 599 + 600 + // If no post updated, parent is probably a comment 601 + tx.ExecContext(ctx, ` 602 + UPDATE comments 603 + SET reply_count = reply_count + 1 604 + WHERE uri = $1 AND deleted_at IS NULL 605 + `, comment.ParentURI) 606 + 607 + return tx.Commit() 608 + } 609 + ``` 610 + 611 + #### Security Validation 612 + ```go 613 + func (c *CommentEventConsumer) validateCommentEvent(ctx, repoDID string, comment *CommentRecord) error { 614 + // Comments MUST come from user repositories (repo owner = commenter DID) 615 + if !strings.HasPrefix(repoDID, "did:") { 616 + return fmt.Errorf("invalid commenter DID format: %s", repoDID) 617 + } 618 + 619 + // Content is required 620 + if comment.Content == "" { 621 + return fmt.Errorf("comment content is required") 622 + } 623 + 624 + // Reply references must have both URI and CID 625 + if comment.Reply.Root.URI == "" || comment.Reply.Root.CID == "" { 626 + return fmt.Errorf("invalid root reference: must have both URI and CID") 627 + } 628 + 629 + if comment.Reply.Parent.URI == "" || comment.Reply.Parent.CID == "" { 630 + return fmt.Errorf("invalid parent reference: must have both URI and CID") 631 + } 632 + 633 + return nil 634 + } 635 + ``` 636 + 637 + **Security Note:** We do NOT verify that the user exists in the AppView because: 638 + 1. Comment events may arrive before user events in Jetstream (race condition) 639 + 2. The comment came from the user's PDS repository (authenticated by PDS) 640 + 3. No database FK constraint allows out-of-order indexing 641 + 4. Orphaned comments (from never-indexed users) are harmless 642 + 643 + --- 644 + 645 + ### 6. WebSocket Connector 646 + 647 + **File:** `internal/atproto/jetstream/comment_jetstream_connector.go` 648 + 649 + Follows the standard Jetstream connector pattern with: 650 + - Auto-reconnect on errors (5-second retry) 651 + - Ping/pong keepalive (30-second ping, 60-second read deadline) 652 + - Graceful shutdown via context cancellation 653 + - Subscribes to: `wantedCollections=social.coves.feed.comment` 654 + 655 + --- 656 + 657 + ### 7. Server Integration 658 + 659 + **File:** `cmd/server/main.go` (lines 289-396) 660 + 661 + ```go 662 + // Initialize comment repository 663 + commentRepo := postgresRepo.NewCommentRepository(db) 664 + log.Println("✅ Comment repository initialized (Jetstream indexing only)") 665 + 666 + // Start Jetstream consumer for comments 667 + commentJetstreamURL := os.Getenv("COMMENT_JETSTREAM_URL") 668 + if commentJetstreamURL == "" { 669 + commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment" 670 + } 671 + 672 + commentEventConsumer := jetstream.NewCommentEventConsumer(commentRepo, db) 673 + commentJetstreamConnector := jetstream.NewCommentJetstreamConnector(commentEventConsumer, commentJetstreamURL) 674 + 675 + go func() { 676 + if startErr := commentJetstreamConnector.Start(ctx); startErr != nil { 677 + log.Printf("Comment Jetstream consumer stopped: %v", startErr) 678 + } 679 + }() 680 + 681 + log.Printf("Started Jetstream comment consumer: %s", commentJetstreamURL) 682 + log.Println(" - Indexing: social.coves.feed.comment CREATE/UPDATE/DELETE operations") 683 + log.Println(" - Updating: Post comment counts and comment reply counts atomically") 684 + ``` 685 + 686 + --- 687 + 688 + ## Testing 689 + 690 + ### Test Suite 691 + 692 + **File:** `tests/integration/comment_consumer_test.go` 693 + 694 + **Test Coverage:** 6 test suites, 18 test cases, **100% passing** 695 + 696 + #### 1. TestCommentConsumer_CreateComment 697 + - ✅ Create comment on post 698 + - ✅ Verify comment is indexed correctly 699 + - ✅ Verify post comment count is incremented 700 + - ✅ Idempotent create - duplicate events don't double-count 701 + 702 + #### 2. TestCommentConsumer_Threading 703 + - ✅ Create first-level comment (reply to post) 704 + - ✅ Create second-level comment (reply to comment) 705 + - ✅ Verify both comments have same root (original post) 706 + - ✅ Verify parent relationships are correct 707 + - ✅ Verify reply counts are updated 708 + - ✅ Query all comments by root (flat list) 709 + - ✅ Query direct replies to post 710 + - ✅ Query direct replies to comment 711 + 712 + #### 3. TestCommentConsumer_UpdateComment 713 + - ✅ Create comment with initial content 714 + - ✅ Manually set vote counts to simulate votes 715 + - ✅ Update comment content 716 + - ✅ Verify content is updated 717 + - ✅ Verify CID is updated 718 + - ✅ **Verify vote counts are preserved** 719 + - ✅ **Verify created_at is preserved** 720 + 721 + #### 4. TestCommentConsumer_DeleteComment 722 + - ✅ Create comment 723 + - ✅ Delete comment (soft delete) 724 + - ✅ Verify deleted_at is set 725 + - ✅ Verify post comment count is decremented 726 + - ✅ Idempotent delete - duplicate deletes don't double-decrement 727 + 728 + #### 5. TestCommentConsumer_SecurityValidation 729 + - ✅ Reject comment with empty content 730 + - ✅ Reject comment with invalid root reference (missing URI) 731 + - ✅ Reject comment with invalid parent reference (missing CID) 732 + - ✅ Reject comment with invalid DID format 733 + 734 + #### 6. TestCommentRepository_Queries 735 + - ✅ ListByRoot returns all comments in thread (4 comments) 736 + - ✅ ListByParent returns direct replies to post (2 comments) 737 + - ✅ ListByParent returns direct replies to comment (2 comments) 738 + - ✅ CountByParent returns correct counts 739 + - ✅ ListByCommenter returns all user's comments 740 + 741 + ### Test Results 742 + 743 + ``` 744 + === Test Summary === 745 + PASS: TestCommentConsumer_CreateComment (0.02s) 746 + PASS: TestCommentConsumer_Threading (0.02s) 747 + PASS: TestCommentConsumer_UpdateComment (0.02s) 748 + PASS: TestCommentConsumer_DeleteComment (0.02s) 749 + PASS: TestCommentConsumer_SecurityValidation (0.01s) 750 + PASS: TestCommentRepository_Queries (0.02s) 751 + 752 + ✅ ALL 18 TESTS PASS 753 + Total time: 0.115s 754 + ``` 755 + 756 + --- 757 + 758 + ## Key Features 759 + 760 + ### ✅ Comments ARE Votable 761 + The vote lexicon explicitly states: *"Record declaring a vote (upvote or downvote) on a **post or comment**"* 762 + 763 + Comments include full vote tracking: 764 + - `upvote_count` 765 + - `downvote_count` 766 + - `score` (calculated as upvote_count - downvote_count) 767 + 768 + ### ✅ Comments ARE Editable 769 + Unlike votes (which are immutable), comments support UPDATE operations: 770 + - Content, facets, embed, and labels can be updated 771 + - Vote counts and created_at are preserved 772 + - CID is updated to reflect new version 773 + 774 + ### ✅ Threading Support 775 + Unlimited nesting depth via root + parent system: 776 + - Every comment knows its root post 777 + - Every comment knows its immediate parent 778 + - Easy to query entire threads or direct replies 779 + - Soft deletes preserve thread structure 780 + 781 + ### ✅ Out-of-Order Indexing 782 + No foreign key constraints allow events to arrive in any order: 783 + - Comment events may arrive before user events 784 + - Comment events may arrive before post events 785 + - All operations are idempotent 786 + - Safe for Jetstream replays 787 + 788 + ### ✅ Atomic Consistency 789 + Database transactions ensure counts are always accurate: 790 + - Comment creation increments parent count 791 + - Comment deletion decrements parent count 792 + - No race conditions 793 + - No orphaned counts 794 + 795 + --- 796 + 797 + ## Implementation Statistics 798 + 799 + ### Phase 1 - Indexing Infrastructure 800 + 801 + **Files Created: 8** 802 + 1. `internal/db/migrations/016_create_comments_table.sql` - 60 lines 803 + 2. `internal/core/comments/comment.go` - 80 lines 804 + 3. `internal/core/comments/interfaces.go` - 45 lines 805 + 4. `internal/core/comments/errors.go` - 40 lines 806 + 5. `internal/db/postgres/comment_repo.go` - 340 lines 807 + 6. `internal/atproto/jetstream/comment_consumer.go` - 530 lines 808 + 7. `internal/atproto/jetstream/comment_jetstream_connector.go` - 130 lines 809 + 8. `tests/integration/comment_consumer_test.go` - 930 lines 810 + 811 + **Files Modified: 1** 812 + 1. `cmd/server/main.go` - Added 20 lines for Jetstream consumer 813 + 814 + **Phase 1 Total:** ~2,175 lines 815 + 816 + ### Phase 2A - Query API 817 + 818 + **Files Created: 9** (listed above in Phase 2A section) 819 + 820 + **Files Modified: 6** (listed above in Phase 2A section) 821 + 822 + **Phase 2A Total:** ~2,400 lines 823 + 824 + ### Combined Total: ~4,575 lines 825 + 826 + --- 827 + 828 + ## Reference Pattern: Vote System 829 + 830 + The comment implementation closely follows the vote system pattern: 831 + 832 + | Aspect | Votes | Comments | 833 + |--------|-------|----------| 834 + | **Location** | User repositories | User repositories | 835 + | **Lexicon** | `social.coves.feed.vote` | `social.coves.feed.comment` | 836 + | **Operations** | CREATE, DELETE | CREATE, UPDATE, DELETE | 837 + | **Mutability** | Immutable | Editable | 838 + | **Foreign Keys** | None (out-of-order indexing) | None (out-of-order indexing) | 839 + | **Delete Pattern** | Soft delete | Soft delete | 840 + | **Idempotency** | ON CONFLICT DO NOTHING | ON CONFLICT DO NOTHING | 841 + | **Count Updates** | Atomic transaction | Atomic transaction | 842 + | **Security** | PDS authentication | PDS authentication | 843 + 844 + --- 845 + 846 + ## Future Phases 847 + 848 + ### 📋 Phase 2B: Vote Integration (Planned) 849 + 850 + **Scope:** 851 + - Update vote consumer to handle comment votes 852 + - Integrate `GetVoteStateForComments()` in service layer 853 + - Populate viewer.vote and viewer.voteUri in commentView 854 + - Test vote creation on comments end-to-end 855 + - Atomic updates to comments.upvote_count, downvote_count, score 856 + 857 + **Dependencies:** 858 + - Phase 1 indexing (✅ Complete) 859 + - Phase 2A query API (✅ Complete) 860 + - Vote consumer (already exists for posts) 861 + 862 + **Estimated effort:** 2-3 hours 863 + 864 + --- 865 + 866 + ### 📋 Phase 2C: Post/User Integration (Partially Complete) 867 + 868 + **Completed (PR Review):** 869 + - ✅ Integrated post repository in comment service 870 + - ✅ Return postView in getComments response with basic fields 871 + - ✅ Populate post author DID, community DID, stats (upvotes, downvotes, score, comment count) 872 + 873 + **Remaining Work:** 874 + - ❌ Integrate user repository for full AuthorView 875 + - ❌ Add display name and avatar to comment/post authors (currently returns DID as handle) 876 + - ❌ Add community name and avatar (currently returns DID as name) 877 + - ❌ Parse and include original record in commentView 878 + 879 + **Dependencies:** 880 + - Phase 2A query API (✅ Complete) 881 + - Post repository integration (✅ Complete) 882 + - User repository integration (⏳ Pending) 883 + 884 + **Estimated effort for remaining work:** 1-2 hours 885 + 886 + --- 887 + 888 + ### 📋 Phase 3: Advanced Features (Future) 889 + 890 + #### 3A: Distinguished Comments 891 + - Moderator/admin comment flags 892 + - Priority sorting for distinguished comments 893 + - Visual indicators in UI 894 + 895 + #### 3B: Comment Search & Filtering 896 + - Full-text search within threads 897 + - Filter by author, time range, score 898 + - Search across community comments 899 + 900 + #### 3C: Moderation Tools 901 + - Hide/remove comments 902 + - Flag system for user reports 903 + - Moderator queue 904 + - Audit log 905 + 906 + #### 3D: Notifications 907 + - Notify users of replies to their comments 908 + - Notify post authors of new comments 909 + - Mention notifications (@user) 910 + - Customizable notification preferences 911 + 912 + #### 3E: Enhanced Features 913 + - Comment edit history tracking 914 + - Save/bookmark comments 915 + - Sort by "controversial" (high engagement, low score) 916 + - Collapsible comment threads 917 + - User-specific comment history API 918 + - Community-wide comment stats/analytics 919 + 920 + --- 921 + 922 + ### 📋 Phase 4: Namespace Migration (Separate Task) 923 + 924 + **Scope:** 925 + - Migrate existing `social.coves.feed.comment` records to `social.coves.community.comment` 926 + - Update all AT-URIs in database 927 + - Update Jetstream consumer collection filter 928 + - Migration script with rollback capability 929 + - Zero-downtime deployment strategy 930 + 931 + **Note:** Currently out of scope - will be tackled separately when needed. 932 + 933 + --- 934 + 935 + ## Performance Considerations 936 + 937 + ### Database Indexes 938 + 939 + All critical query patterns are indexed: 940 + - **Threading queries**: `idx_comments_root`, `idx_comments_parent` 941 + - **Sorting by score**: `idx_comments_parent_score` 942 + - **User history**: `idx_comments_commenter` 943 + - **Vote targeting**: `idx_comments_uri_active` 944 + 945 + ### Denormalized Counts 946 + 947 + Vote counts and reply counts are denormalized for performance: 948 + - Avoids `COUNT(*)` queries on large datasets 949 + - Updated atomically with comment operations 950 + - Indexed for fast sorting 951 + 952 + ### Pagination Support 953 + 954 + All list queries support limit/offset pagination: 955 + - `ListByRoot(ctx, rootURI, limit, offset)` 956 + - `ListByParent(ctx, parentURI, limit, offset)` 957 + - `ListByCommenter(ctx, commenterDID, limit, offset)` 958 + 959 + ### N+1 Query Prevention 960 + 961 + **Problem Solved:** The initial implementation had a classic N+1 query problem where nested reply loading made separate database queries for each comment's children. For a post with 50 top-level comments and 3 levels of depth, this could result in ~1,551 queries. 962 + 963 + **Solution Implemented:** Batch loading strategy using window functions: 964 + 1. Collect all parent URIs at each depth level 965 + 2. Execute single batch query using `ListByParentsBatch()` with PostgreSQL window functions 966 + 3. Group results by parent URI in memory 967 + 4. Recursively process next level 968 + 969 + **Performance Improvement:** 970 + - Old: 1 + N + (N × M) + (N × M × P) queries per request 971 + - New: 1 query per depth level (max 4 queries for depth 3) 972 + - Example with depth 3, 50 comments: 1,551 queries → 4 queries (99.7% reduction) 973 + 974 + **Implementation Details:** 975 + ```sql 976 + -- Uses ROW_NUMBER() window function to limit per parent efficiently 977 + WITH ranked_comments AS ( 978 + SELECT *, 979 + ROW_NUMBER() OVER ( 980 + PARTITION BY parent_uri 981 + ORDER BY hot_rank DESC 982 + ) as rn 983 + FROM comments 984 + WHERE parent_uri = ANY($1) 985 + ) 986 + SELECT * FROM ranked_comments WHERE rn <= $2 987 + ``` 988 + 989 + ### Hot Rank Caching Strategy 990 + 991 + **Current Implementation:** 992 + Hot rank is computed on-demand for every query using the Lemmy algorithm: 993 + ```sql 994 + log(greatest(2, score + 2)) / 995 + power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8) 996 + ``` 997 + 998 + **Performance Impact:** 999 + - Computed for every comment in every hot-sorted query 1000 + - PostgreSQL handles this efficiently for moderate loads (<1000 comments per post) 1001 + - No noticeable performance degradation in testing 1002 + 1003 + **Future Optimization (if needed):** 1004 + 1005 + If hot rank computation becomes a bottleneck at scale: 1006 + 1007 + 1. **Add cached column:** 1008 + ```sql 1009 + ALTER TABLE comments ADD COLUMN hot_rank_cached NUMERIC; 1010 + CREATE INDEX idx_comments_parent_hot_rank_cached 1011 + ON comments(parent_uri, hot_rank_cached DESC) 1012 + WHERE deleted_at IS NULL; 1013 + ``` 1014 + 1015 + 2. **Background recomputation job:** 1016 + ```go 1017 + // Run every 5-15 minutes 1018 + func (j *HotRankJob) UpdateHotRanks(ctx context.Context) error { 1019 + query := ` 1020 + UPDATE comments 1021 + SET hot_rank_cached = log(greatest(2, score + 2)) / 1022 + power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8) 1023 + WHERE deleted_at IS NULL 1024 + ` 1025 + _, err := j.db.ExecContext(ctx, query) 1026 + return err 1027 + } 1028 + ``` 1029 + 1030 + 3. **Use cached value in queries:** 1031 + ```sql 1032 + SELECT * FROM comments 1033 + WHERE parent_uri = $1 1034 + ORDER BY hot_rank_cached DESC, score DESC 1035 + ``` 1036 + 1037 + **When to implement:** 1038 + - Monitor query performance in production 1039 + - If p95 query latency > 200ms for hot-sorted queries 1040 + - If database CPU usage from hot rank computation > 20% 1041 + - Only optimize if measurements show actual bottleneck 1042 + 1043 + **Trade-offs:** 1044 + - **Cached approach:** Faster queries, but ranks update every 5-15 minutes (slightly stale) 1045 + - **On-demand approach:** Always fresh ranks, slightly higher query cost 1046 + - For comment discussions, 5-15 minute staleness is acceptable (comments age slowly) 1047 + 1048 + --- 1049 + 1050 + ## Conclusion 1051 + 1052 + The comment system has successfully completed **Phase 1 (Indexing)** and **Phase 2A (Query API)**, providing a production-ready threaded discussion system for Coves: 1053 + 1054 + ✅ **Phase 1 Complete**: Full indexing infrastructure with Jetstream consumer 1055 + ✅ **Phase 2A Complete**: Query API with hot ranking, threading, and pagination 1056 + ✅ **Fully Tested**: 30+ integration tests across indexing and query layers 1057 + ✅ **Secure**: Input validation, parameterized queries, optional auth 1058 + ✅ **Scalable**: Indexed queries, denormalized counts, cursor pagination 1059 + ✅ **atProto Native**: User-owned records, Jetstream indexing, Bluesky patterns 1060 + 1061 + **Next milestones:** 1062 + - Phase 2B: Vote integration for comment voting 1063 + - Phase 2C: Post/user integration for complete views 1064 + - Phase 3: Advanced features (moderation, notifications, search) 1065 + 1066 + The implementation provides a solid foundation for building rich threaded discussions in Coves while maintaining compatibility with the broader atProto ecosystem and following established patterns from platforms like Lemmy and Reddit. 1067 + 1068 + --- 1069 + 1070 + ## Appendix: Command Reference 1071 + 1072 + ### Run Tests 1073 + 1074 + **Phase 1 - Indexing Tests:** 1075 + ```bash 1076 + TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \ 1077 + go test -v ./tests/integration/comment_consumer_test.go \ 1078 + ./tests/integration/user_test.go \ 1079 + ./tests/integration/helpers.go \ 1080 + -run "TestCommentConsumer" -timeout 60s 1081 + ``` 1082 + 1083 + **Phase 2A - Query API Tests:** 1084 + ```bash 1085 + TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \ 1086 + go test -v ./tests/integration/comment_query_test.go \ 1087 + ./tests/integration/user_test.go \ 1088 + ./tests/integration/helpers.go \ 1089 + -run "TestCommentQuery" -timeout 120s 1090 + ``` 1091 + 1092 + **All Comment Tests:** 1093 + ```bash 1094 + TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \ 1095 + go test -v ./tests/integration/comment_*.go \ 1096 + ./tests/integration/user_test.go \ 1097 + ./tests/integration/helpers.go \ 1098 + -timeout 120s 1099 + ``` 1100 + 1101 + ### Apply Migration 1102 + ```bash 1103 + GOOSE_DRIVER=postgres \ 1104 + GOOSE_DBSTRING="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \ 1105 + goose -dir internal/db/migrations up 1106 + ``` 1107 + 1108 + ### Build Server 1109 + ```bash 1110 + go build ./cmd/server 1111 + ``` 1112 + 1113 + ### Environment Variables 1114 + ```bash 1115 + # Jetstream URL (optional, defaults to localhost:6008) 1116 + export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment" 1117 + 1118 + # Database URL 1119 + export TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 1120 + ``` 1121 + 1122 + --- 1123 + 1124 + **Last Updated:** November 6, 2025 1125 + **Status:** ✅ Phase 1 & 2A Complete - Production-Ready with All PR Fixes
+44
internal/api/handlers/comments/errors.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/core/comments" 5 + "encoding/json" 6 + "log" 7 + "net/http" 8 + ) 9 + 10 + // errorResponse represents a standardized JSON error response 11 + type errorResponse struct { 12 + Error string `json:"error"` 13 + Message string `json:"message"` 14 + } 15 + 16 + // writeError writes a JSON error response with the given status code 17 + func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 18 + w.Header().Set("Content-Type", "application/json") 19 + w.WriteHeader(statusCode) 20 + if err := json.NewEncoder(w).Encode(errorResponse{ 21 + Error: errorType, 22 + Message: message, 23 + }); err != nil { 24 + log.Printf("Failed to encode error response: %v", err) 25 + } 26 + } 27 + 28 + // handleServiceError maps service-layer errors to HTTP responses 29 + // This follows the error handling pattern from other handlers (post, community) 30 + func handleServiceError(w http.ResponseWriter, err error) { 31 + switch { 32 + case comments.IsNotFound(err): 33 + writeError(w, http.StatusNotFound, "NotFound", err.Error()) 34 + 35 + case comments.IsValidationError(err): 36 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 37 + 38 + default: 39 + // Don't leak internal error details to clients 40 + log.Printf("Unexpected error in comments handler: %v", err) 41 + writeError(w, http.StatusInternalServerError, "InternalServerError", 42 + "An internal error occurred") 43 + } 44 + }
+167
internal/api/handlers/comments/get_comments.go
··· 1 + // Package comments provides HTTP handlers for the comment query API. 2 + // These handlers follow XRPC conventions and integrate with the comments service layer. 3 + package comments 4 + 5 + import ( 6 + "Coves/internal/api/middleware" 7 + "Coves/internal/core/comments" 8 + "encoding/json" 9 + "log" 10 + "net/http" 11 + "strconv" 12 + ) 13 + 14 + // GetCommentsHandler handles comment retrieval for posts 15 + type GetCommentsHandler struct { 16 + service Service 17 + } 18 + 19 + // Service defines the interface for comment business logic 20 + // This will be implemented by the comments service layer in Phase 2 21 + type Service interface { 22 + GetComments(r *http.Request, req *GetCommentsRequest) (*comments.GetCommentsResponse, error) 23 + } 24 + 25 + // GetCommentsRequest represents the query parameters for fetching comments 26 + // Matches social.coves.feed.getComments lexicon input 27 + type GetCommentsRequest struct { 28 + Cursor *string `json:"cursor,omitempty"` 29 + ViewerDID *string `json:"-"` 30 + PostURI string `json:"post"` 31 + Sort string `json:"sort,omitempty"` 32 + Timeframe string `json:"timeframe,omitempty"` 33 + Depth int `json:"depth,omitempty"` 34 + Limit int `json:"limit,omitempty"` 35 + } 36 + 37 + // NewGetCommentsHandler creates a new handler for fetching comments 38 + func NewGetCommentsHandler(service Service) *GetCommentsHandler { 39 + return &GetCommentsHandler{ 40 + service: service, 41 + } 42 + } 43 + 44 + // HandleGetComments handles GET /xrpc/social.coves.feed.getComments 45 + // Retrieves comments on a post with threading support 46 + func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) { 47 + // 1. Only allow GET method 48 + if r.Method != http.MethodGet { 49 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 50 + return 51 + } 52 + 53 + // 2. Parse query parameters 54 + query := r.URL.Query() 55 + post := query.Get("post") 56 + sort := query.Get("sort") 57 + timeframe := query.Get("timeframe") 58 + depthStr := query.Get("depth") 59 + limitStr := query.Get("limit") 60 + cursor := query.Get("cursor") 61 + 62 + // 3. Validate required parameters 63 + if post == "" { 64 + writeError(w, http.StatusBadRequest, "InvalidRequest", "post parameter is required") 65 + return 66 + } 67 + 68 + // 4. Parse and validate depth with default 69 + depth := 10 // Default depth 70 + if depthStr != "" { 71 + parsed, err := strconv.Atoi(depthStr) 72 + if err != nil { 73 + writeError(w, http.StatusBadRequest, "InvalidRequest", "depth must be a valid integer") 74 + return 75 + } 76 + if parsed < 0 { 77 + writeError(w, http.StatusBadRequest, "InvalidRequest", "depth must be non-negative") 78 + return 79 + } 80 + depth = parsed 81 + } 82 + 83 + // 5. Parse and validate limit with default and max 84 + limit := 50 // Default limit 85 + if limitStr != "" { 86 + parsed, err := strconv.Atoi(limitStr) 87 + if err != nil { 88 + writeError(w, http.StatusBadRequest, "InvalidRequest", "limit must be a valid integer") 89 + return 90 + } 91 + if parsed < 1 { 92 + writeError(w, http.StatusBadRequest, "InvalidRequest", "limit must be positive") 93 + return 94 + } 95 + if parsed > 100 { 96 + writeError(w, http.StatusBadRequest, "InvalidRequest", "limit cannot exceed 100") 97 + return 98 + } 99 + limit = parsed 100 + } 101 + 102 + // 6. Validate sort parameter (if provided) 103 + if sort != "" && sort != "hot" && sort != "top" && sort != "new" { 104 + writeError(w, http.StatusBadRequest, "InvalidRequest", 105 + "sort must be one of: hot, top, new") 106 + return 107 + } 108 + 109 + // 7. Validate timeframe parameter (only valid with "top" sort) 110 + if timeframe != "" { 111 + if sort != "top" { 112 + writeError(w, http.StatusBadRequest, "InvalidRequest", 113 + "timeframe can only be used with sort=top") 114 + return 115 + } 116 + validTimeframes := map[string]bool{ 117 + "hour": true, "day": true, "week": true, 118 + "month": true, "year": true, "all": true, 119 + } 120 + if !validTimeframes[timeframe] { 121 + writeError(w, http.StatusBadRequest, "InvalidRequest", 122 + "timeframe must be one of: hour, day, week, month, year, all") 123 + return 124 + } 125 + } 126 + 127 + // 8. Extract viewer DID from context (set by OptionalAuth middleware) 128 + viewerDID := middleware.GetUserDID(r) 129 + var viewerPtr *string 130 + if viewerDID != "" { 131 + viewerPtr = &viewerDID 132 + } 133 + 134 + // 9. Build service request 135 + req := &GetCommentsRequest{ 136 + PostURI: post, 137 + Sort: sort, 138 + Timeframe: timeframe, 139 + Depth: depth, 140 + Limit: limit, 141 + Cursor: ptrOrNil(cursor), 142 + ViewerDID: viewerPtr, 143 + } 144 + 145 + // 10. Call service layer 146 + resp, err := h.service.GetComments(r, req) 147 + if err != nil { 148 + handleServiceError(w, err) 149 + return 150 + } 151 + 152 + // 11. Return JSON response 153 + w.Header().Set("Content-Type", "application/json") 154 + w.WriteHeader(http.StatusOK) 155 + if err := json.NewEncoder(w).Encode(resp); err != nil { 156 + // Log encoding errors but don't return error response (headers already sent) 157 + log.Printf("Failed to encode comments response: %v", err) 158 + } 159 + } 160 + 161 + // ptrOrNil converts an empty string to nil pointer, otherwise returns pointer to string 162 + func ptrOrNil(s string) *string { 163 + if s == "" { 164 + return nil 165 + } 166 + return &s 167 + }
+22
internal/api/handlers/comments/middleware.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "net/http" 6 + ) 7 + 8 + // OptionalAuthMiddleware wraps the existing OptionalAuth middleware from the middleware package. 9 + // This ensures comment handlers can access viewer identity when available, but don't require authentication. 10 + // 11 + // Usage in router setup: 12 + // 13 + // commentHandler := comments.NewGetCommentsHandler(commentService) 14 + // router.Handle("/xrpc/social.coves.feed.getComments", 15 + // comments.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments)) 16 + // 17 + // The middleware extracts the viewer DID from the Authorization header if present and valid, 18 + // making it available via middleware.GetUserDID(r) in the handler. 19 + // If no valid token is present, the request continues as anonymous (empty DID). 20 + func OptionalAuthMiddleware(authMiddleware *middleware.AtProtoAuthMiddleware, next http.HandlerFunc) http.Handler { 21 + return authMiddleware.OptionalAuth(http.HandlerFunc(next)) 22 + }
+37
internal/api/handlers/comments/service_adapter.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/core/comments" 5 + "net/http" 6 + ) 7 + 8 + // ServiceAdapter adapts the core comments.Service to the handler's Service interface 9 + // This bridges the gap between HTTP-layer concerns (http.Request) and domain-layer concerns (context.Context) 10 + type ServiceAdapter struct { 11 + coreService comments.Service 12 + } 13 + 14 + // NewServiceAdapter creates a new service adapter wrapping the core comment service 15 + func NewServiceAdapter(coreService comments.Service) Service { 16 + return &ServiceAdapter{ 17 + coreService: coreService, 18 + } 19 + } 20 + 21 + // GetComments adapts the handler request to the core service request 22 + // Converts handler-specific GetCommentsRequest to core GetCommentsRequest 23 + func (a *ServiceAdapter) GetComments(r *http.Request, req *GetCommentsRequest) (*comments.GetCommentsResponse, error) { 24 + // Convert handler request to core service request 25 + coreReq := &comments.GetCommentsRequest{ 26 + PostURI: req.PostURI, 27 + Sort: req.Sort, 28 + Timeframe: req.Timeframe, 29 + Depth: req.Depth, 30 + Limit: req.Limit, 31 + Cursor: req.Cursor, 32 + ViewerDID: req.ViewerDID, 33 + } 34 + 35 + // Call core service with request context 36 + return a.coreService.GetComments(r.Context(), coreReq) 37 + }
+6 -8
internal/atproto/jetstream/comment_consumer.go
··· 300 300 time.Now(), 301 301 commentID, 302 302 ) 303 - 304 303 if err != nil { 305 304 return fmt.Errorf("failed to resurrect comment: %w", err) 306 305 } ··· 329 328 comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs), 330 329 comment.CreatedAt, time.Now(), 331 330 ).Scan(&commentID) 332 - 333 331 if err != nil { 334 332 return fmt.Errorf("failed to insert comment: %w", err) 335 333 } ··· 593 591 // CommentRecordFromJetstream represents a comment record as received from Jetstream 594 592 // Matches social.coves.feed.comment lexicon 595 593 type CommentRecordFromJetstream struct { 596 - Type string `json:"$type"` 594 + Labels interface{} `json:"labels,omitempty"` 595 + Embed map[string]interface{} `json:"embed,omitempty"` 597 596 Reply ReplyRefFromJetstream `json:"reply"` 597 + Type string `json:"$type"` 598 598 Content string `json:"content"` 599 + CreatedAt string `json:"createdAt"` 599 600 Facets []interface{} `json:"facets,omitempty"` 600 - Embed map[string]interface{} `json:"embed,omitempty"` 601 601 Langs []string `json:"langs,omitempty"` 602 - Labels interface{} `json:"labels,omitempty"` 603 - CreatedAt string `json:"createdAt"` 604 602 } 605 603 606 604 // ReplyRefFromJetstream represents the threading structure ··· 638 636 // Returns nil pointers for empty/nil fields (DRY helper to avoid duplication) 639 637 func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) { 640 638 // Serialize facets if present 641 - if commentRecord.Facets != nil && len(commentRecord.Facets) > 0 { 639 + if len(commentRecord.Facets) > 0 { 642 640 if facetsBytes, err := json.Marshal(commentRecord.Facets); err == nil { 643 641 facetsStr := string(facetsBytes) 644 642 facetsJSON = &facetsStr ··· 646 644 } 647 645 648 646 // Serialize embed if present 649 - if commentRecord.Embed != nil && len(commentRecord.Embed) > 0 { 647 + if len(commentRecord.Embed) > 0 { 650 648 if embedBytes, err := json.Marshal(commentRecord.Embed); err == nil { 651 649 embedStr := string(embedBytes) 652 650 embedJSON = &embedStr
+8 -3
internal/atproto/jetstream/community_consumer.go
··· 19 19 20 20 // CommunityEventConsumer consumes community-related events from Jetstream 21 21 type CommunityEventConsumer struct { 22 - repo communities.Repository // Repository for community operations 23 - identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) } // For resolving handles from DIDs 22 + repo communities.Repository // Repository for community operations 23 + identityResolver interface { 24 + Resolve(context.Context, string) (*identity.Identity, error) 25 + } // For resolving handles from DIDs 24 26 httpClient *http.Client // Shared HTTP client with connection pooling 25 27 didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results 26 28 wellKnownLimiter *rate.Limiter // Rate limiter for .well-known fetches ··· 38 40 // instanceDID: The DID of this Coves instance (for hostedBy verification) 39 41 // skipVerification: Skip did:web verification (for dev mode) 40 42 // identityResolver: Optional resolver for resolving handles from DIDs (can be nil for tests) 41 - func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) }) *CommunityEventConsumer { 43 + func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface { 44 + Resolve(context.Context, string) (*identity.Identity, error) 45 + }, 46 + ) *CommunityEventConsumer { 42 47 // Create bounded LRU cache for DID document verification results 43 48 // Max 1000 entries to prevent unbounded memory growth (PR review feedback) 44 49 // Each entry ~100 bytes → max ~100KB memory overhead
+3 -3
internal/atproto/jetstream/post_consumer.go
··· 15 15 // PostEventConsumer consumes post-related events from Jetstream 16 16 // Currently handles only CREATE operations for social.coves.community.post 17 17 // UPDATE and DELETE handlers will be added when those features are implemented 18 - type PostEventConsumer struct{ 18 + type PostEventConsumer struct { 19 19 postRepo posts.Repository 20 20 communityRepo communities.Repository 21 21 userService users.UserService ··· 200 200 201 201 // PostRecordFromJetstream represents a post record as received from Jetstream 202 202 // Matches the structure written to PDS via social.coves.community.post 203 - type PostRecordFromJetstream struct{ 203 + type PostRecordFromJetstream struct { 204 204 OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 205 205 FederatedFrom interface{} `json:"federatedFrom,omitempty"` 206 206 Location interface{} `json:"location,omitempty"` 207 207 Title *string `json:"title,omitempty"` 208 208 Content *string `json:"content,omitempty"` 209 209 Embed map[string]interface{} `json:"embed,omitempty"` 210 + Labels *posts.SelfLabels `json:"labels,omitempty"` 210 211 Type string `json:"$type"` 211 212 Community string `json:"community"` 212 213 Author string `json:"author"` 213 214 CreatedAt string `json:"createdAt"` 214 215 Facets []interface{} `json:"facets,omitempty"` 215 - Labels *posts.SelfLabels `json:"labels,omitempty"` 216 216 } 217 217 218 218 // parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
+221
internal/atproto/lexicon/social/coves/community/comment/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.community.comment.defs", 4 + "defs": { 5 + "commentView": { 6 + "type": "object", 7 + "description": "Base view for a single comment with voting, stats, and viewer state", 8 + "required": ["uri", "cid", "author", "record", "post", "content", "createdAt", "indexedAt", "stats"], 9 + "properties": { 10 + "uri": { 11 + "type": "string", 12 + "format": "at-uri", 13 + "description": "AT-URI of the comment record" 14 + }, 15 + "cid": { 16 + "type": "string", 17 + "format": "cid", 18 + "description": "CID of the comment record" 19 + }, 20 + "author": { 21 + "type": "ref", 22 + "ref": "social.coves.community.post.get#authorView", 23 + "description": "Comment author information" 24 + }, 25 + "record": { 26 + "type": "unknown", 27 + "description": "The actual comment record verbatim" 28 + }, 29 + "post": { 30 + "type": "ref", 31 + "ref": "#postRef", 32 + "description": "Reference to the parent post" 33 + }, 34 + "parent": { 35 + "type": "ref", 36 + "ref": "#commentRef", 37 + "description": "Reference to parent comment if this is a nested reply" 38 + }, 39 + "content": { 40 + "type": "string", 41 + "description": "Comment text content" 42 + }, 43 + "contentFacets": { 44 + "type": "array", 45 + "description": "Rich text annotations for mentions, links, formatting", 46 + "items": { 47 + "type": "ref", 48 + "ref": "social.coves.richtext.facet" 49 + } 50 + }, 51 + "embed": { 52 + "type": "union", 53 + "description": "Embedded content in the comment (images or quoted post)", 54 + "refs": [ 55 + "social.coves.embed.images#view", 56 + "social.coves.embed.post#view" 57 + ] 58 + }, 59 + "createdAt": { 60 + "type": "string", 61 + "format": "datetime", 62 + "description": "When the comment was created" 63 + }, 64 + "indexedAt": { 65 + "type": "string", 66 + "format": "datetime", 67 + "description": "When this comment was indexed by the AppView" 68 + }, 69 + "stats": { 70 + "type": "ref", 71 + "ref": "#commentStats", 72 + "description": "Comment statistics (votes, replies)" 73 + }, 74 + "viewer": { 75 + "type": "ref", 76 + "ref": "#commentViewerState", 77 + "description": "Viewer-specific state (vote, saved, etc.)" 78 + } 79 + } 80 + }, 81 + "threadViewComment": { 82 + "type": "object", 83 + "description": "Wrapper for threaded comment structure, similar to Bluesky's threadViewPost pattern", 84 + "required": ["comment"], 85 + "properties": { 86 + "comment": { 87 + "type": "ref", 88 + "ref": "#commentView", 89 + "description": "The comment itself" 90 + }, 91 + "replies": { 92 + "type": "array", 93 + "description": "Nested replies to this comment", 94 + "items": { 95 + "type": "union", 96 + "refs": ["#threadViewComment", "#notFoundComment", "#blockedComment"] 97 + } 98 + }, 99 + "hasMore": { 100 + "type": "boolean", 101 + "description": "True if more replies exist but are not included in this response" 102 + } 103 + } 104 + }, 105 + "commentRef": { 106 + "type": "object", 107 + "description": "Reference to a comment record", 108 + "required": ["uri", "cid"], 109 + "properties": { 110 + "uri": { 111 + "type": "string", 112 + "format": "at-uri", 113 + "description": "AT-URI of the comment" 114 + }, 115 + "cid": { 116 + "type": "string", 117 + "format": "cid", 118 + "description": "CID of the comment record" 119 + } 120 + } 121 + }, 122 + "postRef": { 123 + "type": "object", 124 + "description": "Reference to a post record", 125 + "required": ["uri", "cid"], 126 + "properties": { 127 + "uri": { 128 + "type": "string", 129 + "format": "at-uri", 130 + "description": "AT-URI of the post" 131 + }, 132 + "cid": { 133 + "type": "string", 134 + "format": "cid", 135 + "description": "CID of the post record" 136 + } 137 + } 138 + }, 139 + "notFoundComment": { 140 + "type": "object", 141 + "description": "Comment was not found (deleted, never indexed, or invalid URI)", 142 + "required": ["uri", "notFound"], 143 + "properties": { 144 + "uri": { 145 + "type": "string", 146 + "format": "at-uri", 147 + "description": "AT-URI of the missing comment" 148 + }, 149 + "notFound": { 150 + "type": "boolean", 151 + "const": true, 152 + "description": "Always true for not found comments" 153 + } 154 + } 155 + }, 156 + "blockedComment": { 157 + "type": "object", 158 + "description": "Comment is blocked due to viewer blocking author or moderation action", 159 + "required": ["uri", "blocked"], 160 + "properties": { 161 + "uri": { 162 + "type": "string", 163 + "format": "at-uri", 164 + "description": "AT-URI of the blocked comment" 165 + }, 166 + "blocked": { 167 + "type": "boolean", 168 + "const": true, 169 + "description": "Always true for blocked comments" 170 + }, 171 + "blockedBy": { 172 + "type": "string", 173 + "knownValues": ["author", "moderator"], 174 + "description": "What caused the block: viewer blocked author, or comment was removed by moderators" 175 + } 176 + } 177 + }, 178 + "commentStats": { 179 + "type": "object", 180 + "description": "Statistics for a comment", 181 + "required": ["upvotes", "downvotes", "score", "replyCount"], 182 + "properties": { 183 + "upvotes": { 184 + "type": "integer", 185 + "minimum": 0, 186 + "description": "Number of upvotes" 187 + }, 188 + "downvotes": { 189 + "type": "integer", 190 + "minimum": 0, 191 + "description": "Number of downvotes" 192 + }, 193 + "score": { 194 + "type": "integer", 195 + "description": "Calculated score (upvotes - downvotes)" 196 + }, 197 + "replyCount": { 198 + "type": "integer", 199 + "minimum": 0, 200 + "description": "Number of direct replies to this comment" 201 + } 202 + } 203 + }, 204 + "commentViewerState": { 205 + "type": "object", 206 + "description": "Viewer-specific state for a comment", 207 + "properties": { 208 + "vote": { 209 + "type": "string", 210 + "knownValues": ["up", "down"], 211 + "description": "Viewer's vote on this comment" 212 + }, 213 + "voteUri": { 214 + "type": "string", 215 + "format": "at-uri", 216 + "description": "AT-URI of the viewer's vote record" 217 + } 218 + } 219 + } 220 + } 221 + }
+86
internal/atproto/lexicon/social/coves/community/comment/getComments.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.community.comment.getComments", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get comments for a post with threading and sorting support. Supports hot/top/new sorting, configurable nesting depth, and pagination.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["post"], 11 + "properties": { 12 + "post": { 13 + "type": "string", 14 + "format": "at-uri", 15 + "description": "AT-URI of the post to get comments for" 16 + }, 17 + "sort": { 18 + "type": "string", 19 + "default": "hot", 20 + "knownValues": ["hot", "top", "new"], 21 + "description": "Sort order: hot (trending), top (highest score), new (most recent)" 22 + }, 23 + "timeframe": { 24 + "type": "string", 25 + "knownValues": ["hour", "day", "week", "month", "year", "all"], 26 + "description": "Timeframe for 'top' sort. Ignored for other sort types." 27 + }, 28 + "depth": { 29 + "type": "integer", 30 + "default": 10, 31 + "minimum": 0, 32 + "maximum": 100, 33 + "description": "Maximum reply nesting depth to return. 0 returns only top-level comments." 34 + }, 35 + "limit": { 36 + "type": "integer", 37 + "default": 50, 38 + "minimum": 1, 39 + "maximum": 100, 40 + "description": "Maximum number of top-level comments to return per page" 41 + }, 42 + "cursor": { 43 + "type": "string", 44 + "description": "Pagination cursor from previous response" 45 + } 46 + } 47 + }, 48 + "output": { 49 + "encoding": "application/json", 50 + "schema": { 51 + "type": "object", 52 + "required": ["comments", "post"], 53 + "properties": { 54 + "comments": { 55 + "type": "array", 56 + "description": "Top-level comments with nested replies up to requested depth", 57 + "items": { 58 + "type": "ref", 59 + "ref": "social.coves.community.comment.defs#threadViewComment" 60 + } 61 + }, 62 + "post": { 63 + "type": "ref", 64 + "ref": "social.coves.community.post.get#postView", 65 + "description": "The post these comments belong to" 66 + }, 67 + "cursor": { 68 + "type": "string", 69 + "description": "Pagination cursor for fetching next page of top-level comments" 70 + } 71 + } 72 + } 73 + }, 74 + "errors": [ 75 + { 76 + "name": "NotFound", 77 + "description": "Post not found" 78 + }, 79 + { 80 + "name": "InvalidRequest", 81 + "description": "Invalid parameters (malformed URI, invalid sort/timeframe combination, etc.)" 82 + } 83 + ] 84 + } 85 + } 86 + }
+32 -39
internal/core/comments/comment.go
··· 7 7 // Comment represents a comment in the AppView database 8 8 // Comments are indexed from the firehose after being written to user repositories 9 9 type Comment struct { 10 - ID int64 `json:"id" db:"id"` 11 - URI string `json:"uri" db:"uri"` 12 - CID string `json:"cid" db:"cid"` 13 - RKey string `json:"rkey" db:"rkey"` 14 - CommenterDID string `json:"commenterDid" db:"commenter_did"` 15 - 16 - // Threading (reply references) 17 - RootURI string `json:"rootUri" db:"root_uri"` 18 - RootCID string `json:"rootCid" db:"root_cid"` 19 - ParentURI string `json:"parentUri" db:"parent_uri"` 20 - ParentCID string `json:"parentCid" db:"parent_cid"` 21 - 22 - // Content 23 - Content string `json:"content" db:"content"` 24 - ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` 25 - Embed *string `json:"embed,omitempty" db:"embed"` 26 - ContentLabels *string `json:"labels,omitempty" db:"content_labels"` 27 - Langs []string `json:"langs,omitempty" db:"langs"` 28 - 29 - // Timestamps 30 - CreatedAt time.Time `json:"createdAt" db:"created_at"` 31 - IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` 32 - DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` 33 - 34 - // Stats (denormalized for performance) 35 - UpvoteCount int `json:"upvoteCount" db:"upvote_count"` 36 - DownvoteCount int `json:"downvoteCount" db:"downvote_count"` 37 - Score int `json:"score" db:"score"` 38 - ReplyCount int `json:"replyCount" db:"reply_count"` 10 + IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` 11 + CreatedAt time.Time `json:"createdAt" db:"created_at"` 12 + ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` 13 + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` 14 + ContentLabels *string `json:"labels,omitempty" db:"content_labels"` 15 + Embed *string `json:"embed,omitempty" db:"embed"` 16 + CommenterHandle string `json:"commenterHandle,omitempty" db:"-"` 17 + CommenterDID string `json:"commenterDid" db:"commenter_did"` 18 + ParentURI string `json:"parentUri" db:"parent_uri"` 19 + ParentCID string `json:"parentCid" db:"parent_cid"` 20 + Content string `json:"content" db:"content"` 21 + RootURI string `json:"rootUri" db:"root_uri"` 22 + URI string `json:"uri" db:"uri"` 23 + RootCID string `json:"rootCid" db:"root_cid"` 24 + CID string `json:"cid" db:"cid"` 25 + RKey string `json:"rkey" db:"rkey"` 26 + Langs []string `json:"langs,omitempty" db:"langs"` 27 + ID int64 `json:"id" db:"id"` 28 + UpvoteCount int `json:"upvoteCount" db:"upvote_count"` 29 + DownvoteCount int `json:"downvoteCount" db:"downvote_count"` 30 + Score int `json:"score" db:"score"` 31 + ReplyCount int `json:"replyCount" db:"reply_count"` 39 32 } 40 33 41 34 // CommentRecord represents the atProto record structure indexed from Jetstream 42 35 // This is the data structure that gets stored in the user's repository 43 36 // Matches social.coves.feed.comment lexicon 44 37 type CommentRecord struct { 45 - Type string `json:"$type"` 46 - Reply ReplyRef `json:"reply"` 47 - Content string `json:"content"` 48 - Facets []interface{} `json:"facets,omitempty"` 49 - Embed map[string]interface{} `json:"embed,omitempty"` 50 - Langs []string `json:"langs,omitempty"` 51 - Labels *SelfLabels `json:"labels,omitempty"` 52 - CreatedAt string `json:"createdAt"` 38 + Embed map[string]interface{} `json:"embed,omitempty"` 39 + Labels *SelfLabels `json:"labels,omitempty"` 40 + Reply ReplyRef `json:"reply"` 41 + Type string `json:"$type"` 42 + Content string `json:"content"` 43 + CreatedAt string `json:"createdAt"` 44 + Facets []interface{} `json:"facets,omitempty"` 45 + Langs []string `json:"langs,omitempty"` 53 46 } 54 47 55 48 // ReplyRef represents the threading structure from the comment lexicon ··· 75 68 // SelfLabel represents a single label value per com.atproto.label.defs#selfLabel 76 69 // Neg is optional and negates the label when true 77 70 type SelfLabel struct { 78 - Val string `json:"val"` // Required: label value (max 128 chars) 79 - Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true 71 + Neg *bool `json:"neg,omitempty"` 72 + Val string `json:"val"` 80 73 }
+460
internal/core/comments/comment_service.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/core/communities" 5 + "Coves/internal/core/posts" 6 + "Coves/internal/core/users" 7 + "context" 8 + "errors" 9 + "fmt" 10 + "log" 11 + "strings" 12 + "time" 13 + ) 14 + 15 + // Service defines the business logic interface for comment operations 16 + // Orchestrates repository calls and builds view models for API responses 17 + type Service interface { 18 + // GetComments retrieves and builds a threaded comment tree for a post 19 + // Supports hot, top, and new sorting with configurable depth and pagination 20 + GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 21 + } 22 + 23 + // GetCommentsRequest defines the parameters for fetching comments 24 + type GetCommentsRequest struct { 25 + Cursor *string 26 + ViewerDID *string 27 + PostURI string 28 + Sort string 29 + Timeframe string 30 + Depth int 31 + Limit int 32 + } 33 + 34 + // commentService implements the Service interface 35 + // Coordinates between repository layer and view model construction 36 + type commentService struct { 37 + commentRepo Repository // Comment data access 38 + userRepo users.UserRepository // User lookup for author hydration 39 + postRepo posts.Repository // Post lookup for building post views 40 + communityRepo communities.Repository // Community lookup for community hydration 41 + } 42 + 43 + // NewCommentService creates a new comment service instance 44 + // All repositories are required for proper view construction per lexicon requirements 45 + func NewCommentService( 46 + commentRepo Repository, 47 + userRepo users.UserRepository, 48 + postRepo posts.Repository, 49 + communityRepo communities.Repository, 50 + ) Service { 51 + return &commentService{ 52 + commentRepo: commentRepo, 53 + userRepo: userRepo, 54 + postRepo: postRepo, 55 + communityRepo: communityRepo, 56 + } 57 + } 58 + 59 + // GetComments retrieves comments for a post with threading and pagination 60 + // Algorithm: 61 + // 1. Validate input parameters and apply defaults 62 + // 2. Fetch top-level comments with specified sorting 63 + // 3. Recursively load nested replies up to depth limit 64 + // 4. Build view models with author info and stats 65 + // 5. Return response with pagination cursor 66 + func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) { 67 + // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations) 68 + if err := validateGetCommentsRequest(req); err != nil { 69 + return nil, fmt.Errorf("invalid request: %w", err) 70 + } 71 + 72 + // Add timeout to prevent runaway queries with deep nesting 73 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 74 + defer cancel() 75 + 76 + // 2. Fetch post for context 77 + post, err := s.postRepo.GetByURI(ctx, req.PostURI) 78 + if err != nil { 79 + // Translate post not-found errors to comment-layer errors for proper HTTP status 80 + if posts.IsNotFound(err) { 81 + return nil, ErrRootNotFound 82 + } 83 + return nil, fmt.Errorf("failed to fetch post: %w", err) 84 + } 85 + 86 + // Build post view for response (hydrates author handle and community name) 87 + postView := s.buildPostView(ctx, post, req.ViewerDID) 88 + 89 + // 3. Fetch top-level comments with pagination 90 + // Uses repository's hot rank sorting and cursor-based pagination 91 + topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank( 92 + ctx, 93 + req.PostURI, 94 + req.Sort, 95 + req.Timeframe, 96 + req.Limit, 97 + req.Cursor, 98 + ) 99 + if err != nil { 100 + return nil, fmt.Errorf("failed to fetch top-level comments: %w", err) 101 + } 102 + 103 + // 4. Build threaded view with nested replies up to depth limit 104 + // This iteratively loads child comments and builds the tree structure 105 + threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID) 106 + 107 + // 5. Return response with comments, post reference, and cursor 108 + return &GetCommentsResponse{ 109 + Comments: threadViews, 110 + Post: postView, 111 + Cursor: nextCursor, 112 + }, nil 113 + } 114 + 115 + // buildThreadViews constructs threaded comment views with nested replies using batch loading 116 + // Uses batch queries to prevent N+1 query problem when loading nested replies 117 + // Loads replies level-by-level up to the specified depth limit 118 + func (s *commentService) buildThreadViews( 119 + ctx context.Context, 120 + comments []*Comment, 121 + remainingDepth int, 122 + sort string, 123 + viewerDID *string, 124 + ) []*ThreadViewComment { 125 + // Always return an empty slice, never nil (important for JSON serialization) 126 + result := make([]*ThreadViewComment, 0, len(comments)) 127 + 128 + if len(comments) == 0 { 129 + return result 130 + } 131 + 132 + // Build thread views for current level 133 + threadViews := make([]*ThreadViewComment, 0, len(comments)) 134 + commentsByURI := make(map[string]*ThreadViewComment) 135 + parentsWithReplies := make([]string, 0) 136 + 137 + for _, comment := range comments { 138 + // Skip deleted comments (soft-deleted records) 139 + if comment.DeletedAt != nil { 140 + continue 141 + } 142 + 143 + // Build the comment view with author info and stats 144 + commentView := s.buildCommentView(comment, viewerDID) 145 + 146 + threadView := &ThreadViewComment{ 147 + Comment: commentView, 148 + Replies: nil, 149 + HasMore: comment.ReplyCount > 0 && remainingDepth == 0, 150 + } 151 + 152 + threadViews = append(threadViews, threadView) 153 + commentsByURI[comment.URI] = threadView 154 + 155 + // Collect parent URIs that have replies and depth remaining 156 + if remainingDepth > 0 && comment.ReplyCount > 0 { 157 + parentsWithReplies = append(parentsWithReplies, comment.URI) 158 + } 159 + } 160 + 161 + // Batch load all replies for this level in a single query 162 + if len(parentsWithReplies) > 0 { 163 + const repliesPerParent = 5 // Load top 5 replies per comment 164 + 165 + repliesByParent, err := s.commentRepo.ListByParentsBatch( 166 + ctx, 167 + parentsWithReplies, 168 + sort, 169 + repliesPerParent, 170 + ) 171 + 172 + // Process replies if batch query succeeded 173 + if err == nil { 174 + // Group child comments by parent for recursive processing 175 + for parentURI, replies := range repliesByParent { 176 + threadView := commentsByURI[parentURI] 177 + if threadView != nil && len(replies) > 0 { 178 + // Recursively build views for child comments 179 + threadView.Replies = s.buildThreadViews( 180 + ctx, 181 + replies, 182 + remainingDepth-1, 183 + sort, 184 + viewerDID, 185 + ) 186 + 187 + // Update HasMore based on actual reply count vs loaded count 188 + // Get the original comment to check reply count 189 + for _, comment := range comments { 190 + if comment.URI == parentURI { 191 + threadView.HasMore = comment.ReplyCount > len(replies) 192 + break 193 + } 194 + } 195 + } 196 + } 197 + } 198 + } 199 + 200 + return threadViews 201 + } 202 + 203 + // buildCommentView converts a Comment entity to a CommentView with full metadata 204 + // Constructs author view, stats, and references to parent post/comment 205 + func (s *commentService) buildCommentView(comment *Comment, viewerDID *string) *CommentView { 206 + // Build author view from comment data 207 + // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN 208 + authorView := &posts.AuthorView{ 209 + DID: comment.CommenterDID, 210 + Handle: comment.CommenterHandle, 211 + // TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2B) 212 + } 213 + 214 + // Build aggregated statistics 215 + stats := &CommentStats{ 216 + Upvotes: comment.UpvoteCount, 217 + Downvotes: comment.DownvoteCount, 218 + Score: comment.Score, 219 + ReplyCount: comment.ReplyCount, 220 + } 221 + 222 + // Build reference to parent post (always present) 223 + postRef := &CommentRef{ 224 + URI: comment.RootURI, 225 + CID: comment.RootCID, 226 + } 227 + 228 + // Build reference to parent comment (only if nested) 229 + // Top-level comments have ParentURI == RootURI (both point to the post) 230 + var parentRef *CommentRef 231 + if comment.ParentURI != comment.RootURI { 232 + parentRef = &CommentRef{ 233 + URI: comment.ParentURI, 234 + CID: comment.ParentCID, 235 + } 236 + } 237 + 238 + // Build viewer state (stubbed for now - Phase 2B) 239 + // Future: Fetch viewer's vote state from GetVoteStateForComments 240 + var viewer *CommentViewerState 241 + if viewerDID != nil { 242 + // TODO: Query voter state 243 + // voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI}) 244 + // For now, return empty viewer state to indicate authenticated request 245 + viewer = &CommentViewerState{ 246 + Vote: nil, 247 + VoteURI: nil, 248 + } 249 + } 250 + 251 + // Build minimal comment record to satisfy lexicon contract 252 + // The record field is required by social.coves.community.comment.defs#commentView 253 + commentRecord := s.buildCommentRecord(comment) 254 + 255 + return &CommentView{ 256 + URI: comment.URI, 257 + CID: comment.CID, 258 + Author: authorView, 259 + Record: commentRecord, 260 + Post: postRef, 261 + Parent: parentRef, 262 + Content: comment.Content, 263 + CreatedAt: comment.CreatedAt.Format(time.RFC3339), 264 + IndexedAt: comment.IndexedAt.Format(time.RFC3339), 265 + Stats: stats, 266 + Viewer: viewer, 267 + } 268 + } 269 + 270 + // buildCommentRecord constructs a minimal CommentRecord from a Comment entity 271 + // Satisfies the lexicon requirement that commentView.record is a required field 272 + // TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 273 + func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord { 274 + record := &CommentRecord{ 275 + Type: "social.coves.feed.comment", 276 + Reply: ReplyRef{ 277 + Root: StrongRef{ 278 + URI: comment.RootURI, 279 + CID: comment.RootCID, 280 + }, 281 + Parent: StrongRef{ 282 + URI: comment.ParentURI, 283 + CID: comment.ParentCID, 284 + }, 285 + }, 286 + Content: comment.Content, 287 + CreatedAt: comment.CreatedAt.Format(time.RFC3339), 288 + Langs: comment.Langs, 289 + } 290 + 291 + // TODO (Phase 2C): Parse JSON fields from database for complete record: 292 + // - Unmarshal comment.Embed (*string) → record.Embed (map[string]interface{}) 293 + // - Unmarshal comment.ContentFacets (*string) → record.Facets ([]interface{}) 294 + // - Unmarshal comment.ContentLabels (*string) → record.Labels (*SelfLabels) 295 + // These fields are stored as JSONB in the database and need proper deserialization 296 + 297 + return record 298 + } 299 + 300 + // buildPostView converts a Post entity to a PostView for the comment response 301 + // Hydrates author handle and community name per lexicon requirements 302 + func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView { 303 + // Build author view - fetch user to get handle (required by lexicon) 304 + // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid 305 + authorHandle := post.AuthorDID // Fallback if user not found 306 + if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil { 307 + authorHandle = user.Handle 308 + } else { 309 + // Log warning but don't fail the entire request 310 + log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err) 311 + } 312 + 313 + authorView := &posts.AuthorView{ 314 + DID: post.AuthorDID, 315 + Handle: authorHandle, 316 + // TODO (Phase 2C): Add DisplayName, Avatar, Reputation from user profile 317 + } 318 + 319 + // Build community reference - fetch community to get name (required by lexicon) 320 + // The lexicon marks communityRef.name as required, so DIDs are insufficient 321 + communityName := post.CommunityDID // Fallback if community not found 322 + if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil { 323 + communityName = community.Handle // Use handle as display name 324 + // TODO (Phase 2C): Use community.DisplayName or community.Name if available 325 + } else { 326 + // Log warning but don't fail the entire request 327 + log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err) 328 + } 329 + 330 + communityRef := &posts.CommunityRef{ 331 + DID: post.CommunityDID, 332 + Name: communityName, 333 + // TODO (Phase 2C): Add Avatar from community profile 334 + } 335 + 336 + // Build aggregated statistics 337 + stats := &posts.PostStats{ 338 + Upvotes: post.UpvoteCount, 339 + Downvotes: post.DownvoteCount, 340 + Score: post.Score, 341 + CommentCount: post.CommentCount, 342 + } 343 + 344 + // Build viewer state if authenticated 345 + var viewer *posts.ViewerState 346 + if viewerDID != nil { 347 + // TODO (Phase 2B): Query viewer's vote state 348 + viewer = &posts.ViewerState{ 349 + Vote: nil, 350 + VoteURI: nil, 351 + Saved: false, 352 + } 353 + } 354 + 355 + // Build minimal post record to satisfy lexicon contract 356 + // The record field is required by social.coves.community.post.get#postView 357 + postRecord := s.buildPostRecord(post) 358 + 359 + return &posts.PostView{ 360 + URI: post.URI, 361 + CID: post.CID, 362 + RKey: post.RKey, 363 + Author: authorView, 364 + Record: postRecord, 365 + Community: communityRef, 366 + Title: post.Title, 367 + Text: post.Content, 368 + CreatedAt: post.CreatedAt, 369 + IndexedAt: post.IndexedAt, 370 + EditedAt: post.EditedAt, 371 + Stats: stats, 372 + Viewer: viewer, 373 + } 374 + } 375 + 376 + // buildPostRecord constructs a minimal PostRecord from a Post entity 377 + // Satisfies the lexicon requirement that postView.record is a required field 378 + // TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record 379 + func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord { 380 + record := &posts.PostRecord{ 381 + Type: "social.coves.community.post", 382 + Community: post.CommunityDID, 383 + Author: post.AuthorDID, 384 + CreatedAt: post.CreatedAt.Format(time.RFC3339), 385 + Title: post.Title, 386 + Content: post.Content, 387 + } 388 + 389 + // TODO (Phase 2C): Parse JSON fields from database for complete record: 390 + // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{}) 391 + // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{}) 392 + // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels) 393 + // These fields are stored as JSONB in the database and need proper deserialization 394 + 395 + return record 396 + } 397 + 398 + // validateGetCommentsRequest validates and normalizes request parameters 399 + // Applies default values and enforces bounds per API specification 400 + func validateGetCommentsRequest(req *GetCommentsRequest) error { 401 + if req == nil { 402 + return errors.New("request cannot be nil") 403 + } 404 + 405 + // Validate PostURI is present and well-formed 406 + if req.PostURI == "" { 407 + return errors.New("post URI is required") 408 + } 409 + 410 + if !strings.HasPrefix(req.PostURI, "at://") { 411 + return errors.New("invalid AT-URI format: must start with 'at://'") 412 + } 413 + 414 + // Apply depth defaults and bounds (0-100, default 10) 415 + if req.Depth < 0 { 416 + req.Depth = 10 417 + } 418 + if req.Depth > 100 { 419 + req.Depth = 100 420 + } 421 + 422 + // Apply limit defaults and bounds (1-100, default 50) 423 + if req.Limit <= 0 { 424 + req.Limit = 50 425 + } 426 + if req.Limit > 100 { 427 + req.Limit = 100 428 + } 429 + 430 + // Apply sort default and validate 431 + if req.Sort == "" { 432 + req.Sort = "hot" 433 + } 434 + 435 + validSorts := map[string]bool{ 436 + "hot": true, 437 + "top": true, 438 + "new": true, 439 + } 440 + if !validSorts[req.Sort] { 441 + return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort) 442 + } 443 + 444 + // Validate timeframe (only applies to "top" sort) 445 + if req.Timeframe != "" { 446 + validTimeframes := map[string]bool{ 447 + "hour": true, 448 + "day": true, 449 + "week": true, 450 + "month": true, 451 + "year": true, 452 + "all": true, 453 + } 454 + if !validTimeframes[req.Timeframe] { 455 + return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe) 456 + } 457 + } 458 + 459 + return nil 460 + }
+7
internal/core/comments/errors.go
··· 42 42 func IsConflict(err error) bool { 43 43 return errors.Is(err, ErrCommentAlreadyExists) 44 44 } 45 + 46 + // IsValidationError checks if an error is a validation error 47 + func IsValidationError(err error) bool { 48 + return errors.Is(err, ErrInvalidReply) || 49 + errors.Is(err, ErrContentTooLong) || 50 + errors.Is(err, ErrContentEmpty) 51 + }
+33
internal/core/comments/interfaces.go
··· 42 42 // ListByCommenter retrieves all comments by a specific user 43 43 // Future: Used for user comment history 44 44 ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) 45 + 46 + // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 47 + // Supports hot, top, and new sorting with cursor-based pagination 48 + // Returns comments with author info hydrated and next page cursor 49 + ListByParentWithHotRank( 50 + ctx context.Context, 51 + parentURI string, 52 + sort string, // "hot", "top", "new" 53 + timeframe string, // "hour", "day", "week", "month", "year", "all" (for "top" only) 54 + limit int, 55 + cursor *string, 56 + ) ([]*Comment, *string, error) 57 + 58 + // GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query 59 + // Returns map[uri]*Comment for efficient lookups 60 + // Used for hydrating comment threads without N+1 queries 61 + GetByURIsBatch(ctx context.Context, uris []string) (map[string]*Comment, error) 62 + 63 + // GetVoteStateForComments retrieves the viewer's votes on a batch of comments 64 + // Returns map[commentURI]*Vote for efficient lookups 65 + // Future: Used when votes table is implemented 66 + GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) 67 + 68 + // ListByParentsBatch retrieves direct replies to multiple parents in a single query 69 + // Returns map[parentURI][]*Comment grouped by parent 70 + // Used to prevent N+1 queries when loading nested replies 71 + // Limits results per parent to avoid memory exhaustion 72 + ListByParentsBatch( 73 + ctx context.Context, 74 + parentURIs []string, 75 + sort string, 76 + limitPerParent int, 77 + ) (map[string][]*Comment, error) 45 78 }
+65
internal/core/comments/view_models.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/core/posts" 5 + ) 6 + 7 + // CommentView represents the full view of a comment with all metadata 8 + // Matches social.coves.feed.getComments#commentView lexicon 9 + // Used in thread views and get endpoints 10 + type CommentView struct { 11 + Embed interface{} `json:"embed,omitempty"` 12 + Record interface{} `json:"record"` 13 + Viewer *CommentViewerState `json:"viewer,omitempty"` 14 + Author *posts.AuthorView `json:"author"` 15 + Post *CommentRef `json:"post"` 16 + Parent *CommentRef `json:"parent,omitempty"` 17 + Stats *CommentStats `json:"stats"` 18 + Content string `json:"content"` 19 + CreatedAt string `json:"createdAt"` 20 + IndexedAt string `json:"indexedAt"` 21 + URI string `json:"uri"` 22 + CID string `json:"cid"` 23 + ContentFacets []interface{} `json:"contentFacets,omitempty"` 24 + } 25 + 26 + // ThreadViewComment represents a comment with its nested replies 27 + // Matches social.coves.feed.getComments#threadViewComment lexicon 28 + // Supports recursive threading for comment trees 29 + type ThreadViewComment struct { 30 + Comment *CommentView `json:"comment"` 31 + Replies []*ThreadViewComment `json:"replies,omitempty"` // Recursive nested replies 32 + HasMore bool `json:"hasMore,omitempty"` // Indicates more replies exist 33 + } 34 + 35 + // CommentRef is a minimal reference to a post or comment (URI + CID) 36 + // Used for threading references (post and parent comment) 37 + type CommentRef struct { 38 + URI string `json:"uri"` 39 + CID string `json:"cid"` 40 + } 41 + 42 + // CommentStats represents aggregated statistics for a comment 43 + // Includes voting metrics and reply counts 44 + type CommentStats struct { 45 + Upvotes int `json:"upvotes"` 46 + Downvotes int `json:"downvotes"` 47 + Score int `json:"score"` 48 + ReplyCount int `json:"replyCount"` 49 + } 50 + 51 + // CommentViewerState represents the viewer's relationship with the comment 52 + // Includes voting state and vote record reference 53 + type CommentViewerState struct { 54 + Vote *string `json:"vote,omitempty"` // "up" or "down" 55 + VoteURI *string `json:"voteUri,omitempty"` // URI of the vote record 56 + } 57 + 58 + // GetCommentsResponse represents the response for fetching comments on a post 59 + // Matches social.coves.feed.getComments lexicon output 60 + // Includes the full comment thread tree and original post reference 61 + type GetCommentsResponse struct { 62 + Post interface{} `json:"post"` 63 + Cursor *string `json:"cursor,omitempty"` 64 + Comments []*ThreadViewComment `json:"comments"` 65 + }
+5 -5
internal/core/posts/post.go
··· 13 13 // SelfLabel represents a single label value per com.atproto.label.defs#selfLabel 14 14 // Neg is optional and negates the label when true 15 15 type SelfLabel struct { 16 - Val string `json:"val"` // Required: label value (max 128 chars) 17 - Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true 16 + Neg *bool `json:"neg,omitempty"` 17 + Val string `json:"val"` 18 18 } 19 19 20 20 // Post represents a post in the AppView database ··· 50 50 Title *string `json:"title,omitempty"` 51 51 Content *string `json:"content,omitempty"` 52 52 Embed map[string]interface{} `json:"embed,omitempty"` 53 + Labels *SelfLabels `json:"labels,omitempty"` 53 54 Community string `json:"community"` 54 55 AuthorDID string `json:"authorDid"` 55 56 Facets []interface{} `json:"facets,omitempty"` 56 - Labels *SelfLabels `json:"labels,omitempty"` 57 57 } 58 58 59 59 // CreatePostResponse represents the response from creating a post 60 60 // Matches social.coves.community.post.create lexicon output schema 61 - type CreatePostResponse struct{ 61 + type CreatePostResponse struct { 62 62 URI string `json:"uri"` // AT-URI of created post 63 63 CID string `json:"cid"` // CID of created post 64 64 } ··· 72 72 Title *string `json:"title,omitempty"` 73 73 Content *string `json:"content,omitempty"` 74 74 Embed map[string]interface{} `json:"embed,omitempty"` 75 + Labels *SelfLabels `json:"labels,omitempty"` 75 76 Type string `json:"$type"` 76 77 Community string `json:"community"` 77 78 Author string `json:"author"` 78 79 CreatedAt string `json:"createdAt"` 79 80 Facets []interface{} `json:"facets,omitempty"` 80 - Labels *SelfLabels `json:"labels,omitempty"` 81 81 } 82 82 83 83 // PostView represents the full view of a post with all metadata
+2 -2
internal/core/posts/service.go
··· 259 259 // IMPORTANT: repo is set to community DID, not author DID 260 260 // This writes the post to the community's repository 261 261 payload := map[string]interface{}{ 262 - "repo": community.DID, // Community's repository 262 + "repo": community.DID, // Community's repository 263 263 "collection": "social.coves.community.post", // Collection type 264 - "record": record, // The post record 264 + "record": record, // The post record 265 265 // "rkey" omitted - PDS will auto-generate TID 266 266 } 267 267
+587
internal/db/postgres/comment_repo.go
··· 4 4 "Coves/internal/core/comments" 5 5 "context" 6 6 "database/sql" 7 + "encoding/base64" 7 8 "fmt" 8 9 "log" 9 10 "strings" ··· 352 353 353 354 return result, nil 354 355 } 356 + 357 + // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 358 + // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) 359 + // Uses cursor-based pagination with composite keys for consistent ordering 360 + // Hydrates author info (handle, display_name, avatar) via JOIN with users table 361 + func (r *postgresCommentRepo) ListByParentWithHotRank( 362 + ctx context.Context, 363 + parentURI string, 364 + sort string, 365 + timeframe string, 366 + limit int, 367 + cursor *string, 368 + ) ([]*comments.Comment, *string, error) { 369 + // Build ORDER BY clause and time filter based on sort type 370 + orderBy, timeFilter := r.buildCommentSortClause(sort, timeframe) 371 + 372 + // Parse cursor for pagination 373 + cursorFilter, cursorValues, err := r.parseCommentCursor(cursor, sort) 374 + if err != nil { 375 + return nil, nil, fmt.Errorf("invalid cursor: %w", err) 376 + } 377 + 378 + // Build SELECT clause - compute hot_rank for "hot" sort 379 + // Hot rank formula (Lemmy algorithm): 380 + // log(greatest(2, score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8) 381 + // 382 + // This formula: 383 + // - Gives logarithmic weight to score (prevents high-score dominance) 384 + // - Decays over time with power 1.8 (faster than linear, slower than quadratic) 385 + // - Uses hours as time unit (3600 seconds) 386 + // - Adds constants to prevent division by zero and ensure positive values 387 + var selectClause string 388 + if sort == "hot" { 389 + selectClause = ` 390 + SELECT 391 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 392 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 393 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 394 + c.created_at, c.indexed_at, c.deleted_at, 395 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 396 + log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank, 397 + COALESCE(u.handle, c.commenter_did) as author_handle 398 + FROM comments c` 399 + } else { 400 + selectClause = ` 401 + SELECT 402 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 403 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 404 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 405 + c.created_at, c.indexed_at, c.deleted_at, 406 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 407 + NULL::numeric as hot_rank, 408 + COALESCE(u.handle, c.commenter_did) as author_handle 409 + FROM comments c` 410 + } 411 + 412 + // Build complete query with JOINs and filters 413 + // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 414 + query := fmt.Sprintf(` 415 + %s 416 + LEFT JOIN users u ON c.commenter_did = u.did 417 + WHERE c.parent_uri = $1 AND c.deleted_at IS NULL 418 + %s 419 + %s 420 + ORDER BY %s 421 + LIMIT $2 422 + `, selectClause, timeFilter, cursorFilter, orderBy) 423 + 424 + // Prepare query arguments 425 + args := []interface{}{parentURI, limit + 1} // +1 to detect next page 426 + args = append(args, cursorValues...) 427 + 428 + // Execute query 429 + rows, err := r.db.QueryContext(ctx, query, args...) 430 + if err != nil { 431 + return nil, nil, fmt.Errorf("failed to query comments with hot rank: %w", err) 432 + } 433 + defer func() { 434 + if err := rows.Close(); err != nil { 435 + log.Printf("Failed to close rows: %v", err) 436 + } 437 + }() 438 + 439 + // Scan results 440 + var result []*comments.Comment 441 + var hotRanks []float64 442 + for rows.Next() { 443 + var comment comments.Comment 444 + var langs pq.StringArray 445 + var hotRank sql.NullFloat64 446 + var authorHandle string 447 + 448 + err := rows.Scan( 449 + &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 450 + &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 451 + &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 452 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 453 + &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 454 + &hotRank, &authorHandle, 455 + ) 456 + if err != nil { 457 + return nil, nil, fmt.Errorf("failed to scan comment: %w", err) 458 + } 459 + 460 + comment.Langs = langs 461 + comment.CommenterHandle = authorHandle 462 + 463 + // Store hot_rank for cursor building 464 + hotRankValue := 0.0 465 + if hotRank.Valid { 466 + hotRankValue = hotRank.Float64 467 + } 468 + hotRanks = append(hotRanks, hotRankValue) 469 + 470 + result = append(result, &comment) 471 + } 472 + 473 + if err = rows.Err(); err != nil { 474 + return nil, nil, fmt.Errorf("error iterating comments: %w", err) 475 + } 476 + 477 + // Handle pagination cursor 478 + var nextCursor *string 479 + if len(result) > limit && limit > 0 { 480 + result = result[:limit] 481 + hotRanks = hotRanks[:limit] 482 + lastComment := result[len(result)-1] 483 + lastHotRank := hotRanks[len(hotRanks)-1] 484 + cursorStr := r.buildCommentCursor(lastComment, sort, lastHotRank) 485 + nextCursor = &cursorStr 486 + } 487 + 488 + return result, nextCursor, nil 489 + } 490 + 491 + // buildCommentSortClause returns the ORDER BY SQL and optional time filter 492 + func (r *postgresCommentRepo) buildCommentSortClause(sort, timeframe string) (string, string) { 493 + var orderBy string 494 + switch sort { 495 + case "hot": 496 + // Hot rank DESC, then score DESC as tiebreaker, then created_at DESC, then uri DESC 497 + orderBy = `hot_rank DESC, c.score DESC, c.created_at DESC, c.uri DESC` 498 + case "top": 499 + // Score DESC, then created_at DESC, then uri DESC 500 + orderBy = `c.score DESC, c.created_at DESC, c.uri DESC` 501 + case "new": 502 + // Created at DESC, then uri DESC 503 + orderBy = `c.created_at DESC, c.uri DESC` 504 + default: 505 + // Default to hot 506 + orderBy = `hot_rank DESC, c.score DESC, c.created_at DESC, c.uri DESC` 507 + } 508 + 509 + // Add time filter for "top" sort 510 + var timeFilter string 511 + if sort == "top" { 512 + timeFilter = r.buildCommentTimeFilter(timeframe) 513 + } 514 + 515 + return orderBy, timeFilter 516 + } 517 + 518 + // buildCommentTimeFilter returns SQL filter for timeframe 519 + func (r *postgresCommentRepo) buildCommentTimeFilter(timeframe string) string { 520 + if timeframe == "" || timeframe == "all" { 521 + return "" 522 + } 523 + 524 + var interval string 525 + switch timeframe { 526 + case "hour": 527 + interval = "1 hour" 528 + case "day": 529 + interval = "1 day" 530 + case "week": 531 + interval = "7 days" 532 + case "month": 533 + interval = "30 days" 534 + case "year": 535 + interval = "1 year" 536 + default: 537 + return "" 538 + } 539 + 540 + return fmt.Sprintf("AND c.created_at >= NOW() - INTERVAL '%s'", interval) 541 + } 542 + 543 + // parseCommentCursor decodes pagination cursor for comments 544 + func (r *postgresCommentRepo) parseCommentCursor(cursor *string, sort string) (string, []interface{}, error) { 545 + if cursor == nil || *cursor == "" { 546 + return "", nil, nil 547 + } 548 + 549 + // Validate cursor size to prevent DoS via massive base64 strings 550 + const maxCursorSize = 1024 551 + if len(*cursor) > maxCursorSize { 552 + return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize) 553 + } 554 + 555 + // Decode base64 cursor 556 + decoded, err := base64.URLEncoding.DecodeString(*cursor) 557 + if err != nil { 558 + return "", nil, fmt.Errorf("invalid cursor encoding") 559 + } 560 + 561 + // Parse cursor based on sort type using | delimiter 562 + // Format: hotRank|score|createdAt|uri (for hot) 563 + // score|createdAt|uri (for top) 564 + // createdAt|uri (for new) 565 + parts := strings.Split(string(decoded), "|") 566 + 567 + switch sort { 568 + case "new": 569 + // Cursor format: createdAt|uri 570 + if len(parts) != 2 { 571 + return "", nil, fmt.Errorf("invalid cursor format for new sort") 572 + } 573 + 574 + createdAt := parts[0] 575 + uri := parts[1] 576 + 577 + // Validate AT-URI format 578 + if !strings.HasPrefix(uri, "at://") { 579 + return "", nil, fmt.Errorf("invalid cursor URI") 580 + } 581 + 582 + filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))` 583 + return filter, []interface{}{createdAt, uri}, nil 584 + 585 + case "top": 586 + // Cursor format: score|createdAt|uri 587 + if len(parts) != 3 { 588 + return "", nil, fmt.Errorf("invalid cursor format for top sort") 589 + } 590 + 591 + scoreStr := parts[0] 592 + createdAt := parts[1] 593 + uri := parts[2] 594 + 595 + // Parse score as integer 596 + score := 0 597 + if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil { 598 + return "", nil, fmt.Errorf("invalid cursor score") 599 + } 600 + 601 + // Validate AT-URI format 602 + if !strings.HasPrefix(uri, "at://") { 603 + return "", nil, fmt.Errorf("invalid cursor URI") 604 + } 605 + 606 + filter := `AND (c.score < $3 OR (c.score = $3 AND c.created_at < $4) OR (c.score = $3 AND c.created_at = $4 AND c.uri < $5))` 607 + return filter, []interface{}{score, createdAt, uri}, nil 608 + 609 + case "hot": 610 + // Cursor format: hotRank|score|createdAt|uri 611 + if len(parts) != 4 { 612 + return "", nil, fmt.Errorf("invalid cursor format for hot sort") 613 + } 614 + 615 + hotRankStr := parts[0] 616 + scoreStr := parts[1] 617 + createdAt := parts[2] 618 + uri := parts[3] 619 + 620 + // Parse hot_rank as float 621 + hotRank := 0.0 622 + if _, err := fmt.Sscanf(hotRankStr, "%f", &hotRank); err != nil { 623 + return "", nil, fmt.Errorf("invalid cursor hot rank") 624 + } 625 + 626 + // Parse score as integer 627 + score := 0 628 + if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil { 629 + return "", nil, fmt.Errorf("invalid cursor score") 630 + } 631 + 632 + // Validate AT-URI format 633 + if !strings.HasPrefix(uri, "at://") { 634 + return "", nil, fmt.Errorf("invalid cursor URI") 635 + } 636 + 637 + // Use computed hot_rank expression in comparison 638 + hotRankExpr := `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8)` 639 + filter := fmt.Sprintf(`AND ((%s < $3 OR (%s = $3 AND c.score < $4) OR (%s = $3 AND c.score = $4 AND c.created_at < $5) OR (%s = $3 AND c.score = $4 AND c.created_at = $5 AND c.uri < $6)) AND c.uri != $7)`, 640 + hotRankExpr, hotRankExpr, hotRankExpr, hotRankExpr) 641 + return filter, []interface{}{hotRank, score, createdAt, uri, uri}, nil 642 + 643 + default: 644 + return "", nil, nil 645 + } 646 + } 647 + 648 + // buildCommentCursor creates pagination cursor from last comment 649 + func (r *postgresCommentRepo) buildCommentCursor(comment *comments.Comment, sort string, hotRank float64) string { 650 + var cursorStr string 651 + const delimiter = "|" 652 + 653 + switch sort { 654 + case "new": 655 + // Format: createdAt|uri 656 + cursorStr = fmt.Sprintf("%s%s%s", 657 + comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), 658 + delimiter, 659 + comment.URI) 660 + 661 + case "top": 662 + // Format: score|createdAt|uri 663 + cursorStr = fmt.Sprintf("%d%s%s%s%s", 664 + comment.Score, 665 + delimiter, 666 + comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), 667 + delimiter, 668 + comment.URI) 669 + 670 + case "hot": 671 + // Format: hotRank|score|createdAt|uri 672 + cursorStr = fmt.Sprintf("%f%s%d%s%s%s%s", 673 + hotRank, 674 + delimiter, 675 + comment.Score, 676 + delimiter, 677 + comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), 678 + delimiter, 679 + comment.URI) 680 + 681 + default: 682 + cursorStr = comment.URI 683 + } 684 + 685 + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) 686 + } 687 + 688 + // GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query 689 + // Returns map[uri]*Comment for efficient lookups without N+1 queries 690 + func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) { 691 + if len(uris) == 0 { 692 + return make(map[string]*comments.Comment), nil 693 + } 694 + 695 + // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 696 + // COALESCE falls back to DID when handle is NULL (user not yet in users table) 697 + query := ` 698 + SELECT 699 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 700 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 701 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 702 + c.created_at, c.indexed_at, c.deleted_at, 703 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 704 + COALESCE(u.handle, c.commenter_did) as author_handle 705 + FROM comments c 706 + LEFT JOIN users u ON c.commenter_did = u.did 707 + WHERE c.uri = ANY($1) AND c.deleted_at IS NULL 708 + ` 709 + 710 + rows, err := r.db.QueryContext(ctx, query, pq.Array(uris)) 711 + if err != nil { 712 + return nil, fmt.Errorf("failed to batch get comments by URIs: %w", err) 713 + } 714 + defer func() { 715 + if err := rows.Close(); err != nil { 716 + log.Printf("Failed to close rows: %v", err) 717 + } 718 + }() 719 + 720 + result := make(map[string]*comments.Comment) 721 + for rows.Next() { 722 + var comment comments.Comment 723 + var langs pq.StringArray 724 + var authorHandle string 725 + 726 + err := rows.Scan( 727 + &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 728 + &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 729 + &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 730 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 731 + &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 732 + &authorHandle, 733 + ) 734 + if err != nil { 735 + return nil, fmt.Errorf("failed to scan comment: %w", err) 736 + } 737 + 738 + comment.Langs = langs 739 + result[comment.URI] = &comment 740 + } 741 + 742 + if err = rows.Err(); err != nil { 743 + return nil, fmt.Errorf("error iterating comments: %w", err) 744 + } 745 + 746 + return result, nil 747 + } 748 + 749 + // ListByParentsBatch retrieves direct replies to multiple parents in a single query 750 + // Groups results by parent URI to prevent N+1 queries when loading nested replies 751 + // Uses window functions to limit results per parent efficiently 752 + func (r *postgresCommentRepo) ListByParentsBatch( 753 + ctx context.Context, 754 + parentURIs []string, 755 + sort string, 756 + limitPerParent int, 757 + ) (map[string][]*comments.Comment, error) { 758 + if len(parentURIs) == 0 { 759 + return make(map[string][]*comments.Comment), nil 760 + } 761 + 762 + // Build ORDER BY clause based on sort type 763 + // windowOrderBy must inline expressions (can't use SELECT aliases in window functions) 764 + var windowOrderBy string 765 + var selectClause string 766 + switch sort { 767 + case "hot": 768 + selectClause = ` 769 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 770 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 771 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 772 + c.created_at, c.indexed_at, c.deleted_at, 773 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 774 + log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank, 775 + COALESCE(u.handle, c.commenter_did) as author_handle` 776 + // CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY 777 + windowOrderBy = `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) DESC, c.score DESC, c.created_at DESC` 778 + case "top": 779 + selectClause = ` 780 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 781 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 782 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 783 + c.created_at, c.indexed_at, c.deleted_at, 784 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 785 + NULL::numeric as hot_rank, 786 + COALESCE(u.handle, c.commenter_did) as author_handle` 787 + windowOrderBy = `c.score DESC, c.created_at DESC` 788 + case "new": 789 + selectClause = ` 790 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 791 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 792 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 793 + c.created_at, c.indexed_at, c.deleted_at, 794 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 795 + NULL::numeric as hot_rank, 796 + COALESCE(u.handle, c.commenter_did) as author_handle` 797 + windowOrderBy = `c.created_at DESC` 798 + default: 799 + // Default to hot 800 + selectClause = ` 801 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 802 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 803 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 804 + c.created_at, c.indexed_at, c.deleted_at, 805 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 806 + log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank, 807 + COALESCE(u.handle, c.commenter_did) as author_handle` 808 + // CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY 809 + windowOrderBy = `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) DESC, c.score DESC, c.created_at DESC` 810 + } 811 + 812 + // Use window function to limit results per parent 813 + // This is more efficient than LIMIT in a subquery per parent 814 + // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 815 + query := fmt.Sprintf(` 816 + WITH ranked_comments AS ( 817 + SELECT 818 + %s, 819 + ROW_NUMBER() OVER ( 820 + PARTITION BY c.parent_uri 821 + ORDER BY %s 822 + ) as rn 823 + FROM comments c 824 + LEFT JOIN users u ON c.commenter_did = u.did 825 + WHERE c.parent_uri = ANY($1) AND c.deleted_at IS NULL 826 + ) 827 + SELECT 828 + id, uri, cid, rkey, commenter_did, 829 + root_uri, root_cid, parent_uri, parent_cid, 830 + content, content_facets, embed, content_labels, langs, 831 + created_at, indexed_at, deleted_at, 832 + upvote_count, downvote_count, score, reply_count, 833 + hot_rank, author_handle 834 + FROM ranked_comments 835 + WHERE rn <= $2 836 + ORDER BY parent_uri, rn 837 + `, selectClause, windowOrderBy) 838 + 839 + rows, err := r.db.QueryContext(ctx, query, pq.Array(parentURIs), limitPerParent) 840 + if err != nil { 841 + return nil, fmt.Errorf("failed to batch query comments by parents: %w", err) 842 + } 843 + defer func() { 844 + if err := rows.Close(); err != nil { 845 + log.Printf("Failed to close rows: %v", err) 846 + } 847 + }() 848 + 849 + // Group results by parent URI 850 + result := make(map[string][]*comments.Comment) 851 + for rows.Next() { 852 + var comment comments.Comment 853 + var langs pq.StringArray 854 + var hotRank sql.NullFloat64 855 + var authorHandle string 856 + 857 + err := rows.Scan( 858 + &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 859 + &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 860 + &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 861 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, 862 + &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 863 + &hotRank, &authorHandle, 864 + ) 865 + if err != nil { 866 + return nil, fmt.Errorf("failed to scan comment: %w", err) 867 + } 868 + 869 + comment.Langs = langs 870 + comment.CommenterHandle = authorHandle 871 + 872 + // Group by parent URI 873 + result[comment.ParentURI] = append(result[comment.ParentURI], &comment) 874 + } 875 + 876 + if err = rows.Err(); err != nil { 877 + return nil, fmt.Errorf("error iterating comments: %w", err) 878 + } 879 + 880 + return result, nil 881 + } 882 + 883 + // GetVoteStateForComments retrieves the viewer's votes on a batch of comments 884 + // Returns map[commentURI]*Vote for efficient lookups 885 + // Note: This implementation is prepared for when the votes table indexing is implemented 886 + // Currently returns an empty map as votes may not be fully indexed yet 887 + func (r *postgresCommentRepo) GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) { 888 + if len(commentURIs) == 0 || viewerDID == "" { 889 + return make(map[string]interface{}), nil 890 + } 891 + 892 + // Query votes table for viewer's votes on these comments 893 + // Note: This assumes votes table exists and is being indexed 894 + // If votes table doesn't exist yet, this query will fail gracefully 895 + query := ` 896 + SELECT subject_uri, direction, uri, cid, created_at 897 + FROM votes 898 + WHERE voter_did = $1 AND subject_uri = ANY($2) AND deleted_at IS NULL 899 + ` 900 + 901 + rows, err := r.db.QueryContext(ctx, query, viewerDID, pq.Array(commentURIs)) 902 + if err != nil { 903 + // If votes table doesn't exist yet, return empty map instead of error 904 + // This allows the API to work before votes indexing is fully implemented 905 + if strings.Contains(err.Error(), "does not exist") { 906 + return make(map[string]interface{}), nil 907 + } 908 + return nil, fmt.Errorf("failed to get vote state for comments: %w", err) 909 + } 910 + defer func() { 911 + if err := rows.Close(); err != nil { 912 + log.Printf("Failed to close rows: %v", err) 913 + } 914 + }() 915 + 916 + // Build result map with vote information 917 + result := make(map[string]interface{}) 918 + for rows.Next() { 919 + var subjectURI, direction, uri, cid string 920 + var createdAt sql.NullTime 921 + 922 + err := rows.Scan(&subjectURI, &direction, &uri, &cid, &createdAt) 923 + if err != nil { 924 + return nil, fmt.Errorf("failed to scan vote: %w", err) 925 + } 926 + 927 + // Store vote info as a simple map (can be enhanced later with proper Vote struct) 928 + result[subjectURI] = map[string]interface{}{ 929 + "direction": direction, 930 + "uri": uri, 931 + "cid": cid, 932 + "createdAt": createdAt.Time, 933 + } 934 + } 935 + 936 + if err = rows.Err(); err != nil { 937 + return nil, fmt.Errorf("error iterating votes: %w", err) 938 + } 939 + 940 + return result, nil 941 + }
+2 -2
tests/integration/comment_consumer_test.go
··· 45 45 RKey: rkey, 46 46 CID: "bafytest123", 47 47 Record: map[string]interface{}{ 48 - "$type": "social.coves.feed.comment", 48 + "$type": "social.coves.feed.comment", 49 49 "content": "This is a test comment on a post!", 50 50 "reply": map[string]interface{}{ 51 51 "root": map[string]interface{}{ ··· 119 119 RKey: rkey, 120 120 CID: "bafytest456", 121 121 Record: map[string]interface{}{ 122 - "$type": "social.coves.feed.comment", 122 + "$type": "social.coves.feed.comment", 123 123 "content": "Idempotent test comment", 124 124 "reply": map[string]interface{}{ 125 125 "root": map[string]interface{}{
+928
tests/integration/comment_query_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/atproto/jetstream" 5 + "Coves/internal/core/comments" 6 + "Coves/internal/db/postgres" 7 + "context" 8 + "database/sql" 9 + "encoding/json" 10 + "fmt" 11 + "net/http" 12 + "net/http/httptest" 13 + "strings" 14 + "testing" 15 + "time" 16 + 17 + "github.com/stretchr/testify/assert" 18 + "github.com/stretchr/testify/require" 19 + ) 20 + 21 + // TestCommentQuery_BasicFetch tests fetching top-level comments with default params 22 + func TestCommentQuery_BasicFetch(t *testing.T) { 23 + db := setupTestDB(t) 24 + defer func() { 25 + if err := db.Close(); err != nil { 26 + t.Logf("Failed to close database: %v", err) 27 + } 28 + }() 29 + 30 + ctx := context.Background() 31 + testUser := createTestUser(t, db, "basicfetch.test", "did:plc:basicfetch123") 32 + testCommunity, err := createFeedTestCommunity(db, ctx, "basicfetchcomm", "ownerbasic.test") 33 + require.NoError(t, err, "Failed to create test community") 34 + 35 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Basic Fetch Test Post", 0, time.Now()) 36 + 37 + // Create 3 top-level comments with different scores and ages 38 + comment1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "First comment", 10, 2, time.Now().Add(-2*time.Hour)) 39 + comment2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Second comment", 5, 1, time.Now().Add(-30*time.Minute)) 40 + comment3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Third comment", 3, 0, time.Now().Add(-5*time.Minute)) 41 + 42 + // Fetch comments with default params (hot sort) 43 + service := setupCommentService(db) 44 + req := &comments.GetCommentsRequest{ 45 + PostURI: postURI, 46 + Sort: "hot", 47 + Depth: 10, 48 + Limit: 50, 49 + } 50 + 51 + resp, err := service.GetComments(ctx, req) 52 + require.NoError(t, err, "GetComments should not return error") 53 + require.NotNil(t, resp, "Response should not be nil") 54 + 55 + // Verify all 3 comments returned 56 + assert.Len(t, resp.Comments, 3, "Should return all 3 top-level comments") 57 + 58 + // Verify stats are correct 59 + for _, threadView := range resp.Comments { 60 + commentView := threadView.Comment 61 + assert.NotNil(t, commentView.Stats, "Stats should not be nil") 62 + 63 + // Verify upvotes, downvotes, score, reply count present 64 + assert.GreaterOrEqual(t, commentView.Stats.Upvotes, 0, "Upvotes should be non-negative") 65 + assert.GreaterOrEqual(t, commentView.Stats.Downvotes, 0, "Downvotes should be non-negative") 66 + assert.Equal(t, 0, commentView.Stats.ReplyCount, "Top-level comments should have 0 replies") 67 + } 68 + 69 + // Verify URIs match 70 + commentURIs := []string{comment1, comment2, comment3} 71 + returnedURIs := make(map[string]bool) 72 + for _, tv := range resp.Comments { 73 + returnedURIs[tv.Comment.URI] = true 74 + } 75 + 76 + for _, uri := range commentURIs { 77 + assert.True(t, returnedURIs[uri], "Comment URI %s should be in results", uri) 78 + } 79 + } 80 + 81 + // TestCommentQuery_NestedReplies tests fetching comments with nested reply structure 82 + func TestCommentQuery_NestedReplies(t *testing.T) { 83 + db := setupTestDB(t) 84 + defer func() { 85 + if err := db.Close(); err != nil { 86 + t.Logf("Failed to close database: %v", err) 87 + } 88 + }() 89 + 90 + ctx := context.Background() 91 + testUser := createTestUser(t, db, "nested.test", "did:plc:nested123") 92 + testCommunity, err := createFeedTestCommunity(db, ctx, "nestedcomm", "ownernested.test") 93 + require.NoError(t, err) 94 + 95 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Nested Test Post", 0, time.Now()) 96 + 97 + // Create nested structure: 98 + // Post 99 + // |- Comment A (top-level) 100 + // |- Reply A1 101 + // |- Reply A1a 102 + // |- Reply A1b 103 + // |- Reply A2 104 + // |- Comment B (top-level) 105 + 106 + commentA := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment A", 5, 0, time.Now().Add(-1*time.Hour)) 107 + replyA1 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A1", 3, 0, time.Now().Add(-50*time.Minute)) 108 + replyA1a := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1a", 2, 0, time.Now().Add(-40*time.Minute)) 109 + replyA1b := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1b", 1, 0, time.Now().Add(-30*time.Minute)) 110 + replyA2 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A2", 2, 0, time.Now().Add(-20*time.Minute)) 111 + commentB := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment B", 4, 0, time.Now().Add(-10*time.Minute)) 112 + 113 + // Fetch with depth=2 (should get 2 levels of nesting) 114 + service := setupCommentService(db) 115 + req := &comments.GetCommentsRequest{ 116 + PostURI: postURI, 117 + Sort: "new", 118 + Depth: 2, 119 + Limit: 50, 120 + } 121 + 122 + resp, err := service.GetComments(ctx, req) 123 + require.NoError(t, err) 124 + require.Len(t, resp.Comments, 2, "Should return 2 top-level comments") 125 + 126 + // Find Comment A in results 127 + var commentAThread *comments.ThreadViewComment 128 + for _, tv := range resp.Comments { 129 + if tv.Comment.URI == commentA { 130 + commentAThread = tv 131 + break 132 + } 133 + } 134 + require.NotNil(t, commentAThread, "Comment A should be in results") 135 + 136 + // Verify Comment A has replies 137 + require.NotNil(t, commentAThread.Replies, "Comment A should have replies") 138 + assert.Len(t, commentAThread.Replies, 2, "Comment A should have 2 direct replies (A1 and A2)") 139 + 140 + // Find Reply A1 141 + var replyA1Thread *comments.ThreadViewComment 142 + for _, reply := range commentAThread.Replies { 143 + if reply.Comment.URI == replyA1 { 144 + replyA1Thread = reply 145 + break 146 + } 147 + } 148 + require.NotNil(t, replyA1Thread, "Reply A1 should be in results") 149 + 150 + // Verify Reply A1 has nested replies (at depth 2) 151 + require.NotNil(t, replyA1Thread.Replies, "Reply A1 should have nested replies at depth 2") 152 + assert.Len(t, replyA1Thread.Replies, 2, "Reply A1 should have 2 nested replies (A1a and A1b)") 153 + 154 + // Verify reply URIs 155 + replyURIs := make(map[string]bool) 156 + for _, r := range replyA1Thread.Replies { 157 + replyURIs[r.Comment.URI] = true 158 + } 159 + assert.True(t, replyURIs[replyA1a], "Reply A1a should be present") 160 + assert.True(t, replyURIs[replyA1b], "Reply A1b should be present") 161 + 162 + // Verify no deeper nesting (depth limit enforced) 163 + for _, r := range replyA1Thread.Replies { 164 + assert.Nil(t, r.Replies, "Replies at depth 2 should not have further nesting") 165 + } 166 + 167 + _ = commentB 168 + _ = replyA2 169 + } 170 + 171 + // TestCommentQuery_DepthLimit tests depth limiting works correctly 172 + func TestCommentQuery_DepthLimit(t *testing.T) { 173 + db := setupTestDB(t) 174 + defer func() { 175 + if err := db.Close(); err != nil { 176 + t.Logf("Failed to close database: %v", err) 177 + } 178 + }() 179 + 180 + ctx := context.Background() 181 + testUser := createTestUser(t, db, "depth.test", "did:plc:depth123") 182 + testCommunity, err := createFeedTestCommunity(db, ctx, "depthcomm", "ownerdepth.test") 183 + require.NoError(t, err) 184 + 185 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Depth Test Post", 0, time.Now()) 186 + 187 + // Create deeply nested thread (5 levels) 188 + // Post -> C1 -> C2 -> C3 -> C4 -> C5 189 + c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Level 1", 5, 0, time.Now().Add(-5*time.Minute)) 190 + c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, c1, "Level 2", 4, 0, time.Now().Add(-4*time.Minute)) 191 + c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, c2, "Level 3", 3, 0, time.Now().Add(-3*time.Minute)) 192 + c4 := createTestCommentWithScore(t, db, testUser.DID, postURI, c3, "Level 4", 2, 0, time.Now().Add(-2*time.Minute)) 193 + c5 := createTestCommentWithScore(t, db, testUser.DID, postURI, c4, "Level 5", 1, 0, time.Now().Add(-1*time.Minute)) 194 + 195 + t.Run("Depth 0 returns flat list", func(t *testing.T) { 196 + service := setupCommentService(db) 197 + req := &comments.GetCommentsRequest{ 198 + PostURI: postURI, 199 + Sort: "new", 200 + Depth: 0, 201 + Limit: 50, 202 + } 203 + 204 + resp, err := service.GetComments(ctx, req) 205 + require.NoError(t, err) 206 + require.Len(t, resp.Comments, 1, "Should return 1 top-level comment") 207 + 208 + // Verify no replies included 209 + assert.Nil(t, resp.Comments[0].Replies, "Depth 0 should not include replies") 210 + 211 + // Verify HasMore flag is set (c1 has replies) 212 + assert.True(t, resp.Comments[0].HasMore, "HasMore should be true when replies exist but depth=0") 213 + }) 214 + 215 + t.Run("Depth 3 returns exactly 3 levels", func(t *testing.T) { 216 + service := setupCommentService(db) 217 + req := &comments.GetCommentsRequest{ 218 + PostURI: postURI, 219 + Sort: "new", 220 + Depth: 3, 221 + Limit: 50, 222 + } 223 + 224 + resp, err := service.GetComments(ctx, req) 225 + require.NoError(t, err) 226 + require.Len(t, resp.Comments, 1, "Should return 1 top-level comment") 227 + 228 + // Traverse and verify exactly 3 levels 229 + level1 := resp.Comments[0] 230 + require.NotNil(t, level1.Replies, "Level 1 should have replies") 231 + require.Len(t, level1.Replies, 1, "Level 1 should have 1 reply") 232 + 233 + level2 := level1.Replies[0] 234 + require.NotNil(t, level2.Replies, "Level 2 should have replies") 235 + require.Len(t, level2.Replies, 1, "Level 2 should have 1 reply") 236 + 237 + level3 := level2.Replies[0] 238 + require.NotNil(t, level3.Replies, "Level 3 should have replies") 239 + require.Len(t, level3.Replies, 1, "Level 3 should have 1 reply") 240 + 241 + // Level 4 should NOT have replies (depth limit) 242 + level4 := level3.Replies[0] 243 + assert.Nil(t, level4.Replies, "Level 4 should not have replies (depth limit)") 244 + 245 + // Verify HasMore is set correctly at depth boundary 246 + assert.True(t, level4.HasMore, "HasMore should be true at depth boundary when more replies exist") 247 + }) 248 + 249 + _ = c2 250 + _ = c3 251 + _ = c4 252 + _ = c5 253 + } 254 + 255 + // TestCommentQuery_HotSorting tests hot sorting with Lemmy algorithm 256 + func TestCommentQuery_HotSorting(t *testing.T) { 257 + db := setupTestDB(t) 258 + defer func() { 259 + if err := db.Close(); err != nil { 260 + t.Logf("Failed to close database: %v", err) 261 + } 262 + }() 263 + 264 + ctx := context.Background() 265 + testUser := createTestUser(t, db, "hot.test", "did:plc:hot123") 266 + testCommunity, err := createFeedTestCommunity(db, ctx, "hotcomm", "ownerhot.test") 267 + require.NoError(t, err) 268 + 269 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Hot Sorting Test", 0, time.Now()) 270 + 271 + // Create 3 comments with different scores and ages 272 + // Comment 1: score=10, created 1 hour ago 273 + c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Old high score", 10, 0, time.Now().Add(-1*time.Hour)) 274 + 275 + // Comment 2: score=5, created 5 minutes ago (should rank higher due to recency) 276 + c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Recent medium score", 5, 0, time.Now().Add(-5*time.Minute)) 277 + 278 + // Comment 3: score=-2, created now (negative score should rank lower) 279 + c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Negative score", 0, 2, time.Now()) 280 + 281 + service := setupCommentService(db) 282 + req := &comments.GetCommentsRequest{ 283 + PostURI: postURI, 284 + Sort: "hot", 285 + Depth: 0, 286 + Limit: 50, 287 + } 288 + 289 + resp, err := service.GetComments(ctx, req) 290 + require.NoError(t, err) 291 + require.Len(t, resp.Comments, 3, "Should return all 3 comments") 292 + 293 + // Verify hot sorting order 294 + // Recent comment with medium score should rank higher than old comment with high score 295 + assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Recent medium score should rank first") 296 + assert.Equal(t, c1, resp.Comments[1].Comment.URI, "Old high score should rank second") 297 + assert.Equal(t, c3, resp.Comments[2].Comment.URI, "Negative score should rank last") 298 + 299 + // Verify negative scores are handled gracefully 300 + negativeComment := resp.Comments[2].Comment 301 + assert.Equal(t, -2, negativeComment.Stats.Score, "Negative score should be preserved") 302 + assert.Equal(t, 0, negativeComment.Stats.Upvotes, "Upvotes should be 0") 303 + assert.Equal(t, 2, negativeComment.Stats.Downvotes, "Downvotes should be 2") 304 + } 305 + 306 + // TestCommentQuery_TopSorting tests top sorting with score-based ordering 307 + func TestCommentQuery_TopSorting(t *testing.T) { 308 + db := setupTestDB(t) 309 + defer func() { 310 + if err := db.Close(); err != nil { 311 + t.Logf("Failed to close database: %v", err) 312 + } 313 + }() 314 + 315 + ctx := context.Background() 316 + testUser := createTestUser(t, db, "top.test", "did:plc:top123") 317 + testCommunity, err := createFeedTestCommunity(db, ctx, "topcomm", "ownertop.test") 318 + require.NoError(t, err) 319 + 320 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Top Sorting Test", 0, time.Now()) 321 + 322 + // Create comments with different scores 323 + c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Low score", 2, 0, time.Now().Add(-30*time.Minute)) 324 + c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "High score", 10, 0, time.Now().Add(-1*time.Hour)) 325 + c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Medium score", 5, 0, time.Now().Add(-15*time.Minute)) 326 + 327 + t.Run("Top sort without timeframe", func(t *testing.T) { 328 + service := setupCommentService(db) 329 + req := &comments.GetCommentsRequest{ 330 + PostURI: postURI, 331 + Sort: "top", 332 + Depth: 0, 333 + Limit: 50, 334 + } 335 + 336 + resp, err := service.GetComments(ctx, req) 337 + require.NoError(t, err) 338 + require.Len(t, resp.Comments, 3) 339 + 340 + // Verify highest score first 341 + assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Highest score should be first") 342 + assert.Equal(t, c3, resp.Comments[1].Comment.URI, "Medium score should be second") 343 + assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Low score should be third") 344 + }) 345 + 346 + t.Run("Top sort with hour timeframe", func(t *testing.T) { 347 + service := setupCommentService(db) 348 + req := &comments.GetCommentsRequest{ 349 + PostURI: postURI, 350 + Sort: "top", 351 + Timeframe: "hour", 352 + Depth: 0, 353 + Limit: 50, 354 + } 355 + 356 + resp, err := service.GetComments(ctx, req) 357 + require.NoError(t, err) 358 + 359 + // Only comments from last hour should be included (c1 and c3, not c2) 360 + assert.LessOrEqual(t, len(resp.Comments), 2, "Should exclude comments older than 1 hour") 361 + 362 + // Verify c2 (created 1 hour ago) is excluded 363 + for _, tv := range resp.Comments { 364 + assert.NotEqual(t, c2, tv.Comment.URI, "Comment older than 1 hour should be excluded") 365 + } 366 + }) 367 + } 368 + 369 + // TestCommentQuery_NewSorting tests chronological sorting 370 + func TestCommentQuery_NewSorting(t *testing.T) { 371 + db := setupTestDB(t) 372 + defer func() { 373 + if err := db.Close(); err != nil { 374 + t.Logf("Failed to close database: %v", err) 375 + } 376 + }() 377 + 378 + ctx := context.Background() 379 + testUser := createTestUser(t, db, "new.test", "did:plc:new123") 380 + testCommunity, err := createFeedTestCommunity(db, ctx, "newcomm", "ownernew.test") 381 + require.NoError(t, err) 382 + 383 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "New Sorting Test", 0, time.Now()) 384 + 385 + // Create comments at different times (different scores to verify time is priority) 386 + c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Oldest", 10, 0, time.Now().Add(-1*time.Hour)) 387 + c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Middle", 5, 0, time.Now().Add(-30*time.Minute)) 388 + c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Newest", 2, 0, time.Now().Add(-5*time.Minute)) 389 + 390 + service := setupCommentService(db) 391 + req := &comments.GetCommentsRequest{ 392 + PostURI: postURI, 393 + Sort: "new", 394 + Depth: 0, 395 + Limit: 50, 396 + } 397 + 398 + resp, err := service.GetComments(ctx, req) 399 + require.NoError(t, err) 400 + require.Len(t, resp.Comments, 3) 401 + 402 + // Verify chronological order (newest first) 403 + assert.Equal(t, c3, resp.Comments[0].Comment.URI, "Newest comment should be first") 404 + assert.Equal(t, c2, resp.Comments[1].Comment.URI, "Middle comment should be second") 405 + assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Oldest comment should be third") 406 + } 407 + 408 + // TestCommentQuery_Pagination tests cursor-based pagination 409 + func TestCommentQuery_Pagination(t *testing.T) { 410 + db := setupTestDB(t) 411 + defer func() { 412 + if err := db.Close(); err != nil { 413 + t.Logf("Failed to close database: %v", err) 414 + } 415 + }() 416 + 417 + ctx := context.Background() 418 + testUser := createTestUser(t, db, "page.test", "did:plc:page123") 419 + testCommunity, err := createFeedTestCommunity(db, ctx, "pagecomm", "ownerpage.test") 420 + require.NoError(t, err) 421 + 422 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Pagination Test", 0, time.Now()) 423 + 424 + // Create 60 comments 425 + allCommentURIs := make([]string, 60) 426 + for i := 0; i < 60; i++ { 427 + uri := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, 428 + fmt.Sprintf("Comment %d", i), i, 0, time.Now().Add(-time.Duration(60-i)*time.Minute)) 429 + allCommentURIs[i] = uri 430 + } 431 + 432 + service := setupCommentService(db) 433 + 434 + // Fetch first page (limit=50) 435 + req1 := &comments.GetCommentsRequest{ 436 + PostURI: postURI, 437 + Sort: "new", 438 + Depth: 0, 439 + Limit: 50, 440 + } 441 + 442 + resp1, err := service.GetComments(ctx, req1) 443 + require.NoError(t, err) 444 + assert.Len(t, resp1.Comments, 50, "First page should have 50 comments") 445 + require.NotNil(t, resp1.Cursor, "Cursor should be present for next page") 446 + 447 + // Fetch second page with cursor 448 + req2 := &comments.GetCommentsRequest{ 449 + PostURI: postURI, 450 + Sort: "new", 451 + Depth: 0, 452 + Limit: 50, 453 + Cursor: resp1.Cursor, 454 + } 455 + 456 + resp2, err := service.GetComments(ctx, req2) 457 + require.NoError(t, err) 458 + assert.Len(t, resp2.Comments, 10, "Second page should have remaining 10 comments") 459 + assert.Nil(t, resp2.Cursor, "Cursor should be nil on last page") 460 + 461 + // Verify no duplicates between pages 462 + page1URIs := make(map[string]bool) 463 + for _, tv := range resp1.Comments { 464 + page1URIs[tv.Comment.URI] = true 465 + } 466 + 467 + for _, tv := range resp2.Comments { 468 + assert.False(t, page1URIs[tv.Comment.URI], "Comment %s should not appear in both pages", tv.Comment.URI) 469 + } 470 + 471 + // Verify all comments eventually retrieved 472 + allRetrieved := make(map[string]bool) 473 + for _, tv := range resp1.Comments { 474 + allRetrieved[tv.Comment.URI] = true 475 + } 476 + for _, tv := range resp2.Comments { 477 + allRetrieved[tv.Comment.URI] = true 478 + } 479 + assert.Len(t, allRetrieved, 60, "All 60 comments should be retrieved across pages") 480 + } 481 + 482 + // TestCommentQuery_EmptyThread tests fetching comments from a post with no comments 483 + func TestCommentQuery_EmptyThread(t *testing.T) { 484 + db := setupTestDB(t) 485 + defer func() { 486 + if err := db.Close(); err != nil { 487 + t.Logf("Failed to close database: %v", err) 488 + } 489 + }() 490 + 491 + ctx := context.Background() 492 + testUser := createTestUser(t, db, "empty.test", "did:plc:empty123") 493 + testCommunity, err := createFeedTestCommunity(db, ctx, "emptycomm", "ownerempty.test") 494 + require.NoError(t, err) 495 + 496 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Empty Thread Test", 0, time.Now()) 497 + 498 + service := setupCommentService(db) 499 + req := &comments.GetCommentsRequest{ 500 + PostURI: postURI, 501 + Sort: "hot", 502 + Depth: 10, 503 + Limit: 50, 504 + } 505 + 506 + resp, err := service.GetComments(ctx, req) 507 + require.NoError(t, err) 508 + require.NotNil(t, resp, "Response should not be nil") 509 + 510 + // Verify empty array (not null) 511 + assert.NotNil(t, resp.Comments, "Comments array should not be nil") 512 + assert.Len(t, resp.Comments, 0, "Comments array should be empty") 513 + 514 + // Verify no cursor returned 515 + assert.Nil(t, resp.Cursor, "Cursor should be nil for empty results") 516 + } 517 + 518 + // TestCommentQuery_DeletedComments tests that soft-deleted comments are excluded 519 + func TestCommentQuery_DeletedComments(t *testing.T) { 520 + db := setupTestDB(t) 521 + defer func() { 522 + if err := db.Close(); err != nil { 523 + t.Logf("Failed to close database: %v", err) 524 + } 525 + }() 526 + 527 + ctx := context.Background() 528 + commentRepo := postgres.NewCommentRepository(db) 529 + consumer := jetstream.NewCommentEventConsumer(commentRepo, db) 530 + 531 + testUser := createTestUser(t, db, "deleted.test", "did:plc:deleted123") 532 + testCommunity, err := createFeedTestCommunity(db, ctx, "deletedcomm", "ownerdeleted.test") 533 + require.NoError(t, err) 534 + 535 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "Deleted Comments Test", 0, time.Now()) 536 + 537 + // Create 5 comments via Jetstream consumer 538 + commentURIs := make([]string, 5) 539 + for i := 0; i < 5; i++ { 540 + rkey := generateTID() 541 + uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, rkey) 542 + commentURIs[i] = uri 543 + 544 + event := &jetstream.JetstreamEvent{ 545 + Did: testUser.DID, 546 + Kind: "commit", 547 + Commit: &jetstream.CommitEvent{ 548 + Operation: "create", 549 + Collection: "social.coves.feed.comment", 550 + RKey: rkey, 551 + CID: fmt.Sprintf("bafyc%d", i), 552 + Record: map[string]interface{}{ 553 + "$type": "social.coves.feed.comment", 554 + "content": fmt.Sprintf("Comment %d", i), 555 + "reply": map[string]interface{}{ 556 + "root": map[string]interface{}{ 557 + "uri": postURI, 558 + "cid": "bafypost", 559 + }, 560 + "parent": map[string]interface{}{ 561 + "uri": postURI, 562 + "cid": "bafypost", 563 + }, 564 + }, 565 + "createdAt": time.Now().Add(time.Duration(i) * time.Minute).Format(time.RFC3339), 566 + }, 567 + }, 568 + } 569 + 570 + require.NoError(t, consumer.HandleEvent(ctx, event)) 571 + } 572 + 573 + // Soft-delete 2 comments (index 1 and 3) 574 + deleteEvent1 := &jetstream.JetstreamEvent{ 575 + Did: testUser.DID, 576 + Kind: "commit", 577 + Commit: &jetstream.CommitEvent{ 578 + Operation: "delete", 579 + Collection: "social.coves.feed.comment", 580 + RKey: strings.Split(commentURIs[1], "/")[4], 581 + }, 582 + } 583 + require.NoError(t, consumer.HandleEvent(ctx, deleteEvent1)) 584 + 585 + deleteEvent2 := &jetstream.JetstreamEvent{ 586 + Did: testUser.DID, 587 + Kind: "commit", 588 + Commit: &jetstream.CommitEvent{ 589 + Operation: "delete", 590 + Collection: "social.coves.feed.comment", 591 + RKey: strings.Split(commentURIs[3], "/")[4], 592 + }, 593 + } 594 + require.NoError(t, consumer.HandleEvent(ctx, deleteEvent2)) 595 + 596 + // Fetch comments 597 + service := setupCommentService(db) 598 + req := &comments.GetCommentsRequest{ 599 + PostURI: postURI, 600 + Sort: "new", 601 + Depth: 0, 602 + Limit: 50, 603 + } 604 + 605 + resp, err := service.GetComments(ctx, req) 606 + require.NoError(t, err) 607 + 608 + // Verify only 3 comments returned (2 were deleted) 609 + assert.Len(t, resp.Comments, 3, "Should only return non-deleted comments") 610 + 611 + // Verify deleted comments are not in results 612 + returnedURIs := make(map[string]bool) 613 + for _, tv := range resp.Comments { 614 + returnedURIs[tv.Comment.URI] = true 615 + } 616 + 617 + assert.False(t, returnedURIs[commentURIs[1]], "Deleted comment 1 should not be in results") 618 + assert.False(t, returnedURIs[commentURIs[3]], "Deleted comment 3 should not be in results") 619 + assert.True(t, returnedURIs[commentURIs[0]], "Non-deleted comment 0 should be in results") 620 + assert.True(t, returnedURIs[commentURIs[2]], "Non-deleted comment 2 should be in results") 621 + assert.True(t, returnedURIs[commentURIs[4]], "Non-deleted comment 4 should be in results") 622 + } 623 + 624 + // TestCommentQuery_InvalidInputs tests error handling for invalid inputs 625 + func TestCommentQuery_InvalidInputs(t *testing.T) { 626 + db := setupTestDB(t) 627 + defer func() { 628 + if err := db.Close(); err != nil { 629 + t.Logf("Failed to close database: %v", err) 630 + } 631 + }() 632 + 633 + ctx := context.Background() 634 + service := setupCommentService(db) 635 + 636 + t.Run("Invalid post URI", func(t *testing.T) { 637 + req := &comments.GetCommentsRequest{ 638 + PostURI: "not-an-at-uri", 639 + Sort: "hot", 640 + Depth: 10, 641 + Limit: 50, 642 + } 643 + 644 + _, err := service.GetComments(ctx, req) 645 + assert.Error(t, err, "Should return error for invalid AT-URI") 646 + assert.Contains(t, err.Error(), "invalid", "Error should mention invalid") 647 + }) 648 + 649 + t.Run("Negative depth", func(t *testing.T) { 650 + req := &comments.GetCommentsRequest{ 651 + PostURI: "at://did:plc:test/social.coves.feed.post/abc123", 652 + Sort: "hot", 653 + Depth: -5, 654 + Limit: 50, 655 + } 656 + 657 + resp, err := service.GetComments(ctx, req) 658 + // Should not error, but should clamp to default (10) 659 + require.NoError(t, err) 660 + // Depth is normalized in validation 661 + _ = resp 662 + }) 663 + 664 + t.Run("Depth exceeds max", func(t *testing.T) { 665 + req := &comments.GetCommentsRequest{ 666 + PostURI: "at://did:plc:test/social.coves.feed.post/abc123", 667 + Sort: "hot", 668 + Depth: 150, // Exceeds max of 100 669 + Limit: 50, 670 + } 671 + 672 + resp, err := service.GetComments(ctx, req) 673 + // Should not error, but should clamp to 100 674 + require.NoError(t, err) 675 + _ = resp 676 + }) 677 + 678 + t.Run("Limit exceeds max", func(t *testing.T) { 679 + req := &comments.GetCommentsRequest{ 680 + PostURI: "at://did:plc:test/social.coves.feed.post/abc123", 681 + Sort: "hot", 682 + Depth: 10, 683 + Limit: 150, // Exceeds max of 100 684 + } 685 + 686 + resp, err := service.GetComments(ctx, req) 687 + // Should not error, but should clamp to 100 688 + require.NoError(t, err) 689 + _ = resp 690 + }) 691 + 692 + t.Run("Invalid sort", func(t *testing.T) { 693 + req := &comments.GetCommentsRequest{ 694 + PostURI: "at://did:plc:test/social.coves.feed.post/abc123", 695 + Sort: "invalid", 696 + Depth: 10, 697 + Limit: 50, 698 + } 699 + 700 + _, err := service.GetComments(ctx, req) 701 + assert.Error(t, err, "Should return error for invalid sort") 702 + assert.Contains(t, err.Error(), "invalid sort", "Error should mention invalid sort") 703 + }) 704 + 705 + t.Run("Empty post URI", func(t *testing.T) { 706 + req := &comments.GetCommentsRequest{ 707 + PostURI: "", 708 + Sort: "hot", 709 + Depth: 10, 710 + Limit: 50, 711 + } 712 + 713 + _, err := service.GetComments(ctx, req) 714 + assert.Error(t, err, "Should return error for empty post URI") 715 + }) 716 + } 717 + 718 + // TestCommentQuery_HTTPHandler tests the HTTP handler end-to-end 719 + func TestCommentQuery_HTTPHandler(t *testing.T) { 720 + db := setupTestDB(t) 721 + defer func() { 722 + if err := db.Close(); err != nil { 723 + t.Logf("Failed to close database: %v", err) 724 + } 725 + }() 726 + 727 + ctx := context.Background() 728 + testUser := createTestUser(t, db, "http.test", "did:plc:http123") 729 + testCommunity, err := createFeedTestCommunity(db, ctx, "httpcomm", "ownerhttp.test") 730 + require.NoError(t, err) 731 + 732 + postURI := createTestPost(t, db, testCommunity, testUser.DID, "HTTP Handler Test", 0, time.Now()) 733 + 734 + // Create test comments 735 + createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 1", 5, 0, time.Now().Add(-30*time.Minute)) 736 + createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 2", 3, 0, time.Now().Add(-15*time.Minute)) 737 + 738 + // Setup service adapter for HTTP handler 739 + service := setupCommentServiceAdapter(db) 740 + handler := &testGetCommentsHandler{service: service} 741 + 742 + t.Run("Valid GET request", func(t *testing.T) { 743 + req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&sort=hot&depth=10&limit=50", postURI), nil) 744 + w := httptest.NewRecorder() 745 + 746 + handler.ServeHTTP(w, req) 747 + 748 + assert.Equal(t, http.StatusOK, w.Code) 749 + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) 750 + 751 + var resp comments.GetCommentsResponse 752 + err := json.NewDecoder(w.Body).Decode(&resp) 753 + require.NoError(t, err) 754 + assert.Len(t, resp.Comments, 2, "Should return 2 comments") 755 + }) 756 + 757 + t.Run("Missing post parameter", func(t *testing.T) { 758 + req := httptest.NewRequest("GET", "/xrpc/social.coves.feed.getComments?sort=hot", nil) 759 + w := httptest.NewRecorder() 760 + 761 + handler.ServeHTTP(w, req) 762 + 763 + assert.Equal(t, http.StatusBadRequest, w.Code) 764 + }) 765 + 766 + t.Run("Invalid depth parameter", func(t *testing.T) { 767 + req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&depth=invalid", postURI), nil) 768 + w := httptest.NewRecorder() 769 + 770 + handler.ServeHTTP(w, req) 771 + 772 + assert.Equal(t, http.StatusBadRequest, w.Code) 773 + }) 774 + } 775 + 776 + // Helper: setupCommentService creates a comment service for testing 777 + func setupCommentService(db *sql.DB) comments.Service { 778 + commentRepo := postgres.NewCommentRepository(db) 779 + postRepo := postgres.NewPostRepository(db) 780 + userRepo := postgres.NewUserRepository(db) 781 + communityRepo := postgres.NewCommunityRepository(db) 782 + return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 783 + } 784 + 785 + // Helper: createTestCommentWithScore creates a comment with specific vote counts 786 + func createTestCommentWithScore(t *testing.T, db *sql.DB, commenterDID, rootURI, parentURI, content string, upvotes, downvotes int, createdAt time.Time) string { 787 + t.Helper() 788 + 789 + ctx := context.Background() 790 + rkey := generateTID() 791 + uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", commenterDID, rkey) 792 + 793 + // Insert comment directly for speed 794 + _, err := db.ExecContext(ctx, ` 795 + INSERT INTO comments ( 796 + uri, cid, rkey, commenter_did, 797 + root_uri, root_cid, parent_uri, parent_cid, 798 + content, created_at, indexed_at, 799 + upvote_count, downvote_count, score 800 + ) VALUES ( 801 + $1, $2, $3, $4, 802 + $5, $6, $7, $8, 803 + $9, $10, NOW(), 804 + $11, $12, $13 805 + ) 806 + `, uri, fmt.Sprintf("bafyc%s", rkey), rkey, commenterDID, 807 + rootURI, "bafyroot", parentURI, "bafyparent", 808 + content, createdAt, 809 + upvotes, downvotes, upvotes-downvotes) 810 + 811 + require.NoError(t, err, "Failed to create test comment") 812 + 813 + // Update reply count on parent if it's a nested comment 814 + if parentURI != rootURI { 815 + _, _ = db.ExecContext(ctx, ` 816 + UPDATE comments 817 + SET reply_count = reply_count + 1 818 + WHERE uri = $1 819 + `, parentURI) 820 + } else { 821 + // Update comment count on post if top-level 822 + _, _ = db.ExecContext(ctx, ` 823 + UPDATE posts 824 + SET comment_count = comment_count + 1 825 + WHERE uri = $1 826 + `, parentURI) 827 + } 828 + 829 + return uri 830 + } 831 + 832 + // Helper: Service adapter for HTTP handler testing 833 + type testCommentServiceAdapter struct { 834 + service comments.Service 835 + } 836 + 837 + func (s *testCommentServiceAdapter) GetComments(r *http.Request, req *testGetCommentsRequest) (*comments.GetCommentsResponse, error) { 838 + ctx := r.Context() 839 + 840 + serviceReq := &comments.GetCommentsRequest{ 841 + PostURI: req.PostURI, 842 + Sort: req.Sort, 843 + Timeframe: req.Timeframe, 844 + Depth: req.Depth, 845 + Limit: req.Limit, 846 + Cursor: req.Cursor, 847 + ViewerDID: req.ViewerDID, 848 + } 849 + 850 + return s.service.GetComments(ctx, serviceReq) 851 + } 852 + 853 + type testGetCommentsRequest struct { 854 + Cursor *string 855 + ViewerDID *string 856 + PostURI string 857 + Sort string 858 + Timeframe string 859 + Depth int 860 + Limit int 861 + } 862 + 863 + func setupCommentServiceAdapter(db *sql.DB) *testCommentServiceAdapter { 864 + commentRepo := postgres.NewCommentRepository(db) 865 + postRepo := postgres.NewPostRepository(db) 866 + userRepo := postgres.NewUserRepository(db) 867 + communityRepo := postgres.NewCommunityRepository(db) 868 + service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 869 + return &testCommentServiceAdapter{service: service} 870 + } 871 + 872 + // Helper: Simple HTTP handler wrapper for testing 873 + type testGetCommentsHandler struct { 874 + service *testCommentServiceAdapter 875 + } 876 + 877 + func (h *testGetCommentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 878 + if r.Method != http.MethodGet { 879 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 880 + return 881 + } 882 + 883 + query := r.URL.Query() 884 + post := query.Get("post") 885 + 886 + if post == "" { 887 + http.Error(w, "post parameter is required", http.StatusBadRequest) 888 + return 889 + } 890 + 891 + sort := query.Get("sort") 892 + if sort == "" { 893 + sort = "hot" 894 + } 895 + 896 + depth := 10 897 + if d := query.Get("depth"); d != "" { 898 + if _, err := fmt.Sscanf(d, "%d", &depth); err != nil { 899 + http.Error(w, "invalid depth", http.StatusBadRequest) 900 + return 901 + } 902 + } 903 + 904 + limit := 50 905 + if l := query.Get("limit"); l != "" { 906 + if _, err := fmt.Sscanf(l, "%d", &limit); err != nil { 907 + http.Error(w, "invalid limit", http.StatusBadRequest) 908 + return 909 + } 910 + } 911 + 912 + req := &testGetCommentsRequest{ 913 + PostURI: post, 914 + Sort: sort, 915 + Depth: depth, 916 + Limit: limit, 917 + } 918 + 919 + resp, err := h.service.GetComments(r, req) 920 + if err != nil { 921 + http.Error(w, err.Error(), http.StatusInternalServerError) 922 + return 923 + } 924 + 925 + w.Header().Set("Content-Type", "application/json") 926 + w.WriteHeader(http.StatusOK) 927 + _ = json.NewEncoder(w).Encode(resp) 928 + }
+3 -6
tests/integration/community_consumer_test.go
··· 369 369 370 370 // mockIdentityResolver is a test double for identity resolution 371 371 type mockIdentityResolver struct { 372 - // Map of DID -> handle for successful resolutions 373 372 resolutions map[string]string 374 - // If true, Resolve returns an error 375 - shouldFail bool 376 - // Track calls to verify invocation 377 - callCount int 378 - lastDID string 373 + lastDID string 374 + callCount int 375 + shouldFail bool 379 376 } 380 377 381 378 func newMockIdentityResolver() *mockIdentityResolver {
-12
tests/integration/user_test.go
··· 70 70 return db 71 71 } 72 72 73 - // setupIdentityResolver creates an identity resolver configured for local PLC testing 74 - func setupIdentityResolver(db *sql.DB) interface{ Resolve(context.Context, string) (*identity.Identity, error) } { 75 - plcURL := os.Getenv("PLC_DIRECTORY_URL") 76 - if plcURL == "" { 77 - plcURL = "http://localhost:3002" // Local PLC directory 78 - } 79 - 80 - config := identity.DefaultConfig() 81 - config.PLCURL = plcURL 82 - return identity.NewResolver(db, config) 83 - } 84 - 85 73 // generateTestDID generates a unique test DID for integration tests 86 74 // V2.0: No longer uses DID generator - just creates valid did:plc strings 87 75 func generateTestDID(suffix string) string {
+6 -6
tests/lexicon_validation_test.go
··· 72 72 73 73 // Test specific cross-references that should work 74 74 crossRefs := map[string]string{ 75 - "social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema", 76 - "social.coves.community.rules#rule": "rule definition in community rules", 77 - "social.coves.actor.defs#profileView": "profileView definition in actor defs", 78 - "social.coves.actor.defs#profileStats": "profileStats definition in actor defs", 79 - "social.coves.actor.defs#viewerState": "viewerState definition in actor defs", 80 - "social.coves.community.defs#communityView": "communityView definition in community defs", 75 + "social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema", 76 + "social.coves.community.rules#rule": "rule definition in community rules", 77 + "social.coves.actor.defs#profileView": "profileView definition in actor defs", 78 + "social.coves.actor.defs#profileStats": "profileStats definition in actor defs", 79 + "social.coves.actor.defs#viewerState": "viewerState definition in actor defs", 80 + "social.coves.community.defs#communityView": "communityView definition in community defs", 81 81 "social.coves.community.defs#communityStats": "communityStats definition in community defs", 82 82 } 83 83