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}