A community based topic aggregation platform built on atproto
1package comments
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "Coves/internal/core/communities"
10 "Coves/internal/core/posts"
11 "Coves/internal/core/users"
12
13 "github.com/stretchr/testify/assert"
14)
15
16// Mock implementations for testing
17
18// mockCommentRepo is a mock implementation of the comment Repository interface
19type mockCommentRepo struct {
20 comments map[string]*Comment
21 listByParentWithHotRankFunc func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error)
22 listByParentsBatchFunc func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error)
23 getVoteStateForCommentsFunc func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
24}
25
26func newMockCommentRepo() *mockCommentRepo {
27 return &mockCommentRepo{
28 comments: make(map[string]*Comment),
29 }
30}
31
32func (m *mockCommentRepo) Create(ctx context.Context, comment *Comment) error {
33 m.comments[comment.URI] = comment
34 return nil
35}
36
37func (m *mockCommentRepo) Update(ctx context.Context, comment *Comment) error {
38 if _, ok := m.comments[comment.URI]; !ok {
39 return ErrCommentNotFound
40 }
41 m.comments[comment.URI] = comment
42 return nil
43}
44
45func (m *mockCommentRepo) GetByURI(ctx context.Context, uri string) (*Comment, error) {
46 if c, ok := m.comments[uri]; ok {
47 return c, nil
48 }
49 return nil, ErrCommentNotFound
50}
51
52func (m *mockCommentRepo) Delete(ctx context.Context, uri string) error {
53 delete(m.comments, uri)
54 return nil
55}
56
57func (m *mockCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error) {
58 return nil, nil
59}
60
61func (m *mockCommentRepo) ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*Comment, error) {
62 return nil, nil
63}
64
65func (m *mockCommentRepo) CountByParent(ctx context.Context, parentURI string) (int, error) {
66 return 0, nil
67}
68
69func (m *mockCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) {
70 return nil, nil
71}
72
73func (m *mockCommentRepo) ListByParentWithHotRank(
74 ctx context.Context,
75 parentURI string,
76 sort string,
77 timeframe string,
78 limit int,
79 cursor *string,
80) ([]*Comment, *string, error) {
81 if m.listByParentWithHotRankFunc != nil {
82 return m.listByParentWithHotRankFunc(ctx, parentURI, sort, timeframe, limit, cursor)
83 }
84 return []*Comment{}, nil, nil
85}
86
87func (m *mockCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*Comment, error) {
88 result := make(map[string]*Comment)
89 for _, uri := range uris {
90 if c, ok := m.comments[uri]; ok {
91 result[uri] = c
92 }
93 }
94 return result, nil
95}
96
97func (m *mockCommentRepo) GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) {
98 if m.getVoteStateForCommentsFunc != nil {
99 return m.getVoteStateForCommentsFunc(ctx, viewerDID, commentURIs)
100 }
101 return make(map[string]interface{}), nil
102}
103
104func (m *mockCommentRepo) ListByParentsBatch(
105 ctx context.Context,
106 parentURIs []string,
107 sort string,
108 limitPerParent int,
109) (map[string][]*Comment, error) {
110 if m.listByParentsBatchFunc != nil {
111 return m.listByParentsBatchFunc(ctx, parentURIs, sort, limitPerParent)
112 }
113 return make(map[string][]*Comment), nil
114}
115
116// mockUserRepo is a mock implementation of the users.UserRepository interface
117type mockUserRepo struct {
118 users map[string]*users.User
119}
120
121func newMockUserRepo() *mockUserRepo {
122 return &mockUserRepo{
123 users: make(map[string]*users.User),
124 }
125}
126
127func (m *mockUserRepo) Create(ctx context.Context, user *users.User) (*users.User, error) {
128 m.users[user.DID] = user
129 return user, nil
130}
131
132func (m *mockUserRepo) GetByDID(ctx context.Context, did string) (*users.User, error) {
133 if u, ok := m.users[did]; ok {
134 return u, nil
135 }
136 return nil, errors.New("user not found")
137}
138
139func (m *mockUserRepo) GetByHandle(ctx context.Context, handle string) (*users.User, error) {
140 for _, u := range m.users {
141 if u.Handle == handle {
142 return u, nil
143 }
144 }
145 return nil, errors.New("user not found")
146}
147
148func (m *mockUserRepo) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) {
149 if u, ok := m.users[did]; ok {
150 u.Handle = newHandle
151 return u, nil
152 }
153 return nil, errors.New("user not found")
154}
155
156// mockPostRepo is a mock implementation of the posts.Repository interface
157type mockPostRepo struct {
158 posts map[string]*posts.Post
159}
160
161func newMockPostRepo() *mockPostRepo {
162 return &mockPostRepo{
163 posts: make(map[string]*posts.Post),
164 }
165}
166
167func (m *mockPostRepo) Create(ctx context.Context, post *posts.Post) error {
168 m.posts[post.URI] = post
169 return nil
170}
171
172func (m *mockPostRepo) GetByURI(ctx context.Context, uri string) (*posts.Post, error) {
173 if p, ok := m.posts[uri]; ok {
174 return p, nil
175 }
176 return nil, posts.NewNotFoundError("post", uri)
177}
178
179// mockCommunityRepo is a mock implementation of the communities.Repository interface
180type mockCommunityRepo struct {
181 communities map[string]*communities.Community
182}
183
184func newMockCommunityRepo() *mockCommunityRepo {
185 return &mockCommunityRepo{
186 communities: make(map[string]*communities.Community),
187 }
188}
189
190func (m *mockCommunityRepo) Create(ctx context.Context, community *communities.Community) (*communities.Community, error) {
191 m.communities[community.DID] = community
192 return community, nil
193}
194
195func (m *mockCommunityRepo) GetByDID(ctx context.Context, did string) (*communities.Community, error) {
196 if c, ok := m.communities[did]; ok {
197 return c, nil
198 }
199 return nil, communities.ErrCommunityNotFound
200}
201
202func (m *mockCommunityRepo) GetByHandle(ctx context.Context, handle string) (*communities.Community, error) {
203 for _, c := range m.communities {
204 if c.Handle == handle {
205 return c, nil
206 }
207 }
208 return nil, communities.ErrCommunityNotFound
209}
210
211func (m *mockCommunityRepo) Update(ctx context.Context, community *communities.Community) (*communities.Community, error) {
212 m.communities[community.DID] = community
213 return community, nil
214}
215
216func (m *mockCommunityRepo) Delete(ctx context.Context, did string) error {
217 delete(m.communities, did)
218 return nil
219}
220
221func (m *mockCommunityRepo) UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error {
222 return nil
223}
224
225func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {
226 return nil, 0, nil
227}
228
229func (m *mockCommunityRepo) Search(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
230 return nil, 0, nil
231}
232
233func (m *mockCommunityRepo) Subscribe(ctx context.Context, subscription *communities.Subscription) (*communities.Subscription, error) {
234 return nil, nil
235}
236
237func (m *mockCommunityRepo) SubscribeWithCount(ctx context.Context, subscription *communities.Subscription) (*communities.Subscription, error) {
238 return nil, nil
239}
240
241func (m *mockCommunityRepo) Unsubscribe(ctx context.Context, userDID, communityDID string) error {
242 return nil
243}
244
245func (m *mockCommunityRepo) UnsubscribeWithCount(ctx context.Context, userDID, communityDID string) error {
246 return nil
247}
248
249func (m *mockCommunityRepo) GetSubscription(ctx context.Context, userDID, communityDID string) (*communities.Subscription, error) {
250 return nil, nil
251}
252
253func (m *mockCommunityRepo) GetSubscriptionByURI(ctx context.Context, recordURI string) (*communities.Subscription, error) {
254 return nil, nil
255}
256
257func (m *mockCommunityRepo) ListSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) {
258 return nil, nil
259}
260
261func (m *mockCommunityRepo) ListSubscribers(ctx context.Context, communityDID string, limit, offset int) ([]*communities.Subscription, error) {
262 return nil, nil
263}
264
265func (m *mockCommunityRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) {
266 return nil, nil
267}
268
269func (m *mockCommunityRepo) UnblockCommunity(ctx context.Context, userDID, communityDID string) error {
270 return nil
271}
272
273func (m *mockCommunityRepo) GetBlock(ctx context.Context, userDID, communityDID string) (*communities.CommunityBlock, error) {
274 return nil, nil
275}
276
277func (m *mockCommunityRepo) GetBlockByURI(ctx context.Context, recordURI string) (*communities.CommunityBlock, error) {
278 return nil, nil
279}
280
281func (m *mockCommunityRepo) ListBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
282 return nil, nil
283}
284
285func (m *mockCommunityRepo) IsBlocked(ctx context.Context, userDID, communityDID string) (bool, error) {
286 return false, nil
287}
288
289func (m *mockCommunityRepo) CreateMembership(ctx context.Context, membership *communities.Membership) (*communities.Membership, error) {
290 return nil, nil
291}
292
293func (m *mockCommunityRepo) GetMembership(ctx context.Context, userDID, communityDID string) (*communities.Membership, error) {
294 return nil, nil
295}
296
297func (m *mockCommunityRepo) UpdateMembership(ctx context.Context, membership *communities.Membership) (*communities.Membership, error) {
298 return nil, nil
299}
300
301func (m *mockCommunityRepo) ListMembers(ctx context.Context, communityDID string, limit, offset int) ([]*communities.Membership, error) {
302 return nil, nil
303}
304
305func (m *mockCommunityRepo) CreateModerationAction(ctx context.Context, action *communities.ModerationAction) (*communities.ModerationAction, error) {
306 return nil, nil
307}
308
309func (m *mockCommunityRepo) ListModerationActions(ctx context.Context, communityDID string, limit, offset int) ([]*communities.ModerationAction, error) {
310 return nil, nil
311}
312
313func (m *mockCommunityRepo) IncrementMemberCount(ctx context.Context, communityDID string) error {
314 return nil
315}
316
317func (m *mockCommunityRepo) DecrementMemberCount(ctx context.Context, communityDID string) error {
318 return nil
319}
320
321func (m *mockCommunityRepo) IncrementSubscriberCount(ctx context.Context, communityDID string) error {
322 return nil
323}
324
325func (m *mockCommunityRepo) DecrementSubscriberCount(ctx context.Context, communityDID string) error {
326 return nil
327}
328
329func (m *mockCommunityRepo) IncrementPostCount(ctx context.Context, communityDID string) error {
330 return nil
331}
332
333// Helper functions to create test data
334
335func createTestPost(uri string, authorDID string, communityDID string) *posts.Post {
336 title := "Test Post"
337 content := "Test content"
338 return &posts.Post{
339 URI: uri,
340 CID: "bafytest123",
341 RKey: "testrkey",
342 AuthorDID: authorDID,
343 CommunityDID: communityDID,
344 Title: &title,
345 Content: &content,
346 CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
347 IndexedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
348 UpvoteCount: 10,
349 DownvoteCount: 2,
350 Score: 8,
351 CommentCount: 5,
352 }
353}
354
355func createTestComment(uri string, commenterDID string, commenterHandle string, rootURI string, parentURI string, replyCount int) *Comment {
356 return &Comment{
357 URI: uri,
358 CID: "bafycomment123",
359 RKey: "commentrkey",
360 CommenterDID: commenterDID,
361 CommenterHandle: commenterHandle,
362 Content: "Test comment content",
363 RootURI: rootURI,
364 RootCID: "bafyroot123",
365 ParentURI: parentURI,
366 ParentCID: "bafyparent123",
367 CreatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
368 IndexedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
369 UpvoteCount: 5,
370 DownvoteCount: 1,
371 Score: 4,
372 ReplyCount: replyCount,
373 Langs: []string{"en"},
374 }
375}
376
377func createTestUser(did string, handle string) *users.User {
378 return &users.User{
379 DID: did,
380 Handle: handle,
381 PDSURL: "https://test.pds.local",
382 CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
383 UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
384 }
385}
386
387func createTestCommunity(did string, handle string) *communities.Community {
388 return &communities.Community{
389 DID: did,
390 Handle: handle,
391 Name: "test",
392 DisplayName: "Test Community",
393 Description: "Test description",
394 Visibility: "public",
395 OwnerDID: did,
396 CreatedByDID: "did:plc:creator",
397 HostedByDID: "did:web:coves.social",
398 CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
399 UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
400 }
401}
402
403// Test suite for GetComments
404
405func TestCommentService_GetComments_ValidRequest(t *testing.T) {
406 // Setup
407 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
408 authorDID := "did:plc:author123"
409 communityDID := "did:plc:community123"
410 commenterDID := "did:plc:commenter123"
411 viewerDID := "did:plc:viewer123"
412
413 commentRepo := newMockCommentRepo()
414 userRepo := newMockUserRepo()
415 postRepo := newMockPostRepo()
416 communityRepo := newMockCommunityRepo()
417
418 // Setup test data
419 post := createTestPost(postURI, authorDID, communityDID)
420 _ = postRepo.Create(context.Background(), post)
421
422 author := createTestUser(authorDID, "author.test")
423 _, _ = userRepo.Create(context.Background(), author)
424
425 community := createTestCommunity(communityDID, "test.community.coves.social")
426 _, _ = communityRepo.Create(context.Background(), community)
427
428 comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
429 comment2 := createTestComment("at://did:plc:commenter123/comment/2", commenterDID, "commenter.test", postURI, postURI, 0)
430
431 commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
432 if parentURI == postURI {
433 return []*Comment{comment1, comment2}, nil, nil
434 }
435 return []*Comment{}, nil, nil
436 }
437
438 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
439
440 // Execute
441 req := &GetCommentsRequest{
442 PostURI: postURI,
443 ViewerDID: &viewerDID,
444 Sort: "hot",
445 Depth: 10,
446 Limit: 50,
447 }
448
449 resp, err := service.GetComments(context.Background(), req)
450
451 // Verify
452 assert.NoError(t, err)
453 assert.NotNil(t, resp)
454 assert.Len(t, resp.Comments, 2)
455 assert.NotNil(t, resp.Post)
456 assert.Nil(t, resp.Cursor)
457}
458
459func TestCommentService_GetComments_InvalidPostURI(t *testing.T) {
460 // Setup
461 commentRepo := newMockCommentRepo()
462 userRepo := newMockUserRepo()
463 postRepo := newMockPostRepo()
464 communityRepo := newMockCommunityRepo()
465
466 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
467
468 tests := []struct {
469 name string
470 postURI string
471 wantErr string
472 }{
473 {
474 name: "empty post URI",
475 postURI: "",
476 wantErr: "post URI is required",
477 },
478 {
479 name: "invalid URI format",
480 postURI: "http://invalid.com/post",
481 wantErr: "invalid AT-URI format",
482 },
483 }
484
485 for _, tt := range tests {
486 t.Run(tt.name, func(t *testing.T) {
487 req := &GetCommentsRequest{
488 PostURI: tt.postURI,
489 Sort: "hot",
490 Depth: 10,
491 Limit: 50,
492 }
493
494 resp, err := service.GetComments(context.Background(), req)
495
496 assert.Error(t, err)
497 assert.Nil(t, resp)
498 assert.Contains(t, err.Error(), tt.wantErr)
499 })
500 }
501}
502
503func TestCommentService_GetComments_PostNotFound(t *testing.T) {
504 // Setup
505 commentRepo := newMockCommentRepo()
506 userRepo := newMockUserRepo()
507 postRepo := newMockPostRepo()
508 communityRepo := newMockCommunityRepo()
509
510 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
511
512 // Execute
513 req := &GetCommentsRequest{
514 PostURI: "at://did:plc:post123/app.bsky.feed.post/nonexistent",
515 Sort: "hot",
516 Depth: 10,
517 Limit: 50,
518 }
519
520 resp, err := service.GetComments(context.Background(), req)
521
522 // Verify
523 assert.Error(t, err)
524 assert.Nil(t, resp)
525 assert.Equal(t, ErrRootNotFound, err)
526}
527
528func TestCommentService_GetComments_EmptyComments(t *testing.T) {
529 // Setup
530 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
531 authorDID := "did:plc:author123"
532 communityDID := "did:plc:community123"
533
534 commentRepo := newMockCommentRepo()
535 userRepo := newMockUserRepo()
536 postRepo := newMockPostRepo()
537 communityRepo := newMockCommunityRepo()
538
539 // Setup test data
540 post := createTestPost(postURI, authorDID, communityDID)
541 _ = postRepo.Create(context.Background(), post)
542
543 author := createTestUser(authorDID, "author.test")
544 _, _ = userRepo.Create(context.Background(), author)
545
546 community := createTestCommunity(communityDID, "test.community.coves.social")
547 _, _ = communityRepo.Create(context.Background(), community)
548
549 commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
550 return []*Comment{}, nil, nil
551 }
552
553 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
554
555 // Execute
556 req := &GetCommentsRequest{
557 PostURI: postURI,
558 Sort: "hot",
559 Depth: 10,
560 Limit: 50,
561 }
562
563 resp, err := service.GetComments(context.Background(), req)
564
565 // Verify
566 assert.NoError(t, err)
567 assert.NotNil(t, resp)
568 assert.Len(t, resp.Comments, 0)
569 assert.NotNil(t, resp.Post)
570}
571
572func TestCommentService_GetComments_WithViewerVotes(t *testing.T) {
573 // Setup
574 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
575 authorDID := "did:plc:author123"
576 communityDID := "did:plc:community123"
577 commenterDID := "did:plc:commenter123"
578 viewerDID := "did:plc:viewer123"
579
580 commentRepo := newMockCommentRepo()
581 userRepo := newMockUserRepo()
582 postRepo := newMockPostRepo()
583 communityRepo := newMockCommunityRepo()
584
585 // Setup test data
586 post := createTestPost(postURI, authorDID, communityDID)
587 _ = postRepo.Create(context.Background(), post)
588
589 author := createTestUser(authorDID, "author.test")
590 _, _ = userRepo.Create(context.Background(), author)
591
592 community := createTestCommunity(communityDID, "test.community.coves.social")
593 _, _ = communityRepo.Create(context.Background(), community)
594
595 comment1URI := "at://did:plc:commenter123/comment/1"
596 comment1 := createTestComment(comment1URI, commenterDID, "commenter.test", postURI, postURI, 0)
597
598 commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
599 if parentURI == postURI {
600 return []*Comment{comment1}, nil, nil
601 }
602 return []*Comment{}, nil, nil
603 }
604
605 // Mock vote state
606 commentRepo.getVoteStateForCommentsFunc = func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) {
607 voteURI := "at://did:plc:viewer123/vote/1"
608 return map[string]interface{}{
609 comment1URI: map[string]interface{}{
610 "direction": "up",
611 "uri": voteURI,
612 },
613 }, nil
614 }
615
616 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
617
618 // Execute
619 req := &GetCommentsRequest{
620 PostURI: postURI,
621 ViewerDID: &viewerDID,
622 Sort: "hot",
623 Depth: 10,
624 Limit: 50,
625 }
626
627 resp, err := service.GetComments(context.Background(), req)
628
629 // Verify
630 assert.NoError(t, err)
631 assert.NotNil(t, resp)
632 assert.Len(t, resp.Comments, 1)
633
634 // Check viewer state
635 commentView := resp.Comments[0].Comment
636 assert.NotNil(t, commentView.Viewer)
637 assert.NotNil(t, commentView.Viewer.Vote)
638 assert.Equal(t, "up", *commentView.Viewer.Vote)
639 assert.NotNil(t, commentView.Viewer.VoteURI)
640}
641
642func TestCommentService_GetComments_WithoutViewer(t *testing.T) {
643 // Setup
644 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
645 authorDID := "did:plc:author123"
646 communityDID := "did:plc:community123"
647 commenterDID := "did:plc:commenter123"
648
649 commentRepo := newMockCommentRepo()
650 userRepo := newMockUserRepo()
651 postRepo := newMockPostRepo()
652 communityRepo := newMockCommunityRepo()
653
654 // Setup test data
655 post := createTestPost(postURI, authorDID, communityDID)
656 _ = postRepo.Create(context.Background(), post)
657
658 author := createTestUser(authorDID, "author.test")
659 _, _ = userRepo.Create(context.Background(), author)
660
661 community := createTestCommunity(communityDID, "test.community.coves.social")
662 _, _ = communityRepo.Create(context.Background(), community)
663
664 comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
665
666 commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
667 if parentURI == postURI {
668 return []*Comment{comment1}, nil, nil
669 }
670 return []*Comment{}, nil, nil
671 }
672
673 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
674
675 // Execute without viewer
676 req := &GetCommentsRequest{
677 PostURI: postURI,
678 ViewerDID: nil,
679 Sort: "hot",
680 Depth: 10,
681 Limit: 50,
682 }
683
684 resp, err := service.GetComments(context.Background(), req)
685
686 // Verify
687 assert.NoError(t, err)
688 assert.NotNil(t, resp)
689 assert.Len(t, resp.Comments, 1)
690
691 // Viewer state should be nil
692 commentView := resp.Comments[0].Comment
693 assert.Nil(t, commentView.Viewer)
694}
695
696func TestCommentService_GetComments_SortingOptions(t *testing.T) {
697 // Setup
698 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
699 authorDID := "did:plc:author123"
700 communityDID := "did:plc:community123"
701 commenterDID := "did:plc:commenter123"
702
703 tests := []struct {
704 name string
705 sort string
706 timeframe string
707 wantErr bool
708 }{
709 {"hot sorting", "hot", "", false},
710 {"top sorting", "top", "day", false},
711 {"new sorting", "new", "", false},
712 {"invalid sorting", "invalid", "", true},
713 }
714
715 for _, tt := range tests {
716 t.Run(tt.name, func(t *testing.T) {
717 commentRepo := newMockCommentRepo()
718 userRepo := newMockUserRepo()
719 postRepo := newMockPostRepo()
720 communityRepo := newMockCommunityRepo()
721
722 if !tt.wantErr {
723 post := createTestPost(postURI, authorDID, communityDID)
724 _ = postRepo.Create(context.Background(), post)
725
726 author := createTestUser(authorDID, "author.test")
727 _, _ = userRepo.Create(context.Background(), author)
728
729 community := createTestCommunity(communityDID, "test.community.coves.social")
730 _, _ = communityRepo.Create(context.Background(), community)
731
732 comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
733
734 commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
735 return []*Comment{comment1}, nil, nil
736 }
737 }
738
739 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
740
741 req := &GetCommentsRequest{
742 PostURI: postURI,
743 Sort: tt.sort,
744 Timeframe: tt.timeframe,
745 Depth: 10,
746 Limit: 50,
747 }
748
749 resp, err := service.GetComments(context.Background(), req)
750
751 if tt.wantErr {
752 assert.Error(t, err)
753 assert.Nil(t, resp)
754 } else {
755 assert.NoError(t, err)
756 assert.NotNil(t, resp)
757 }
758 })
759 }
760}
761
762func TestCommentService_GetComments_RepositoryError(t *testing.T) {
763 // Setup
764 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
765 authorDID := "did:plc:author123"
766 communityDID := "did:plc:community123"
767
768 commentRepo := newMockCommentRepo()
769 userRepo := newMockUserRepo()
770 postRepo := newMockPostRepo()
771 communityRepo := newMockCommunityRepo()
772
773 // Setup test data
774 post := createTestPost(postURI, authorDID, communityDID)
775 _ = postRepo.Create(context.Background(), post)
776
777 author := createTestUser(authorDID, "author.test")
778 _, _ = userRepo.Create(context.Background(), author)
779
780 community := createTestCommunity(communityDID, "test.community.coves.social")
781 _, _ = communityRepo.Create(context.Background(), community)
782
783 // Mock repository error
784 commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
785 return nil, nil, errors.New("database error")
786 }
787
788 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
789
790 // Execute
791 req := &GetCommentsRequest{
792 PostURI: postURI,
793 Sort: "hot",
794 Depth: 10,
795 Limit: 50,
796 }
797
798 resp, err := service.GetComments(context.Background(), req)
799
800 // Verify
801 assert.Error(t, err)
802 assert.Nil(t, resp)
803 assert.Contains(t, err.Error(), "failed to fetch top-level comments")
804}
805
806// Test suite for buildThreadViews
807
808func TestCommentService_buildThreadViews_EmptyInput(t *testing.T) {
809 // Setup
810 commentRepo := newMockCommentRepo()
811 userRepo := newMockUserRepo()
812 postRepo := newMockPostRepo()
813 communityRepo := newMockCommunityRepo()
814
815 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
816
817 // Execute
818 result := service.buildThreadViews(context.Background(), []*Comment{}, 10, "hot", nil)
819
820 // Verify - should return empty slice, not nil
821 assert.NotNil(t, result)
822 assert.Len(t, result, 0)
823}
824
825func TestCommentService_buildThreadViews_SkipsDeletedComments(t *testing.T) {
826 // Setup
827 commentRepo := newMockCommentRepo()
828 userRepo := newMockUserRepo()
829 postRepo := newMockPostRepo()
830 communityRepo := newMockCommunityRepo()
831
832 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
833 deletedAt := time.Now()
834
835 // Create a deleted comment
836 deletedComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
837 deletedComment.DeletedAt = &deletedAt
838
839 // Create a normal comment
840 normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
841
842 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
843
844 // Execute
845 result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil)
846
847 // Verify - should only include non-deleted comment
848 assert.Len(t, result, 1)
849 assert.Equal(t, normalComment.URI, result[0].Comment.URI)
850}
851
852func TestCommentService_buildThreadViews_WithNestedReplies(t *testing.T) {
853 // Setup
854 commentRepo := newMockCommentRepo()
855 userRepo := newMockUserRepo()
856 postRepo := newMockPostRepo()
857 communityRepo := newMockCommunityRepo()
858
859 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
860 parentURI := "at://did:plc:commenter123/comment/1"
861 childURI := "at://did:plc:commenter123/comment/2"
862
863 // Parent comment with replies
864 parentComment := createTestComment(parentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 1)
865
866 // Child comment
867 childComment := createTestComment(childURI, "did:plc:commenter123", "commenter.test", postURI, parentURI, 0)
868
869 // Mock batch loading of replies
870 commentRepo.listByParentsBatchFunc = func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error) {
871 return map[string][]*Comment{
872 parentURI: {childComment},
873 }, nil
874 }
875
876 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
877
878 // Execute with depth > 0 to load replies
879 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 1, "hot", nil)
880
881 // Verify
882 assert.Len(t, result, 1)
883 assert.Equal(t, parentURI, result[0].Comment.URI)
884
885 // Check nested replies
886 assert.NotNil(t, result[0].Replies)
887 assert.Len(t, result[0].Replies, 1)
888 assert.Equal(t, childURI, result[0].Replies[0].Comment.URI)
889}
890
891func TestCommentService_buildThreadViews_DepthLimit(t *testing.T) {
892 // Setup
893 commentRepo := newMockCommentRepo()
894 userRepo := newMockUserRepo()
895 postRepo := newMockPostRepo()
896 communityRepo := newMockCommunityRepo()
897
898 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
899
900 // Comment with replies but depth = 0
901 parentComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 5)
902
903 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
904
905 // Execute with depth = 0 (should not load replies)
906 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 0, "hot", nil)
907
908 // Verify
909 assert.Len(t, result, 1)
910 assert.Nil(t, result[0].Replies)
911 assert.True(t, result[0].HasMore) // Should indicate more replies exist
912}
913
914// Test suite for buildCommentView
915
916func TestCommentService_buildCommentView_BasicFields(t *testing.T) {
917 // Setup
918 commentRepo := newMockCommentRepo()
919 userRepo := newMockUserRepo()
920 postRepo := newMockPostRepo()
921 communityRepo := newMockCommunityRepo()
922
923 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
924 commentURI := "at://did:plc:commenter123/comment/1"
925
926 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
927
928 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
929
930 // Execute
931 result := service.buildCommentView(comment, nil, nil)
932
933 // Verify basic fields
934 assert.Equal(t, commentURI, result.URI)
935 assert.Equal(t, comment.CID, result.CID)
936 assert.Equal(t, comment.Content, result.Content)
937 assert.NotNil(t, result.Author)
938 assert.Equal(t, "did:plc:commenter123", result.Author.DID)
939 assert.Equal(t, "commenter.test", result.Author.Handle)
940 assert.NotNil(t, result.Stats)
941 assert.Equal(t, 5, result.Stats.Upvotes)
942 assert.Equal(t, 1, result.Stats.Downvotes)
943 assert.Equal(t, 4, result.Stats.Score)
944 assert.Equal(t, 0, result.Stats.ReplyCount)
945}
946
947func TestCommentService_buildCommentView_TopLevelComment(t *testing.T) {
948 // Setup
949 commentRepo := newMockCommentRepo()
950 userRepo := newMockUserRepo()
951 postRepo := newMockPostRepo()
952 communityRepo := newMockCommunityRepo()
953
954 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
955 commentURI := "at://did:plc:commenter123/comment/1"
956
957 // Top-level comment (parent = root)
958 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
959
960 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
961
962 // Execute
963 result := service.buildCommentView(comment, nil, nil)
964
965 // Verify - parent should be nil for top-level comments
966 assert.NotNil(t, result.Post)
967 assert.Equal(t, postURI, result.Post.URI)
968 assert.Nil(t, result.Parent)
969}
970
971func TestCommentService_buildCommentView_NestedComment(t *testing.T) {
972 // Setup
973 commentRepo := newMockCommentRepo()
974 userRepo := newMockUserRepo()
975 postRepo := newMockPostRepo()
976 communityRepo := newMockCommunityRepo()
977
978 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
979 parentCommentURI := "at://did:plc:commenter123/comment/1"
980 childCommentURI := "at://did:plc:commenter123/comment/2"
981
982 // Nested comment (parent != root)
983 comment := createTestComment(childCommentURI, "did:plc:commenter123", "commenter.test", postURI, parentCommentURI, 0)
984
985 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
986
987 // Execute
988 result := service.buildCommentView(comment, nil, nil)
989
990 // Verify - both post and parent should be present
991 assert.NotNil(t, result.Post)
992 assert.Equal(t, postURI, result.Post.URI)
993 assert.NotNil(t, result.Parent)
994 assert.Equal(t, parentCommentURI, result.Parent.URI)
995}
996
997func TestCommentService_buildCommentView_WithViewerVote(t *testing.T) {
998 // Setup
999 commentRepo := newMockCommentRepo()
1000 userRepo := newMockUserRepo()
1001 postRepo := newMockPostRepo()
1002 communityRepo := newMockCommunityRepo()
1003
1004 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
1005 commentURI := "at://did:plc:commenter123/comment/1"
1006 viewerDID := "did:plc:viewer123"
1007 voteURI := "at://did:plc:viewer123/vote/1"
1008
1009 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
1010
1011 // Mock vote state
1012 voteStates := map[string]interface{}{
1013 commentURI: map[string]interface{}{
1014 "direction": "down",
1015 "uri": voteURI,
1016 },
1017 }
1018
1019 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
1020
1021 // Execute
1022 result := service.buildCommentView(comment, &viewerDID, voteStates)
1023
1024 // Verify viewer state
1025 assert.NotNil(t, result.Viewer)
1026 assert.NotNil(t, result.Viewer.Vote)
1027 assert.Equal(t, "down", *result.Viewer.Vote)
1028 assert.NotNil(t, result.Viewer.VoteURI)
1029 assert.Equal(t, voteURI, *result.Viewer.VoteURI)
1030}
1031
1032func TestCommentService_buildCommentView_NoViewerVote(t *testing.T) {
1033 // Setup
1034 commentRepo := newMockCommentRepo()
1035 userRepo := newMockUserRepo()
1036 postRepo := newMockPostRepo()
1037 communityRepo := newMockCommunityRepo()
1038
1039 postURI := "at://did:plc:post123/app.bsky.feed.post/test"
1040 commentURI := "at://did:plc:commenter123/comment/1"
1041 viewerDID := "did:plc:viewer123"
1042
1043 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
1044
1045 // Empty vote states
1046 voteStates := map[string]interface{}{}
1047
1048 service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
1049
1050 // Execute
1051 result := service.buildCommentView(comment, &viewerDID, voteStates)
1052
1053 // Verify viewer state exists but has no votes
1054 assert.NotNil(t, result.Viewer)
1055 assert.Nil(t, result.Viewer.Vote)
1056 assert.Nil(t, result.Viewer.VoteURI)
1057}
1058
1059// Test suite for validateGetCommentsRequest
1060
1061func TestValidateGetCommentsRequest_NilRequest(t *testing.T) {
1062 err := validateGetCommentsRequest(nil)
1063 assert.Error(t, err)
1064 assert.Contains(t, err.Error(), "request cannot be nil")
1065}
1066
1067func TestValidateGetCommentsRequest_Defaults(t *testing.T) {
1068 req := &GetCommentsRequest{
1069 PostURI: "at://did:plc:post123/app.bsky.feed.post/test",
1070 // Depth and Limit are 0 (zero values)
1071 }
1072
1073 err := validateGetCommentsRequest(req)
1074 assert.NoError(t, err)
1075
1076 // Check defaults applied
1077 assert.Equal(t, "hot", req.Sort)
1078 // Depth 0 is valid (means no replies), only negative values get set to 10
1079 assert.Equal(t, 0, req.Depth)
1080 // Limit <= 0 gets set to 50
1081 assert.Equal(t, 50, req.Limit)
1082}
1083
1084func TestValidateGetCommentsRequest_BoundsEnforcement(t *testing.T) {
1085 tests := []struct {
1086 name string
1087 depth int
1088 limit int
1089 expectedDepth int
1090 expectedLimit int
1091 }{
1092 {"negative depth", -1, 10, 10, 10},
1093 {"depth too high", 150, 10, 100, 10},
1094 {"limit too low", 10, 0, 10, 50},
1095 {"limit too high", 10, 200, 10, 100},
1096 }
1097
1098 for _, tt := range tests {
1099 t.Run(tt.name, func(t *testing.T) {
1100 req := &GetCommentsRequest{
1101 PostURI: "at://did:plc:post123/app.bsky.feed.post/test",
1102 Depth: tt.depth,
1103 Limit: tt.limit,
1104 }
1105
1106 err := validateGetCommentsRequest(req)
1107 assert.NoError(t, err)
1108 assert.Equal(t, tt.expectedDepth, req.Depth)
1109 assert.Equal(t, tt.expectedLimit, req.Limit)
1110 })
1111 }
1112}
1113
1114func TestValidateGetCommentsRequest_InvalidSort(t *testing.T) {
1115 req := &GetCommentsRequest{
1116 PostURI: "at://did:plc:post123/app.bsky.feed.post/test",
1117 Sort: "invalid",
1118 Depth: 10,
1119 Limit: 50,
1120 }
1121
1122 err := validateGetCommentsRequest(req)
1123 assert.Error(t, err)
1124 assert.Contains(t, err.Error(), "invalid sort")
1125}
1126
1127func TestValidateGetCommentsRequest_InvalidTimeframe(t *testing.T) {
1128 req := &GetCommentsRequest{
1129 PostURI: "at://did:plc:post123/app.bsky.feed.post/test",
1130 Sort: "top",
1131 Timeframe: "invalid",
1132 Depth: 10,
1133 Limit: 50,
1134 }
1135
1136 err := validateGetCommentsRequest(req)
1137 assert.Error(t, err)
1138 assert.Contains(t, err.Error(), "invalid timeframe")
1139}