A community based topic aggregation platform built on atproto

refactor(api): remove redundant content fields from view models

Align PostView and CommentView with atProto patterns by accessing content
through the Record object instead of redundant top-level fields. This matches
the Bluesky approach where the record contains the authoritative content.

Changes:
- Remove content field from CommentView (access via Record.Content)
- Remove title/text fields from PostView (access via Record)
- Update lexicons to remove redundant field definitions
- Update comment_service, feed_repo_base, post_repo to stop setting removed fields
- Add test for deleted comments with nil Record
- Add helper functions in integration tests to extract content from Record

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+170 -45
+106 -3
internal/api/handlers/actor/get_comments_test.go
··· 131 131 { 132 132 URI: "at://did:plc:testuser/social.coves.community.comment/abc123", 133 133 CID: "bafytest123", 134 - Content: "Test comment content", 134 + Record: &comments.CommentRecord{ 135 + Type: "social.coves.community.comment", 136 + Content: "Test comment content", 137 + CreatedAt: createdAt, 138 + }, 135 139 CreatedAt: createdAt, 136 140 IndexedAt: indexedAt, 137 141 Author: &posts.AuthorView{ ··· 176 180 t.Errorf("Expected correct comment URI, got '%s'", response.Comments[0].URI) 177 181 } 178 182 179 - if response.Comments[0].Content != "Test comment content" { 180 - t.Errorf("Expected correct comment content, got '%s'", response.Comments[0].Content) 183 + // After JSON marshal/unmarshal, Record becomes map[string]interface{} instead 184 + // of the original *CommentRecord type because json.Unmarshal doesn't preserve 185 + // Go struct types for interface{} fields. 186 + if response.Comments[0].Record == nil { 187 + t.Fatal("Expected Record to be non-nil after JSON round-trip") 188 + } 189 + record, ok := response.Comments[0].Record.(map[string]interface{}) 190 + if !ok { 191 + t.Fatalf("Expected Record to be map[string]interface{}, got %T", response.Comments[0].Record) 192 + } 193 + if record["content"] != "Test comment content" { 194 + t.Errorf("Expected correct comment content, got '%s'", record["content"]) 181 195 } 182 196 } 183 197 ··· 623 637 t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) 624 638 } 625 639 } 640 + 641 + func TestGetCommentsHandler_DeletedComment_NilRecord(t *testing.T) { 642 + // Test that deleted comments are properly serialized with nil Record at the API layer. 643 + // This verifies the JSON response correctly handles deleted comments where content 644 + // has been removed but the comment shell remains for thread continuity. 645 + createdAt := time.Now().Format(time.RFC3339) 646 + indexedAt := time.Now().Format(time.RFC3339) 647 + deletedAt := time.Now().Format(time.RFC3339) 648 + deletionReason := "User deleted" 649 + 650 + mockComments := &mockCommentService{ 651 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 652 + return &comments.GetActorCommentsResponse{ 653 + Comments: []*comments.CommentView{ 654 + { 655 + URI: "at://did:plc:testuser/social.coves.community.comment/deleted123", 656 + CID: "bafydeleted", 657 + Record: nil, // Deleted comments have nil Record 658 + IsDeleted: true, 659 + DeletedAt: &deletedAt, 660 + DeletionReason: &deletionReason, 661 + CreatedAt: createdAt, 662 + IndexedAt: indexedAt, 663 + Author: &posts.AuthorView{ 664 + DID: "did:plc:testuser", 665 + Handle: "test.user", 666 + }, 667 + Post: &comments.CommentRef{ 668 + URI: "at://did:plc:community/social.coves.community.post/parent123", 669 + CID: "bafyparent", 670 + }, 671 + Stats: &comments.CommentStats{ 672 + Upvotes: 0, 673 + Downvotes: 0, 674 + Score: 0, 675 + ReplyCount: 0, 676 + }, 677 + }, 678 + }, 679 + }, nil 680 + }, 681 + } 682 + mockUsers := &mockUserServiceForComments{} 683 + mockVotes := &mockVoteServiceForComments{} 684 + 685 + handler := NewGetCommentsHandler(mockComments, mockUsers, mockVotes) 686 + 687 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:testuser", nil) 688 + rec := httptest.NewRecorder() 689 + 690 + handler.HandleGetComments(rec, req) 691 + 692 + if rec.Code != http.StatusOK { 693 + t.Errorf("Expected status 200, got %d", rec.Code) 694 + } 695 + 696 + var response comments.GetActorCommentsResponse 697 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 698 + t.Fatalf("Failed to decode response: %v", err) 699 + } 700 + 701 + if len(response.Comments) != 1 { 702 + t.Fatalf("Expected 1 comment, got %d", len(response.Comments)) 703 + } 704 + 705 + deletedComment := response.Comments[0] 706 + 707 + // Verify deleted comment fields 708 + if !deletedComment.IsDeleted { 709 + t.Error("Expected IsDeleted to be true for deleted comment") 710 + } 711 + 712 + if deletedComment.Record != nil { 713 + t.Errorf("Expected Record to be nil for deleted comment, got %T", deletedComment.Record) 714 + } 715 + 716 + if deletedComment.DeletedAt == nil || *deletedComment.DeletedAt != deletedAt { 717 + t.Errorf("Expected DeletedAt to be %s, got %v", deletedAt, deletedComment.DeletedAt) 718 + } 719 + 720 + if deletedComment.DeletionReason == nil || *deletedComment.DeletionReason != deletionReason { 721 + t.Errorf("Expected DeletionReason to be %s, got %v", deletionReason, deletedComment.DeletionReason) 722 + } 723 + 724 + // Verify author info is still present (for attribution even on deleted comments) 725 + if deletedComment.Author == nil || deletedComment.Author.DID != "did:plc:testuser" { 726 + t.Error("Expected deleted comment to retain author information") 727 + } 728 + }
+1 -5
internal/atproto/lexicon/social/coves/community/comment/defs.json
··· 5 5 "commentView": { 6 6 "type": "object", 7 7 "description": "Base view for a single comment with voting, stats, and viewer state", 8 - "required": ["uri", "cid", "author", "record", "post", "content", "createdAt", "indexedAt", "stats"], 8 + "required": ["uri", "cid", "author", "record", "post", "createdAt", "indexedAt", "stats"], 9 9 "properties": { 10 10 "uri": { 11 11 "type": "string", ··· 35 35 "type": "ref", 36 36 "ref": "#commentRef", 37 37 "description": "Reference to parent comment if this is a nested reply" 38 - }, 39 - "content": { 40 - "type": "string", 41 - "description": "Comment text content" 42 38 }, 43 39 "embed": { 44 40 "type": "union",
-6
internal/atproto/lexicon/social/coves/community/post/get.json
··· 66 66 "type": "ref", 67 67 "ref": "#communityRef" 68 68 }, 69 - "title": { 70 - "type": "string" 71 - }, 72 - "text": { 73 - "type": "string" 74 - }, 75 69 "embed": { 76 70 "type": "union", 77 71 "description": "Embedded content (images, video, link preview, or quoted post)",
-4
internal/core/comments/comment_service.go
··· 426 426 Record: commentRecord, 427 427 Post: postRef, 428 428 Parent: parentRef, 429 - Content: comment.Content, 430 429 Embed: embed, 431 430 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 432 431 IndexedAt: comment.IndexedAt.Format(time.RFC3339), ··· 486 485 Record: nil, // No record for deleted comments 487 486 Post: postRef, 488 487 Parent: parentRef, 489 - Content: "", // Blanked content 490 488 Embed: nil, 491 489 CreatedAt: comment.CreatedAt.Format(time.RFC3339), 492 490 IndexedAt: comment.IndexedAt.Format(time.RFC3339), ··· 975 973 Author: authorView, 976 974 Record: postRecord, 977 975 Community: communityRef, 978 - Title: post.Title, 979 - Text: post.Content, 980 976 CreatedAt: post.CreatedAt, 981 977 IndexedAt: post.IndexedAt, 982 978 EditedAt: post.EditedAt,
+3 -2
internal/core/comments/comment_service_test.go
··· 939 939 assert.Equal(t, deletedComment.URI, result[0].Comment.URI) 940 940 assert.True(t, result[0].Comment.IsDeleted) 941 941 assert.Equal(t, DeletionReasonAuthor, *result[0].Comment.DeletionReason) 942 - assert.Empty(t, result[0].Comment.Content) 942 + assert.Nil(t, result[0].Comment.Record) // Deleted comments have nil record 943 943 944 944 // Second comment should be the normal one 945 945 assert.Equal(t, normalComment.URI, result[1].Comment.URI) ··· 1031 1031 // Verify basic fields 1032 1032 assert.Equal(t, commentURI, result.URI) 1033 1033 assert.Equal(t, comment.CID, result.CID) 1034 - assert.Equal(t, comment.Content, result.Content) 1034 + record := result.Record.(*CommentRecord) 1035 + assert.Equal(t, comment.Content, record.Content) 1035 1036 assert.NotNil(t, result.Author) 1036 1037 assert.Equal(t, "did:plc:commenter123", result.Author.DID) 1037 1038 assert.Equal(t, "commenter.test", result.Author.Handle)
-1
internal/core/comments/view_models.go
··· 16 16 Post *CommentRef `json:"post"` 17 17 Parent *CommentRef `json:"parent,omitempty"` 18 18 Stats *CommentStats `json:"stats"` 19 - Content string `json:"content"` 20 19 CreatedAt string `json:"createdAt"` 21 20 IndexedAt string `json:"indexedAt"` 22 21 URI string `json:"uri"`
-2
internal/core/posts/post.go
··· 91 91 Embed interface{} `json:"embed,omitempty"` 92 92 Language *string `json:"language,omitempty"` 93 93 EditedAt *time.Time `json:"editedAt,omitempty"` 94 - Title *string `json:"title,omitempty"` 95 - Text *string `json:"text,omitempty"` 96 94 Viewer *ViewerState `json:"viewer,omitempty"` 97 95 Author *AuthorView `json:"author"` 98 96 Stats *PostStats `json:"stats,omitempty"`
-4
internal/db/postgres/feed_repo_base.go
··· 358 358 } 359 359 postView.Community = &communityRef 360 360 361 - // Set optional fields 362 - postView.Title = nullStringPtr(title) 363 - postView.Text = nullStringPtr(content) 364 - 365 361 // Parse facets JSON into local variable (will be added to record below) 366 362 // Log errors but continue - a single malformed post shouldn't break the entire feed 367 363 var facetArray []interface{}
-6
internal/db/postgres/post_repo.go
··· 346 346 postView.Community = &communityRef 347 347 348 348 // Set optional fields 349 - if title.Valid { 350 - postView.Title = &title.String 351 - } 352 - if content.Valid { 353 - postView.Text = &content.String 354 - } 355 349 if editedAt.Valid { 356 350 postView.EditedAt = &editedAt.Time 357 351 }
+20 -2
tests/integration/author_posts_e2e_test.go
··· 25 25 "github.com/pressly/goose/v3" 26 26 ) 27 27 28 + // getPostTitleFromView extracts title from PostView.Record. 29 + // Fails the test if Record structure is invalid (should not happen in valid responses). 30 + func getPostTitleFromView(t *testing.T, pv *posts.PostView) string { 31 + t.Helper() 32 + if pv.Record == nil { 33 + t.Fatalf("getPostTitleFromView: Record is nil for post URI %s", pv.URI) 34 + } 35 + record, ok := pv.Record.(map[string]interface{}) 36 + if !ok { 37 + t.Fatalf("getPostTitleFromView: Record is %T, not map[string]interface{}", pv.Record) 38 + } 39 + title, ok := record["title"].(string) 40 + if !ok { 41 + t.Fatalf("getPostTitleFromView: title field missing or not string, Record: %+v", record) 42 + } 43 + return title 44 + } 45 + 28 46 // TestGetAuthorPosts_E2E_Success tests the full author posts flow with real PDS 29 47 // Flow: Create user on PDS → Create posts → Query via XRPC → Verify response 30 48 func TestGetAuthorPosts_E2E_Success(t *testing.T) { ··· 629 647 } 630 648 631 649 if len(response.Feed) > 0 && response.Feed[0].Post != nil { 632 - title := response.Feed[0].Post.Title 633 - if title == nil || *title != "Jetstream Indexed Post" { 650 + title := getPostTitleFromView(t, response.Feed[0].Post) 651 + if title != "Jetstream Indexed Post" { 634 652 t.Errorf("Expected title 'Jetstream Indexed Post', got %v", title) 635 653 } 636 654 }
+14 -3
tests/integration/blob_upload_e2e_test.go
··· 164 164 165 165 // STEP 6: Verify blob URL transformation in feed responses 166 166 // This is what the feed handler would do before returning to client 167 + // Build the record as the feed repos do 168 + record := map[string]interface{}{ 169 + "$type": "social.coves.community.post", 170 + "createdAt": indexedPost.CreatedAt.Format(time.RFC3339), 171 + } 172 + if indexedPost.Title != nil { 173 + record["title"] = *indexedPost.Title 174 + } 175 + if indexedPost.Content != nil { 176 + record["content"] = *indexedPost.Content 177 + } 178 + 167 179 postView := &posts.PostView{ 168 180 URI: indexedPost.URI, 169 181 CID: indexedPost.CID, 170 - Title: indexedPost.Title, 171 - Text: indexedPost.Content, // Content maps to Text in PostView 172 - Embed: embedMap, // Use parsed embed map 182 + Record: record, 183 + Embed: embedMap, // Use parsed embed map 173 184 CreatedAt: indexedPost.CreatedAt, 174 185 Community: &posts.CommunityRef{ 175 186 DID: community.DID,
+26 -7
tests/integration/feed_test.go
··· 4 4 "Coves/internal/api/handlers/communityFeed" 5 5 "Coves/internal/core/communities" 6 6 "Coves/internal/core/communityFeeds" 7 + "Coves/internal/core/posts" 7 8 "Coves/internal/db/postgres" 8 9 "context" 9 10 "encoding/json" ··· 16 17 "github.com/stretchr/testify/assert" 17 18 "github.com/stretchr/testify/require" 18 19 ) 20 + 21 + // getPostTitle extracts title from PostView.Record. 22 + // Fails the test if Record structure is invalid (should not happen in valid responses). 23 + func getPostTitle(t *testing.T, pv *posts.PostView) string { 24 + t.Helper() 25 + if pv.Record == nil { 26 + t.Fatalf("getPostTitle: Record is nil for post URI %s", pv.URI) 27 + } 28 + record, ok := pv.Record.(map[string]interface{}) 29 + if !ok { 30 + t.Fatalf("getPostTitle: Record is %T, not map[string]interface{}", pv.Record) 31 + } 32 + title, ok := record["title"].(string) 33 + if !ok { 34 + t.Fatalf("getPostTitle: title field missing or not string, Record: %+v", record) 35 + } 36 + return title 37 + } 19 38 20 39 // TestGetCommunityFeed_Hot tests hot feed sorting algorithm 21 40 func TestGetCommunityFeed_Hot(t *testing.T) { ··· 150 169 assert.Len(t, response.Feed, 2) 151 170 152 171 // Verify top-ranked post (highest score) 153 - assert.Equal(t, "2 hours old", *response.Feed[0].Post.Title) 172 + assert.Equal(t, "2 hours old", getPostTitle(t, response.Feed[0].Post)) 154 173 assert.Equal(t, 100, response.Feed[0].Post.Stats.Score) 155 174 }) 156 175 ··· 169 188 assert.Len(t, response.Feed, 3) 170 189 171 190 // Highest score should be first 172 - assert.Equal(t, "2 days old", *response.Feed[0].Post.Title) 191 + assert.Equal(t, "2 days old", getPostTitle(t, response.Feed[0].Post)) 173 192 assert.Equal(t, 200, response.Feed[0].Post.Stats.Score) 174 193 }) 175 194 } ··· 227 246 assert.Len(t, response.Feed, 3) 228 247 229 248 // Verify chronological order (newest first) 230 - assert.Equal(t, "Newest post", *response.Feed[0].Post.Title) 231 - assert.Equal(t, "Middle post", *response.Feed[1].Post.Title) 232 - assert.Equal(t, "Oldest post", *response.Feed[2].Post.Title) 249 + assert.Equal(t, "Newest post", getPostTitle(t, response.Feed[0].Post)) 250 + assert.Equal(t, "Middle post", getPostTitle(t, response.Feed[1].Post)) 251 + assert.Equal(t, "Oldest post", getPostTitle(t, response.Feed[2].Post)) 233 252 } 234 253 235 254 // TestGetCommunityFeed_Pagination tests cursor-based pagination ··· 580 599 581 600 // The highest hot_rank post should be first (recent with low-medium score) 582 601 firstPostURI := page1.Feed[0].Post.URI 583 - t.Logf("Page 1 - First post: %s (URI: %s)", *page1.Feed[0].Post.Title, firstPostURI) 602 + t.Logf("Page 1 - First post: %s (URI: %s)", getPostTitle(t, page1.Feed[0].Post), firstPostURI) 584 603 t.Logf("Page 1 - Cursor: %s", *page1.Cursor) 585 604 586 605 // Page 2: Use cursor - this is where the bug would occur ··· 606 625 seenURIs := map[string]bool{firstPostURI: true} 607 626 for _, p := range page2.Feed { 608 627 allURIs = append(allURIs, p.Post.URI) 609 - t.Logf("Page 2 - Post: %s (URI: %s)", *p.Post.Title, p.Post.URI) 628 + t.Logf("Page 2 - Post: %s (URI: %s)", getPostTitle(t, p.Post), p.Post.URI) 610 629 // Check for duplicates 611 630 if seenURIs[p.Post.URI] { 612 631 t.Errorf("Duplicate post found: %s", p.Post.URI)