A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "fmt" 8 "net/http" 9 "net/http/httptest" 10 "strings" 11 "testing" 12 "time" 13 14 "Coves/internal/atproto/jetstream" 15 "Coves/internal/core/comments" 16 "Coves/internal/db/postgres" 17 18 "github.com/stretchr/testify/assert" 19 "github.com/stretchr/testify/require" 20) 21 22// TestCommentQuery_BasicFetch tests fetching top-level comments with default params 23func TestCommentQuery_BasicFetch(t *testing.T) { 24 db := setupTestDB(t) 25 defer func() { 26 if err := db.Close(); err != nil { 27 t.Logf("Failed to close database: %v", err) 28 } 29 }() 30 31 ctx := context.Background() 32 testUser := createTestUser(t, db, "basicfetch.test", "did:plc:basicfetch123") 33 testCommunity, err := createFeedTestCommunity(db, ctx, "basicfetchcomm", "ownerbasic.test") 34 require.NoError(t, err, "Failed to create test community") 35 36 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Basic Fetch Test Post", 0, time.Now()) 37 38 // Create 3 top-level comments with different scores and ages 39 comment1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "First comment", 10, 2, time.Now().Add(-2*time.Hour)) 40 comment2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Second comment", 5, 1, time.Now().Add(-30*time.Minute)) 41 comment3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Third comment", 3, 0, time.Now().Add(-5*time.Minute)) 42 43 // Fetch comments with default params (hot sort) 44 service := setupCommentService(db) 45 req := &comments.GetCommentsRequest{ 46 PostURI: postURI, 47 Sort: "hot", 48 Depth: 10, 49 Limit: 50, 50 } 51 52 resp, err := service.GetComments(ctx, req) 53 require.NoError(t, err, "GetComments should not return error") 54 require.NotNil(t, resp, "Response should not be nil") 55 56 // Verify all 3 comments returned 57 assert.Len(t, resp.Comments, 3, "Should return all 3 top-level comments") 58 59 // Verify stats are correct 60 for _, threadView := range resp.Comments { 61 commentView := threadView.Comment 62 assert.NotNil(t, commentView.Stats, "Stats should not be nil") 63 64 // Verify upvotes, downvotes, score, reply count present 65 assert.GreaterOrEqual(t, commentView.Stats.Upvotes, 0, "Upvotes should be non-negative") 66 assert.GreaterOrEqual(t, commentView.Stats.Downvotes, 0, "Downvotes should be non-negative") 67 assert.Equal(t, 0, commentView.Stats.ReplyCount, "Top-level comments should have 0 replies") 68 } 69 70 // Verify URIs match 71 commentURIs := []string{comment1, comment2, comment3} 72 returnedURIs := make(map[string]bool) 73 for _, tv := range resp.Comments { 74 returnedURIs[tv.Comment.URI] = true 75 } 76 77 for _, uri := range commentURIs { 78 assert.True(t, returnedURIs[uri], "Comment URI %s should be in results", uri) 79 } 80} 81 82// TestCommentQuery_NestedReplies tests fetching comments with nested reply structure 83func TestCommentQuery_NestedReplies(t *testing.T) { 84 db := setupTestDB(t) 85 defer func() { 86 if err := db.Close(); err != nil { 87 t.Logf("Failed to close database: %v", err) 88 } 89 }() 90 91 ctx := context.Background() 92 testUser := createTestUser(t, db, "nested.test", "did:plc:nested123") 93 testCommunity, err := createFeedTestCommunity(db, ctx, "nestedcomm", "ownernested.test") 94 require.NoError(t, err) 95 96 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Nested Test Post", 0, time.Now()) 97 98 // Create nested structure: 99 // Post 100 // |- Comment A (top-level) 101 // |- Reply A1 102 // |- Reply A1a 103 // |- Reply A1b 104 // |- Reply A2 105 // |- Comment B (top-level) 106 107 commentA := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment A", 5, 0, time.Now().Add(-1*time.Hour)) 108 replyA1 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A1", 3, 0, time.Now().Add(-50*time.Minute)) 109 replyA1a := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1a", 2, 0, time.Now().Add(-40*time.Minute)) 110 replyA1b := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1b", 1, 0, time.Now().Add(-30*time.Minute)) 111 replyA2 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A2", 2, 0, time.Now().Add(-20*time.Minute)) 112 commentB := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment B", 4, 0, time.Now().Add(-10*time.Minute)) 113 114 // Fetch with depth=2 (should get 2 levels of nesting) 115 service := setupCommentService(db) 116 req := &comments.GetCommentsRequest{ 117 PostURI: postURI, 118 Sort: "new", 119 Depth: 2, 120 Limit: 50, 121 } 122 123 resp, err := service.GetComments(ctx, req) 124 require.NoError(t, err) 125 require.Len(t, resp.Comments, 2, "Should return 2 top-level comments") 126 127 // Find Comment A in results 128 var commentAThread *comments.ThreadViewComment 129 for _, tv := range resp.Comments { 130 if tv.Comment.URI == commentA { 131 commentAThread = tv 132 break 133 } 134 } 135 require.NotNil(t, commentAThread, "Comment A should be in results") 136 137 // Verify Comment A has replies 138 require.NotNil(t, commentAThread.Replies, "Comment A should have replies") 139 assert.Len(t, commentAThread.Replies, 2, "Comment A should have 2 direct replies (A1 and A2)") 140 141 // Find Reply A1 142 var replyA1Thread *comments.ThreadViewComment 143 for _, reply := range commentAThread.Replies { 144 if reply.Comment.URI == replyA1 { 145 replyA1Thread = reply 146 break 147 } 148 } 149 require.NotNil(t, replyA1Thread, "Reply A1 should be in results") 150 151 // Verify Reply A1 has nested replies (at depth 2) 152 require.NotNil(t, replyA1Thread.Replies, "Reply A1 should have nested replies at depth 2") 153 assert.Len(t, replyA1Thread.Replies, 2, "Reply A1 should have 2 nested replies (A1a and A1b)") 154 155 // Verify reply URIs 156 replyURIs := make(map[string]bool) 157 for _, r := range replyA1Thread.Replies { 158 replyURIs[r.Comment.URI] = true 159 } 160 assert.True(t, replyURIs[replyA1a], "Reply A1a should be present") 161 assert.True(t, replyURIs[replyA1b], "Reply A1b should be present") 162 163 // Verify no deeper nesting (depth limit enforced) 164 for _, r := range replyA1Thread.Replies { 165 assert.Nil(t, r.Replies, "Replies at depth 2 should not have further nesting") 166 } 167 168 _ = commentB 169 _ = replyA2 170} 171 172// TestCommentQuery_DepthLimit tests depth limiting works correctly 173func TestCommentQuery_DepthLimit(t *testing.T) { 174 db := setupTestDB(t) 175 defer func() { 176 if err := db.Close(); err != nil { 177 t.Logf("Failed to close database: %v", err) 178 } 179 }() 180 181 ctx := context.Background() 182 testUser := createTestUser(t, db, "depth.test", "did:plc:depth123") 183 testCommunity, err := createFeedTestCommunity(db, ctx, "depthcomm", "ownerdepth.test") 184 require.NoError(t, err) 185 186 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Depth Test Post", 0, time.Now()) 187 188 // Create deeply nested thread (5 levels) 189 // Post -> C1 -> C2 -> C3 -> C4 -> C5 190 c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Level 1", 5, 0, time.Now().Add(-5*time.Minute)) 191 c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, c1, "Level 2", 4, 0, time.Now().Add(-4*time.Minute)) 192 c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, c2, "Level 3", 3, 0, time.Now().Add(-3*time.Minute)) 193 c4 := createTestCommentWithScore(t, db, testUser.DID, postURI, c3, "Level 4", 2, 0, time.Now().Add(-2*time.Minute)) 194 c5 := createTestCommentWithScore(t, db, testUser.DID, postURI, c4, "Level 5", 1, 0, time.Now().Add(-1*time.Minute)) 195 196 t.Run("Depth 0 returns flat list", func(t *testing.T) { 197 service := setupCommentService(db) 198 req := &comments.GetCommentsRequest{ 199 PostURI: postURI, 200 Sort: "new", 201 Depth: 0, 202 Limit: 50, 203 } 204 205 resp, err := service.GetComments(ctx, req) 206 require.NoError(t, err) 207 require.Len(t, resp.Comments, 1, "Should return 1 top-level comment") 208 209 // Verify no replies included 210 assert.Nil(t, resp.Comments[0].Replies, "Depth 0 should not include replies") 211 212 // Verify HasMore flag is set (c1 has replies) 213 assert.True(t, resp.Comments[0].HasMore, "HasMore should be true when replies exist but depth=0") 214 }) 215 216 t.Run("Depth 3 returns exactly 3 levels", func(t *testing.T) { 217 service := setupCommentService(db) 218 req := &comments.GetCommentsRequest{ 219 PostURI: postURI, 220 Sort: "new", 221 Depth: 3, 222 Limit: 50, 223 } 224 225 resp, err := service.GetComments(ctx, req) 226 require.NoError(t, err) 227 require.Len(t, resp.Comments, 1, "Should return 1 top-level comment") 228 229 // Traverse and verify exactly 3 levels 230 level1 := resp.Comments[0] 231 require.NotNil(t, level1.Replies, "Level 1 should have replies") 232 require.Len(t, level1.Replies, 1, "Level 1 should have 1 reply") 233 234 level2 := level1.Replies[0] 235 require.NotNil(t, level2.Replies, "Level 2 should have replies") 236 require.Len(t, level2.Replies, 1, "Level 2 should have 1 reply") 237 238 level3 := level2.Replies[0] 239 require.NotNil(t, level3.Replies, "Level 3 should have replies") 240 require.Len(t, level3.Replies, 1, "Level 3 should have 1 reply") 241 242 // Level 4 should NOT have replies (depth limit) 243 level4 := level3.Replies[0] 244 assert.Nil(t, level4.Replies, "Level 4 should not have replies (depth limit)") 245 246 // Verify HasMore is set correctly at depth boundary 247 assert.True(t, level4.HasMore, "HasMore should be true at depth boundary when more replies exist") 248 }) 249 250 _ = c2 251 _ = c3 252 _ = c4 253 _ = c5 254} 255 256// TestCommentQuery_HotSorting tests hot sorting with Lemmy algorithm 257func TestCommentQuery_HotSorting(t *testing.T) { 258 db := setupTestDB(t) 259 defer func() { 260 if err := db.Close(); err != nil { 261 t.Logf("Failed to close database: %v", err) 262 } 263 }() 264 265 ctx := context.Background() 266 testUser := createTestUser(t, db, "hot.test", "did:plc:hot123") 267 testCommunity, err := createFeedTestCommunity(db, ctx, "hotcomm", "ownerhot.test") 268 require.NoError(t, err) 269 270 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Hot Sorting Test", 0, time.Now()) 271 272 // Create 3 comments with different scores and ages 273 // Comment 1: score=10, created 1 hour ago 274 c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Old high score", 10, 0, time.Now().Add(-1*time.Hour)) 275 276 // Comment 2: score=5, created 5 minutes ago (should rank higher due to recency) 277 c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Recent medium score", 5, 0, time.Now().Add(-5*time.Minute)) 278 279 // Comment 3: score=-2, created now (negative score should rank lower) 280 c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Negative score", 0, 2, time.Now()) 281 282 service := setupCommentService(db) 283 req := &comments.GetCommentsRequest{ 284 PostURI: postURI, 285 Sort: "hot", 286 Depth: 0, 287 Limit: 50, 288 } 289 290 resp, err := service.GetComments(ctx, req) 291 require.NoError(t, err) 292 require.Len(t, resp.Comments, 3, "Should return all 3 comments") 293 294 // Verify hot sorting order 295 // Recent comment with medium score should rank higher than old comment with high score 296 assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Recent medium score should rank first") 297 assert.Equal(t, c1, resp.Comments[1].Comment.URI, "Old high score should rank second") 298 assert.Equal(t, c3, resp.Comments[2].Comment.URI, "Negative score should rank last") 299 300 // Verify negative scores are handled gracefully 301 negativeComment := resp.Comments[2].Comment 302 assert.Equal(t, -2, negativeComment.Stats.Score, "Negative score should be preserved") 303 assert.Equal(t, 0, negativeComment.Stats.Upvotes, "Upvotes should be 0") 304 assert.Equal(t, 2, negativeComment.Stats.Downvotes, "Downvotes should be 2") 305} 306 307// TestCommentQuery_TopSorting tests top sorting with score-based ordering 308func TestCommentQuery_TopSorting(t *testing.T) { 309 db := setupTestDB(t) 310 defer func() { 311 if err := db.Close(); err != nil { 312 t.Logf("Failed to close database: %v", err) 313 } 314 }() 315 316 ctx := context.Background() 317 testUser := createTestUser(t, db, "top.test", "did:plc:top123") 318 testCommunity, err := createFeedTestCommunity(db, ctx, "topcomm", "ownertop.test") 319 require.NoError(t, err) 320 321 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Top Sorting Test", 0, time.Now()) 322 323 // Create comments with different scores 324 c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Low score", 2, 0, time.Now().Add(-30*time.Minute)) 325 c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "High score", 10, 0, time.Now().Add(-1*time.Hour)) 326 c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Medium score", 5, 0, time.Now().Add(-15*time.Minute)) 327 328 t.Run("Top sort without timeframe", func(t *testing.T) { 329 service := setupCommentService(db) 330 req := &comments.GetCommentsRequest{ 331 PostURI: postURI, 332 Sort: "top", 333 Depth: 0, 334 Limit: 50, 335 } 336 337 resp, err := service.GetComments(ctx, req) 338 require.NoError(t, err) 339 require.Len(t, resp.Comments, 3) 340 341 // Verify highest score first 342 assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Highest score should be first") 343 assert.Equal(t, c3, resp.Comments[1].Comment.URI, "Medium score should be second") 344 assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Low score should be third") 345 }) 346 347 t.Run("Top sort with hour timeframe", func(t *testing.T) { 348 service := setupCommentService(db) 349 req := &comments.GetCommentsRequest{ 350 PostURI: postURI, 351 Sort: "top", 352 Timeframe: "hour", 353 Depth: 0, 354 Limit: 50, 355 } 356 357 resp, err := service.GetComments(ctx, req) 358 require.NoError(t, err) 359 360 // Only comments from last hour should be included (c1 and c3, not c2) 361 assert.LessOrEqual(t, len(resp.Comments), 2, "Should exclude comments older than 1 hour") 362 363 // Verify c2 (created 1 hour ago) is excluded 364 for _, tv := range resp.Comments { 365 assert.NotEqual(t, c2, tv.Comment.URI, "Comment older than 1 hour should be excluded") 366 } 367 }) 368} 369 370// TestCommentQuery_NewSorting tests chronological sorting 371func TestCommentQuery_NewSorting(t *testing.T) { 372 db := setupTestDB(t) 373 defer func() { 374 if err := db.Close(); err != nil { 375 t.Logf("Failed to close database: %v", err) 376 } 377 }() 378 379 ctx := context.Background() 380 testUser := createTestUser(t, db, "new.test", "did:plc:new123") 381 testCommunity, err := createFeedTestCommunity(db, ctx, "newcomm", "ownernew.test") 382 require.NoError(t, err) 383 384 postURI := createTestPost(t, db, testCommunity, testUser.DID, "New Sorting Test", 0, time.Now()) 385 386 // Create comments at different times (different scores to verify time is priority) 387 c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Oldest", 10, 0, time.Now().Add(-1*time.Hour)) 388 c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Middle", 5, 0, time.Now().Add(-30*time.Minute)) 389 c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Newest", 2, 0, time.Now().Add(-5*time.Minute)) 390 391 service := setupCommentService(db) 392 req := &comments.GetCommentsRequest{ 393 PostURI: postURI, 394 Sort: "new", 395 Depth: 0, 396 Limit: 50, 397 } 398 399 resp, err := service.GetComments(ctx, req) 400 require.NoError(t, err) 401 require.Len(t, resp.Comments, 3) 402 403 // Verify chronological order (newest first) 404 assert.Equal(t, c3, resp.Comments[0].Comment.URI, "Newest comment should be first") 405 assert.Equal(t, c2, resp.Comments[1].Comment.URI, "Middle comment should be second") 406 assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Oldest comment should be third") 407} 408 409// TestCommentQuery_Pagination tests cursor-based pagination 410func TestCommentQuery_Pagination(t *testing.T) { 411 db := setupTestDB(t) 412 defer func() { 413 if err := db.Close(); err != nil { 414 t.Logf("Failed to close database: %v", err) 415 } 416 }() 417 418 ctx := context.Background() 419 testUser := createTestUser(t, db, "page.test", "did:plc:page123") 420 testCommunity, err := createFeedTestCommunity(db, ctx, "pagecomm", "ownerpage.test") 421 require.NoError(t, err) 422 423 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Pagination Test", 0, time.Now()) 424 425 // Create 60 comments 426 allCommentURIs := make([]string, 60) 427 for i := 0; i < 60; i++ { 428 uri := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, 429 fmt.Sprintf("Comment %d", i), i, 0, time.Now().Add(-time.Duration(60-i)*time.Minute)) 430 allCommentURIs[i] = uri 431 } 432 433 service := setupCommentService(db) 434 435 // Fetch first page (limit=50) 436 req1 := &comments.GetCommentsRequest{ 437 PostURI: postURI, 438 Sort: "new", 439 Depth: 0, 440 Limit: 50, 441 } 442 443 resp1, err := service.GetComments(ctx, req1) 444 require.NoError(t, err) 445 assert.Len(t, resp1.Comments, 50, "First page should have 50 comments") 446 require.NotNil(t, resp1.Cursor, "Cursor should be present for next page") 447 448 // Fetch second page with cursor 449 req2 := &comments.GetCommentsRequest{ 450 PostURI: postURI, 451 Sort: "new", 452 Depth: 0, 453 Limit: 50, 454 Cursor: resp1.Cursor, 455 } 456 457 resp2, err := service.GetComments(ctx, req2) 458 require.NoError(t, err) 459 assert.Len(t, resp2.Comments, 10, "Second page should have remaining 10 comments") 460 assert.Nil(t, resp2.Cursor, "Cursor should be nil on last page") 461 462 // Verify no duplicates between pages 463 page1URIs := make(map[string]bool) 464 for _, tv := range resp1.Comments { 465 page1URIs[tv.Comment.URI] = true 466 } 467 468 for _, tv := range resp2.Comments { 469 assert.False(t, page1URIs[tv.Comment.URI], "Comment %s should not appear in both pages", tv.Comment.URI) 470 } 471 472 // Verify all comments eventually retrieved 473 allRetrieved := make(map[string]bool) 474 for _, tv := range resp1.Comments { 475 allRetrieved[tv.Comment.URI] = true 476 } 477 for _, tv := range resp2.Comments { 478 allRetrieved[tv.Comment.URI] = true 479 } 480 assert.Len(t, allRetrieved, 60, "All 60 comments should be retrieved across pages") 481} 482 483// TestCommentQuery_EmptyThread tests fetching comments from a post with no comments 484func TestCommentQuery_EmptyThread(t *testing.T) { 485 db := setupTestDB(t) 486 defer func() { 487 if err := db.Close(); err != nil { 488 t.Logf("Failed to close database: %v", err) 489 } 490 }() 491 492 ctx := context.Background() 493 testUser := createTestUser(t, db, "empty.test", "did:plc:empty123") 494 testCommunity, err := createFeedTestCommunity(db, ctx, "emptycomm", "ownerempty.test") 495 require.NoError(t, err) 496 497 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Empty Thread Test", 0, time.Now()) 498 499 service := setupCommentService(db) 500 req := &comments.GetCommentsRequest{ 501 PostURI: postURI, 502 Sort: "hot", 503 Depth: 10, 504 Limit: 50, 505 } 506 507 resp, err := service.GetComments(ctx, req) 508 require.NoError(t, err) 509 require.NotNil(t, resp, "Response should not be nil") 510 511 // Verify empty array (not null) 512 assert.NotNil(t, resp.Comments, "Comments array should not be nil") 513 assert.Len(t, resp.Comments, 0, "Comments array should be empty") 514 515 // Verify no cursor returned 516 assert.Nil(t, resp.Cursor, "Cursor should be nil for empty results") 517} 518 519// TestCommentQuery_DeletedComments tests that soft-deleted comments are excluded 520func TestCommentQuery_DeletedComments(t *testing.T) { 521 db := setupTestDB(t) 522 defer func() { 523 if err := db.Close(); err != nil { 524 t.Logf("Failed to close database: %v", err) 525 } 526 }() 527 528 ctx := context.Background() 529 commentRepo := postgres.NewCommentRepository(db) 530 consumer := jetstream.NewCommentEventConsumer(commentRepo, db) 531 532 testUser := createTestUser(t, db, "deleted.test", "did:plc:deleted123") 533 testCommunity, err := createFeedTestCommunity(db, ctx, "deletedcomm", "ownerdeleted.test") 534 require.NoError(t, err) 535 536 postURI := createTestPost(t, db, testCommunity, testUser.DID, "Deleted Comments Test", 0, time.Now()) 537 538 // Create 5 comments via Jetstream consumer 539 commentURIs := make([]string, 5) 540 for i := 0; i < 5; i++ { 541 rkey := generateTID() 542 uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, rkey) 543 commentURIs[i] = uri 544 545 event := &jetstream.JetstreamEvent{ 546 Did: testUser.DID, 547 Kind: "commit", 548 Commit: &jetstream.CommitEvent{ 549 Operation: "create", 550 Collection: "social.coves.feed.comment", 551 RKey: rkey, 552 CID: fmt.Sprintf("bafyc%d", i), 553 Record: map[string]interface{}{ 554 "$type": "social.coves.feed.comment", 555 "content": fmt.Sprintf("Comment %d", i), 556 "reply": map[string]interface{}{ 557 "root": map[string]interface{}{ 558 "uri": postURI, 559 "cid": "bafypost", 560 }, 561 "parent": map[string]interface{}{ 562 "uri": postURI, 563 "cid": "bafypost", 564 }, 565 }, 566 "createdAt": time.Now().Add(time.Duration(i) * time.Minute).Format(time.RFC3339), 567 }, 568 }, 569 } 570 571 require.NoError(t, consumer.HandleEvent(ctx, event)) 572 } 573 574 // Soft-delete 2 comments (index 1 and 3) 575 deleteEvent1 := &jetstream.JetstreamEvent{ 576 Did: testUser.DID, 577 Kind: "commit", 578 Commit: &jetstream.CommitEvent{ 579 Operation: "delete", 580 Collection: "social.coves.feed.comment", 581 RKey: strings.Split(commentURIs[1], "/")[4], 582 }, 583 } 584 require.NoError(t, consumer.HandleEvent(ctx, deleteEvent1)) 585 586 deleteEvent2 := &jetstream.JetstreamEvent{ 587 Did: testUser.DID, 588 Kind: "commit", 589 Commit: &jetstream.CommitEvent{ 590 Operation: "delete", 591 Collection: "social.coves.feed.comment", 592 RKey: strings.Split(commentURIs[3], "/")[4], 593 }, 594 } 595 require.NoError(t, consumer.HandleEvent(ctx, deleteEvent2)) 596 597 // Fetch comments 598 service := setupCommentService(db) 599 req := &comments.GetCommentsRequest{ 600 PostURI: postURI, 601 Sort: "new", 602 Depth: 0, 603 Limit: 50, 604 } 605 606 resp, err := service.GetComments(ctx, req) 607 require.NoError(t, err) 608 609 // Verify only 3 comments returned (2 were deleted) 610 assert.Len(t, resp.Comments, 3, "Should only return non-deleted comments") 611 612 // Verify deleted comments are not in results 613 returnedURIs := make(map[string]bool) 614 for _, tv := range resp.Comments { 615 returnedURIs[tv.Comment.URI] = true 616 } 617 618 assert.False(t, returnedURIs[commentURIs[1]], "Deleted comment 1 should not be in results") 619 assert.False(t, returnedURIs[commentURIs[3]], "Deleted comment 3 should not be in results") 620 assert.True(t, returnedURIs[commentURIs[0]], "Non-deleted comment 0 should be in results") 621 assert.True(t, returnedURIs[commentURIs[2]], "Non-deleted comment 2 should be in results") 622 assert.True(t, returnedURIs[commentURIs[4]], "Non-deleted comment 4 should be in results") 623} 624 625// TestCommentQuery_InvalidInputs tests error handling for invalid inputs 626func TestCommentQuery_InvalidInputs(t *testing.T) { 627 db := setupTestDB(t) 628 defer func() { 629 if err := db.Close(); err != nil { 630 t.Logf("Failed to close database: %v", err) 631 } 632 }() 633 634 ctx := context.Background() 635 service := setupCommentService(db) 636 637 // Create a real post for validation tests that should succeed after normalization 638 testUser := createTestUser(t, db, "validation.test", "did:plc:validation123") 639 testCommunity, err := createFeedTestCommunity(db, ctx, "validationcomm", "ownervalidation.test") 640 require.NoError(t, err, "Failed to create test community") 641 validPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Validation Test Post", 0, time.Now()) 642 643 t.Run("Invalid post URI", func(t *testing.T) { 644 req := &comments.GetCommentsRequest{ 645 PostURI: "not-an-at-uri", 646 Sort: "hot", 647 Depth: 10, 648 Limit: 50, 649 } 650 651 _, err := service.GetComments(ctx, req) 652 assert.Error(t, err, "Should return error for invalid AT-URI") 653 assert.Contains(t, err.Error(), "invalid", "Error should mention invalid") 654 }) 655 656 t.Run("Negative depth", func(t *testing.T) { 657 req := &comments.GetCommentsRequest{ 658 PostURI: validPostURI, // Use real post so validation can succeed 659 Sort: "hot", 660 Depth: -5, 661 Limit: 50, 662 } 663 664 resp, err := service.GetComments(ctx, req) 665 // Should not error, but should clamp to default (10) 666 require.NoError(t, err) 667 // Depth is normalized in validation 668 _ = resp 669 }) 670 671 t.Run("Depth exceeds max", func(t *testing.T) { 672 req := &comments.GetCommentsRequest{ 673 PostURI: validPostURI, // Use real post so validation can succeed 674 Sort: "hot", 675 Depth: 150, // Exceeds max of 100 676 Limit: 50, 677 } 678 679 resp, err := service.GetComments(ctx, req) 680 // Should not error, but should clamp to 100 681 require.NoError(t, err) 682 _ = resp 683 }) 684 685 t.Run("Limit exceeds max", func(t *testing.T) { 686 req := &comments.GetCommentsRequest{ 687 PostURI: validPostURI, // Use real post so validation can succeed 688 Sort: "hot", 689 Depth: 10, 690 Limit: 150, // Exceeds max of 100 691 } 692 693 resp, err := service.GetComments(ctx, req) 694 // Should not error, but should clamp to 100 695 require.NoError(t, err) 696 _ = resp 697 }) 698 699 t.Run("Invalid sort", func(t *testing.T) { 700 req := &comments.GetCommentsRequest{ 701 PostURI: "at://did:plc:test/social.coves.feed.post/abc123", 702 Sort: "invalid", 703 Depth: 10, 704 Limit: 50, 705 } 706 707 _, err := service.GetComments(ctx, req) 708 assert.Error(t, err, "Should return error for invalid sort") 709 assert.Contains(t, err.Error(), "invalid sort", "Error should mention invalid sort") 710 }) 711 712 t.Run("Empty post URI", func(t *testing.T) { 713 req := &comments.GetCommentsRequest{ 714 PostURI: "", 715 Sort: "hot", 716 Depth: 10, 717 Limit: 50, 718 } 719 720 _, err := service.GetComments(ctx, req) 721 assert.Error(t, err, "Should return error for empty post URI") 722 }) 723} 724 725// TestCommentQuery_HTTPHandler tests the HTTP handler end-to-end 726func TestCommentQuery_HTTPHandler(t *testing.T) { 727 db := setupTestDB(t) 728 defer func() { 729 if err := db.Close(); err != nil { 730 t.Logf("Failed to close database: %v", err) 731 } 732 }() 733 734 ctx := context.Background() 735 testUser := createTestUser(t, db, "http.test", "did:plc:http123") 736 testCommunity, err := createFeedTestCommunity(db, ctx, "httpcomm", "ownerhttp.test") 737 require.NoError(t, err) 738 739 postURI := createTestPost(t, db, testCommunity, testUser.DID, "HTTP Handler Test", 0, time.Now()) 740 741 // Create test comments 742 createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 1", 5, 0, time.Now().Add(-30*time.Minute)) 743 createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 2", 3, 0, time.Now().Add(-15*time.Minute)) 744 745 // Setup service adapter for HTTP handler 746 service := setupCommentServiceAdapter(db) 747 handler := &testGetCommentsHandler{service: service} 748 749 t.Run("Valid GET request", func(t *testing.T) { 750 req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&sort=hot&depth=10&limit=50", postURI), nil) 751 w := httptest.NewRecorder() 752 753 handler.ServeHTTP(w, req) 754 755 assert.Equal(t, http.StatusOK, w.Code) 756 assert.Equal(t, "application/json", w.Header().Get("Content-Type")) 757 758 var resp comments.GetCommentsResponse 759 err := json.NewDecoder(w.Body).Decode(&resp) 760 require.NoError(t, err) 761 assert.Len(t, resp.Comments, 2, "Should return 2 comments") 762 }) 763 764 t.Run("Missing post parameter", func(t *testing.T) { 765 req := httptest.NewRequest("GET", "/xrpc/social.coves.feed.getComments?sort=hot", nil) 766 w := httptest.NewRecorder() 767 768 handler.ServeHTTP(w, req) 769 770 assert.Equal(t, http.StatusBadRequest, w.Code) 771 }) 772 773 t.Run("Invalid depth parameter", func(t *testing.T) { 774 req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&depth=invalid", postURI), nil) 775 w := httptest.NewRecorder() 776 777 handler.ServeHTTP(w, req) 778 779 assert.Equal(t, http.StatusBadRequest, w.Code) 780 }) 781} 782 783// Helper: setupCommentService creates a comment service for testing 784func setupCommentService(db *sql.DB) comments.Service { 785 commentRepo := postgres.NewCommentRepository(db) 786 postRepo := postgres.NewPostRepository(db) 787 userRepo := postgres.NewUserRepository(db) 788 communityRepo := postgres.NewCommunityRepository(db) 789 return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 790} 791 792// Helper: createTestCommentWithScore creates a comment with specific vote counts 793func createTestCommentWithScore(t *testing.T, db *sql.DB, commenterDID, rootURI, parentURI, content string, upvotes, downvotes int, createdAt time.Time) string { 794 t.Helper() 795 796 ctx := context.Background() 797 rkey := generateTID() 798 uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", commenterDID, rkey) 799 800 // Insert comment directly for speed 801 _, err := db.ExecContext(ctx, ` 802 INSERT INTO comments ( 803 uri, cid, rkey, commenter_did, 804 root_uri, root_cid, parent_uri, parent_cid, 805 content, created_at, indexed_at, 806 upvote_count, downvote_count, score 807 ) VALUES ( 808 $1, $2, $3, $4, 809 $5, $6, $7, $8, 810 $9, $10, NOW(), 811 $11, $12, $13 812 ) 813 `, uri, fmt.Sprintf("bafyc%s", rkey), rkey, commenterDID, 814 rootURI, "bafyroot", parentURI, "bafyparent", 815 content, createdAt, 816 upvotes, downvotes, upvotes-downvotes) 817 818 require.NoError(t, err, "Failed to create test comment") 819 820 // Update reply count on parent if it's a nested comment 821 if parentURI != rootURI { 822 _, _ = db.ExecContext(ctx, ` 823 UPDATE comments 824 SET reply_count = reply_count + 1 825 WHERE uri = $1 826 `, parentURI) 827 } else { 828 // Update comment count on post if top-level 829 _, _ = db.ExecContext(ctx, ` 830 UPDATE posts 831 SET comment_count = comment_count + 1 832 WHERE uri = $1 833 `, parentURI) 834 } 835 836 return uri 837} 838 839// Helper: Service adapter for HTTP handler testing 840type testCommentServiceAdapter struct { 841 service comments.Service 842} 843 844func (s *testCommentServiceAdapter) GetComments(r *http.Request, req *testGetCommentsRequest) (*comments.GetCommentsResponse, error) { 845 ctx := r.Context() 846 847 serviceReq := &comments.GetCommentsRequest{ 848 PostURI: req.PostURI, 849 Sort: req.Sort, 850 Timeframe: req.Timeframe, 851 Depth: req.Depth, 852 Limit: req.Limit, 853 Cursor: req.Cursor, 854 ViewerDID: req.ViewerDID, 855 } 856 857 return s.service.GetComments(ctx, serviceReq) 858} 859 860type testGetCommentsRequest struct { 861 Cursor *string 862 ViewerDID *string 863 PostURI string 864 Sort string 865 Timeframe string 866 Depth int 867 Limit int 868} 869 870func setupCommentServiceAdapter(db *sql.DB) *testCommentServiceAdapter { 871 commentRepo := postgres.NewCommentRepository(db) 872 postRepo := postgres.NewPostRepository(db) 873 userRepo := postgres.NewUserRepository(db) 874 communityRepo := postgres.NewCommunityRepository(db) 875 service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 876 return &testCommentServiceAdapter{service: service} 877} 878 879// Helper: Simple HTTP handler wrapper for testing 880type testGetCommentsHandler struct { 881 service *testCommentServiceAdapter 882} 883 884func (h *testGetCommentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 885 if r.Method != http.MethodGet { 886 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 887 return 888 } 889 890 query := r.URL.Query() 891 post := query.Get("post") 892 893 if post == "" { 894 http.Error(w, "post parameter is required", http.StatusBadRequest) 895 return 896 } 897 898 sort := query.Get("sort") 899 if sort == "" { 900 sort = "hot" 901 } 902 903 depth := 10 904 if d := query.Get("depth"); d != "" { 905 if _, err := fmt.Sscanf(d, "%d", &depth); err != nil { 906 http.Error(w, "invalid depth", http.StatusBadRequest) 907 return 908 } 909 } 910 911 limit := 50 912 if l := query.Get("limit"); l != "" { 913 if _, err := fmt.Sscanf(l, "%d", &limit); err != nil { 914 http.Error(w, "invalid limit", http.StatusBadRequest) 915 return 916 } 917 } 918 919 req := &testGetCommentsRequest{ 920 PostURI: post, 921 Sort: sort, 922 Depth: depth, 923 Limit: limit, 924 } 925 926 resp, err := h.service.GetComments(r, req) 927 if err != nil { 928 http.Error(w, err.Error(), http.StatusInternalServerError) 929 return 930 } 931 932 w.Header().Set("Content-Type", "application/json") 933 w.WriteHeader(http.StatusOK) 934 _ = json.NewEncoder(w).Encode(resp) 935}