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.community.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.community.comment", 550 RKey: rkey, 551 CID: fmt.Sprintf("bafyc%d", i), 552 Record: map[string]interface{}{ 553 "$type": "social.coves.community.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.community.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.community.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 // Create a real post for validation tests that should succeed after normalization 637 testUser := createTestUser(t, db, "validation.test", "did:plc:validation123") 638 testCommunity, err := createFeedTestCommunity(db, ctx, "validationcomm", "ownervalidation.test") 639 require.NoError(t, err, "Failed to create test community") 640 validPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Validation Test Post", 0, time.Now()) 641 642 t.Run("Invalid post URI", func(t *testing.T) { 643 req := &comments.GetCommentsRequest{ 644 PostURI: "not-an-at-uri", 645 Sort: "hot", 646 Depth: 10, 647 Limit: 50, 648 } 649 650 _, err := service.GetComments(ctx, req) 651 assert.Error(t, err, "Should return error for invalid AT-URI") 652 assert.Contains(t, err.Error(), "invalid", "Error should mention invalid") 653 }) 654 655 t.Run("Negative depth", func(t *testing.T) { 656 req := &comments.GetCommentsRequest{ 657 PostURI: validPostURI, // Use real post so validation can succeed 658 Sort: "hot", 659 Depth: -5, 660 Limit: 50, 661 } 662 663 resp, err := service.GetComments(ctx, req) 664 // Should not error, but should clamp to default (10) 665 require.NoError(t, err) 666 // Depth is normalized in validation 667 _ = resp 668 }) 669 670 t.Run("Depth exceeds max", func(t *testing.T) { 671 req := &comments.GetCommentsRequest{ 672 PostURI: validPostURI, // Use real post so validation can succeed 673 Sort: "hot", 674 Depth: 150, // Exceeds max of 100 675 Limit: 50, 676 } 677 678 resp, err := service.GetComments(ctx, req) 679 // Should not error, but should clamp to 100 680 require.NoError(t, err) 681 _ = resp 682 }) 683 684 t.Run("Limit exceeds max", func(t *testing.T) { 685 req := &comments.GetCommentsRequest{ 686 PostURI: validPostURI, // Use real post so validation can succeed 687 Sort: "hot", 688 Depth: 10, 689 Limit: 150, // Exceeds max of 100 690 } 691 692 resp, err := service.GetComments(ctx, req) 693 // Should not error, but should clamp to 100 694 require.NoError(t, err) 695 _ = resp 696 }) 697 698 t.Run("Invalid sort", func(t *testing.T) { 699 req := &comments.GetCommentsRequest{ 700 PostURI: "at://did:plc:test/social.coves.feed.post/abc123", 701 Sort: "invalid", 702 Depth: 10, 703 Limit: 50, 704 } 705 706 _, err := service.GetComments(ctx, req) 707 assert.Error(t, err, "Should return error for invalid sort") 708 assert.Contains(t, err.Error(), "invalid sort", "Error should mention invalid sort") 709 }) 710 711 t.Run("Empty post URI", func(t *testing.T) { 712 req := &comments.GetCommentsRequest{ 713 PostURI: "", 714 Sort: "hot", 715 Depth: 10, 716 Limit: 50, 717 } 718 719 _, err := service.GetComments(ctx, req) 720 assert.Error(t, err, "Should return error for empty post URI") 721 }) 722} 723 724// TestCommentQuery_HTTPHandler tests the HTTP handler end-to-end 725func TestCommentQuery_HTTPHandler(t *testing.T) { 726 db := setupTestDB(t) 727 defer func() { 728 if err := db.Close(); err != nil { 729 t.Logf("Failed to close database: %v", err) 730 } 731 }() 732 733 ctx := context.Background() 734 testUser := createTestUser(t, db, "http.test", "did:plc:http123") 735 testCommunity, err := createFeedTestCommunity(db, ctx, "httpcomm", "ownerhttp.test") 736 require.NoError(t, err) 737 738 postURI := createTestPost(t, db, testCommunity, testUser.DID, "HTTP Handler Test", 0, time.Now()) 739 740 // Create test comments 741 createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 1", 5, 0, time.Now().Add(-30*time.Minute)) 742 createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 2", 3, 0, time.Now().Add(-15*time.Minute)) 743 744 // Setup service adapter for HTTP handler 745 service := setupCommentServiceAdapter(db) 746 handler := &testGetCommentsHandler{service: service} 747 748 t.Run("Valid GET request", func(t *testing.T) { 749 req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&sort=hot&depth=10&limit=50", postURI), nil) 750 w := httptest.NewRecorder() 751 752 handler.ServeHTTP(w, req) 753 754 assert.Equal(t, http.StatusOK, w.Code) 755 assert.Equal(t, "application/json", w.Header().Get("Content-Type")) 756 757 var resp comments.GetCommentsResponse 758 err := json.NewDecoder(w.Body).Decode(&resp) 759 require.NoError(t, err) 760 assert.Len(t, resp.Comments, 2, "Should return 2 comments") 761 }) 762 763 t.Run("Missing post parameter", func(t *testing.T) { 764 req := httptest.NewRequest("GET", "/xrpc/social.coves.feed.getComments?sort=hot", nil) 765 w := httptest.NewRecorder() 766 767 handler.ServeHTTP(w, req) 768 769 assert.Equal(t, http.StatusBadRequest, w.Code) 770 }) 771 772 t.Run("Invalid depth parameter", func(t *testing.T) { 773 req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&depth=invalid", postURI), nil) 774 w := httptest.NewRecorder() 775 776 handler.ServeHTTP(w, req) 777 778 assert.Equal(t, http.StatusBadRequest, w.Code) 779 }) 780} 781 782// Helper: setupCommentService creates a comment service for testing 783func setupCommentService(db *sql.DB) comments.Service { 784 commentRepo := postgres.NewCommentRepository(db) 785 postRepo := postgres.NewPostRepository(db) 786 userRepo := postgres.NewUserRepository(db) 787 communityRepo := postgres.NewCommunityRepository(db) 788 // Use factory constructor with nil factory - these tests only use the read path (GetComments) 789 return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 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.community.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 // Use factory constructor with nil factory - these tests only use the read path (GetComments) 876 service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 877 return &testCommentServiceAdapter{service: service} 878} 879 880// Helper: Simple HTTP handler wrapper for testing 881type testGetCommentsHandler struct { 882 service *testCommentServiceAdapter 883} 884 885func (h *testGetCommentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 886 if r.Method != http.MethodGet { 887 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 888 return 889 } 890 891 query := r.URL.Query() 892 post := query.Get("post") 893 894 if post == "" { 895 http.Error(w, "post parameter is required", http.StatusBadRequest) 896 return 897 } 898 899 sort := query.Get("sort") 900 if sort == "" { 901 sort = "hot" 902 } 903 904 depth := 10 905 if d := query.Get("depth"); d != "" { 906 if _, err := fmt.Sscanf(d, "%d", &depth); err != nil { 907 http.Error(w, "invalid depth", http.StatusBadRequest) 908 return 909 } 910 } 911 912 limit := 50 913 if l := query.Get("limit"); l != "" { 914 if _, err := fmt.Sscanf(l, "%d", &limit); err != nil { 915 http.Error(w, "invalid limit", http.StatusBadRequest) 916 return 917 } 918 } 919 920 req := &testGetCommentsRequest{ 921 PostURI: post, 922 Sort: sort, 923 Depth: depth, 924 Limit: limit, 925 } 926 927 resp, err := h.service.GetComments(r, req) 928 if err != nil { 929 http.Error(w, err.Error(), http.StatusInternalServerError) 930 return 931 } 932 933 w.Header().Set("Content-Type", "application/json") 934 w.WriteHeader(http.StatusOK) 935 _ = json.NewEncoder(w).Encode(resp) 936}