A community based topic aggregation platform built on atproto
1package integration 2 3import ( 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 22func 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 82func 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 172func 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 256func 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 307func 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 370func 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 409func 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 483func 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 519func 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 625func 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 719func 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 777func 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 786func 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 833type testCommentServiceAdapter struct { 834 service comments.Service 835} 836 837func (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 853type 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 863func 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 873type testGetCommentsHandler struct { 874 service *testCommentServiceAdapter 875} 876 877func (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}