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 return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
789}
790
791// Helper: createTestCommentWithScore creates a comment with specific vote counts
792func createTestCommentWithScore(t *testing.T, db *sql.DB, commenterDID, rootURI, parentURI, content string, upvotes, downvotes int, createdAt time.Time) string {
793 t.Helper()
794
795 ctx := context.Background()
796 rkey := generateTID()
797 uri := fmt.Sprintf("at://%s/social.coves.community.comment/%s", commenterDID, rkey)
798
799 // Insert comment directly for speed
800 _, err := db.ExecContext(ctx, `
801 INSERT INTO comments (
802 uri, cid, rkey, commenter_did,
803 root_uri, root_cid, parent_uri, parent_cid,
804 content, created_at, indexed_at,
805 upvote_count, downvote_count, score
806 ) VALUES (
807 $1, $2, $3, $4,
808 $5, $6, $7, $8,
809 $9, $10, NOW(),
810 $11, $12, $13
811 )
812 `, uri, fmt.Sprintf("bafyc%s", rkey), rkey, commenterDID,
813 rootURI, "bafyroot", parentURI, "bafyparent",
814 content, createdAt,
815 upvotes, downvotes, upvotes-downvotes)
816
817 require.NoError(t, err, "Failed to create test comment")
818
819 // Update reply count on parent if it's a nested comment
820 if parentURI != rootURI {
821 _, _ = db.ExecContext(ctx, `
822 UPDATE comments
823 SET reply_count = reply_count + 1
824 WHERE uri = $1
825 `, parentURI)
826 } else {
827 // Update comment count on post if top-level
828 _, _ = db.ExecContext(ctx, `
829 UPDATE posts
830 SET comment_count = comment_count + 1
831 WHERE uri = $1
832 `, parentURI)
833 }
834
835 return uri
836}
837
838// Helper: Service adapter for HTTP handler testing
839type testCommentServiceAdapter struct {
840 service comments.Service
841}
842
843func (s *testCommentServiceAdapter) GetComments(r *http.Request, req *testGetCommentsRequest) (*comments.GetCommentsResponse, error) {
844 ctx := r.Context()
845
846 serviceReq := &comments.GetCommentsRequest{
847 PostURI: req.PostURI,
848 Sort: req.Sort,
849 Timeframe: req.Timeframe,
850 Depth: req.Depth,
851 Limit: req.Limit,
852 Cursor: req.Cursor,
853 ViewerDID: req.ViewerDID,
854 }
855
856 return s.service.GetComments(ctx, serviceReq)
857}
858
859type testGetCommentsRequest struct {
860 Cursor *string
861 ViewerDID *string
862 PostURI string
863 Sort string
864 Timeframe string
865 Depth int
866 Limit int
867}
868
869func setupCommentServiceAdapter(db *sql.DB) *testCommentServiceAdapter {
870 commentRepo := postgres.NewCommentRepository(db)
871 postRepo := postgres.NewPostRepository(db)
872 userRepo := postgres.NewUserRepository(db)
873 communityRepo := postgres.NewCommunityRepository(db)
874 service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
875 return &testCommentServiceAdapter{service: service}
876}
877
878// Helper: Simple HTTP handler wrapper for testing
879type testGetCommentsHandler struct {
880 service *testCommentServiceAdapter
881}
882
883func (h *testGetCommentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
884 if r.Method != http.MethodGet {
885 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
886 return
887 }
888
889 query := r.URL.Query()
890 post := query.Get("post")
891
892 if post == "" {
893 http.Error(w, "post parameter is required", http.StatusBadRequest)
894 return
895 }
896
897 sort := query.Get("sort")
898 if sort == "" {
899 sort = "hot"
900 }
901
902 depth := 10
903 if d := query.Get("depth"); d != "" {
904 if _, err := fmt.Sscanf(d, "%d", &depth); err != nil {
905 http.Error(w, "invalid depth", http.StatusBadRequest)
906 return
907 }
908 }
909
910 limit := 50
911 if l := query.Get("limit"); l != "" {
912 if _, err := fmt.Sscanf(l, "%d", &limit); err != nil {
913 http.Error(w, "invalid limit", http.StatusBadRequest)
914 return
915 }
916 }
917
918 req := &testGetCommentsRequest{
919 PostURI: post,
920 Sort: sort,
921 Depth: depth,
922 Limit: limit,
923 }
924
925 resp, err := h.service.GetComments(r, req)
926 if err != nil {
927 http.Error(w, err.Error(), http.StatusInternalServerError)
928 return
929 }
930
931 w.Header().Set("Content-Type", "application/json")
932 w.WriteHeader(http.StatusOK)
933 _ = json.NewEncoder(w).Encode(resp)
934}