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}