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