A community based topic aggregation platform built on atproto

Merge branch 'feat/comment-write-operations'

Add complete comment write operations (create/update/delete) with:
- XRPC lexicons for all three operations
- Service layer with validation and authorization
- HTTP handlers with proper error mapping
- Comprehensive unit and integration tests
- Proper grapheme counting with uniseg library

Follows write-forward architecture: Client → Handler → Service → PDS → Jetstream → DB

+3250 -48
+11 -3
cmd/server/main.go
··· 408 408 voteService := votes.NewService(voteRepo, oauthClient, oauthStore, voteCache, nil) 409 409 log.Println("✅ Vote service initialized (with OAuth authentication and vote cache)") 410 410 411 - // Initialize comment service (for query API) 411 + // Initialize comment service (for query and write APIs) 412 412 // Requires user and community repos for proper author/community hydration per lexicon 413 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 414 - log.Println("✅ Comment service initialized (with author/community hydration)") 413 + // OAuth client and store are needed for write operations (create, update, delete) 414 + commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo, oauthClient, oauthStore, nil) 415 + log.Println("✅ Comment service initialized (with author/community hydration and write support)") 415 416 416 417 // Initialize feed service 417 418 feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) ··· 528 529 529 530 routes.RegisterVoteRoutes(r, voteService, authMiddleware) 530 531 log.Println("Vote XRPC endpoints registered with OAuth authentication") 532 + 533 + // Register comment write routes (create, update, delete) 534 + routes.RegisterCommentRoutes(r, commentService, authMiddleware) 535 + log.Println("Comment write XRPC endpoints registered") 536 + log.Println(" - POST /xrpc/social.coves.community.comment.create") 537 + log.Println(" - POST /xrpc/social.coves.community.comment.update") 538 + log.Println(" - POST /xrpc/social.coves.community.comment.delete") 531 539 532 540 routes.RegisterCommunityFeedRoutes(r, feedService, voteService, authMiddleware) 533 541 log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)")
+124
docs/PRD_BACKLOG.md
··· 649 649 650 650 ## 🔵 P3: Technical Debt 651 651 652 + ### Implement PutRecord in PDS Client 653 + **Added:** 2025-12-04 | **Effort:** 2-3 hours | **Priority:** Technical Debt 654 + **Status:** 📋 TODO 655 + 656 + **Problem:** 657 + The PDS client (`internal/atproto/pds/client.go`) only has `CreateRecord` but lacks `PutRecord`. This means updates use `CreateRecord` with an existing rkey, which: 658 + 1. Loses optimistic locking (no CID swap check) 659 + 2. Is semantically incorrect (creates vs updates) 660 + 3. Could cause race conditions on concurrent updates 661 + 662 + **atProto Best Practice:** 663 + - `com.atproto.repo.putRecord` should be used for updates 664 + - Accepts `swapRecord` (expected CID) for optimistic locking 665 + - Returns conflict error if CID doesn't match (concurrent modification detected) 666 + 667 + **Solution:** 668 + Add `PutRecord` method to the PDS client interface: 669 + 670 + ```go 671 + // Client interface addition 672 + type Client interface { 673 + // ... existing methods ... 674 + 675 + // PutRecord creates or updates a record with optional optimistic locking. 676 + // If swapRecord is provided, the operation fails if the current CID doesn't match. 677 + PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (uri string, cid string, err error) 678 + } 679 + 680 + // Implementation 681 + func (c *client) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) { 682 + payload := map[string]any{ 683 + "repo": c.did, 684 + "collection": collection, 685 + "rkey": rkey, 686 + "record": record, 687 + } 688 + 689 + // Optional: optimistic locking via CID swap check 690 + if swapRecord != "" { 691 + payload["swapRecord"] = swapRecord 692 + } 693 + 694 + var result struct { 695 + URI string `json:"uri"` 696 + CID string `json:"cid"` 697 + } 698 + 699 + err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), payload, &result) 700 + if err != nil { 701 + return "", "", wrapAPIError(err, "putRecord") 702 + } 703 + 704 + return result.URI, result.CID, nil 705 + } 706 + ``` 707 + 708 + **Error Handling:** 709 + Add new error type for conflict detection: 710 + ```go 711 + var ErrConflict = errors.New("record was modified by another operation") 712 + ``` 713 + 714 + Map HTTP 409 in `wrapAPIError`: 715 + ```go 716 + case 409: 717 + return fmt.Errorf("%s: %w: %s", operation, ErrConflict, apiErr.Message) 718 + ``` 719 + 720 + **Files to Modify:** 721 + - `internal/atproto/pds/client.go` - Add `PutRecord` method and interface 722 + - `internal/atproto/pds/errors.go` - Add `ErrConflict` error type 723 + 724 + **Testing:** 725 + - Unit test: Verify payload includes `swapRecord` when provided 726 + - Integration test: Concurrent updates detect conflict 727 + - Integration test: Update without `swapRecord` still works (backwards compatible) 728 + 729 + **Blocked By:** Nothing 730 + **Blocks:** "Migrate UpdateComment to use PutRecord" 731 + 732 + --- 733 + 734 + ### Migrate UpdateComment to Use PutRecord 735 + **Added:** 2025-12-04 | **Effort:** 1 hour | **Priority:** Technical Debt 736 + **Status:** 📋 TODO (Blocked) 737 + **Blocked By:** "Implement PutRecord in PDS Client" 738 + 739 + **Problem:** 740 + `UpdateComment` in `internal/core/comments/comment_service.go` uses `CreateRecord` for updates instead of `PutRecord`. This lacks optimistic locking and is semantically incorrect. 741 + 742 + **Current Code (lines 687-690):** 743 + ```go 744 + // TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking. 745 + // PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected. 746 + // However, PutRecord is not yet implemented in internal/atproto/pds/client.go. 747 + uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord) 748 + ``` 749 + 750 + **Solution:** 751 + Once `PutRecord` is implemented in the PDS client, update to: 752 + ```go 753 + // Use PutRecord with optimistic locking via existing CID 754 + uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID) 755 + if err != nil { 756 + if errors.Is(err, pds.ErrConflict) { 757 + // Record was modified by another operation - return appropriate error 758 + return nil, fmt.Errorf("comment was modified, please refresh and try again: %w", err) 759 + } 760 + // ... existing error handling 761 + } 762 + ``` 763 + 764 + **Files to Modify:** 765 + - `internal/core/comments/comment_service.go` - UpdateComment method 766 + - `internal/core/comments/errors.go` - Add `ErrConcurrentModification` if needed 767 + 768 + **Testing:** 769 + - Unit test: Verify `PutRecord` is called with correct CID 770 + - Integration test: Simulate concurrent update, verify conflict handling 771 + 772 + **Impact:** Proper optimistic locking prevents lost updates from race conditions 773 + 774 + --- 775 + 652 776 ### Consolidate Environment Variable Validation 653 777 **Added:** 2025-10-11 | **Effort:** 2-3 hours 654 778
+1 -1
go.mod
··· 72 72 github.com/prometheus/client_model v0.5.0 // indirect 73 73 github.com/prometheus/common v0.45.0 // indirect 74 74 github.com/prometheus/procfs v0.12.0 // indirect 75 - github.com/rivo/uniseg v0.1.0 // indirect 75 + github.com/rivo/uniseg v0.4.7 // indirect 76 76 github.com/segmentio/asm v1.2.0 // indirect 77 77 github.com/sethvargo/go-retry v0.3.0 // indirect 78 78 github.com/spaolacci/murmur3 v1.1.0 // indirect
+2
go.sum
··· 159 159 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 160 160 github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= 161 161 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 162 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 163 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 162 164 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 163 165 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 164 166 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+130
internal/api/handlers/comments/create_comment.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/comments" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // CreateCommentHandler handles comment creation requests 12 + type CreateCommentHandler struct { 13 + service comments.Service 14 + } 15 + 16 + // NewCreateCommentHandler creates a new handler for creating comments 17 + func NewCreateCommentHandler(service comments.Service) *CreateCommentHandler { 18 + return &CreateCommentHandler{ 19 + service: service, 20 + } 21 + } 22 + 23 + // CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create 24 + type CreateCommentInput struct { 25 + Reply struct { 26 + Root struct { 27 + URI string `json:"uri"` 28 + CID string `json:"cid"` 29 + } `json:"root"` 30 + Parent struct { 31 + URI string `json:"uri"` 32 + CID string `json:"cid"` 33 + } `json:"parent"` 34 + } `json:"reply"` 35 + Content string `json:"content"` 36 + Facets []interface{} `json:"facets,omitempty"` 37 + Embed interface{} `json:"embed,omitempty"` 38 + Langs []string `json:"langs,omitempty"` 39 + Labels interface{} `json:"labels,omitempty"` 40 + } 41 + 42 + // CreateCommentOutput matches the lexicon output schema 43 + type CreateCommentOutput struct { 44 + URI string `json:"uri"` 45 + CID string `json:"cid"` 46 + } 47 + 48 + // HandleCreate handles comment creation requests 49 + // POST /xrpc/social.coves.community.comment.create 50 + // 51 + // Request body: { "reply": { "root": {...}, "parent": {...} }, "content": "..." } 52 + // Response: { "uri": "at://...", "cid": "..." } 53 + func (h *CreateCommentHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { 54 + // 1. Check method is POST 55 + if r.Method != http.MethodPost { 56 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 57 + return 58 + } 59 + 60 + // 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments) 61 + r.Body = http.MaxBytesReader(w, r.Body, 100*1024) 62 + 63 + // 3. Parse JSON body into CreateCommentInput 64 + var input CreateCommentInput 65 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 66 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 67 + return 68 + } 69 + 70 + // 4. Get OAuth session from context (injected by auth middleware) 71 + session := middleware.GetOAuthSession(r) 72 + if session == nil { 73 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 74 + return 75 + } 76 + 77 + // 5. Convert labels interface{} to *comments.SelfLabels if provided 78 + var labels *comments.SelfLabels 79 + if input.Labels != nil { 80 + labelsJSON, err := json.Marshal(input.Labels) 81 + if err != nil { 82 + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format") 83 + return 84 + } 85 + var selfLabels comments.SelfLabels 86 + if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil { 87 + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure") 88 + return 89 + } 90 + labels = &selfLabels 91 + } 92 + 93 + // 6. Convert input to CreateCommentRequest 94 + req := comments.CreateCommentRequest{ 95 + Reply: comments.ReplyRef{ 96 + Root: comments.StrongRef{ 97 + URI: input.Reply.Root.URI, 98 + CID: input.Reply.Root.CID, 99 + }, 100 + Parent: comments.StrongRef{ 101 + URI: input.Reply.Parent.URI, 102 + CID: input.Reply.Parent.CID, 103 + }, 104 + }, 105 + Content: input.Content, 106 + Facets: input.Facets, 107 + Embed: input.Embed, 108 + Langs: input.Langs, 109 + Labels: labels, 110 + } 111 + 112 + // 7. Call service to create comment 113 + response, err := h.service.CreateComment(r.Context(), session, req) 114 + if err != nil { 115 + handleServiceError(w, err) 116 + return 117 + } 118 + 119 + // 8. Return JSON response with URI and CID 120 + output := CreateCommentOutput{ 121 + URI: response.URI, 122 + CID: response.CID, 123 + } 124 + 125 + w.Header().Set("Content-Type", "application/json") 126 + w.WriteHeader(http.StatusOK) 127 + if err := json.NewEncoder(w).Encode(output); err != nil { 128 + log.Printf("Failed to encode response: %v", err) 129 + } 130 + }
+80
internal/api/handlers/comments/delete_comment.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/comments" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // DeleteCommentHandler handles comment deletion requests 12 + type DeleteCommentHandler struct { 13 + service comments.Service 14 + } 15 + 16 + // NewDeleteCommentHandler creates a new handler for deleting comments 17 + func NewDeleteCommentHandler(service comments.Service) *DeleteCommentHandler { 18 + return &DeleteCommentHandler{ 19 + service: service, 20 + } 21 + } 22 + 23 + // DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete 24 + type DeleteCommentInput struct { 25 + URI string `json:"uri"` 26 + } 27 + 28 + // DeleteCommentOutput is empty per lexicon specification 29 + type DeleteCommentOutput struct{} 30 + 31 + // HandleDelete handles comment deletion requests 32 + // POST /xrpc/social.coves.community.comment.delete 33 + // 34 + // Request body: { "uri": "at://..." } 35 + // Response: {} 36 + func (h *DeleteCommentHandler) HandleDelete(w http.ResponseWriter, r *http.Request) { 37 + // 1. Check method is POST 38 + if r.Method != http.MethodPost { 39 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 40 + return 41 + } 42 + 43 + // 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments) 44 + r.Body = http.MaxBytesReader(w, r.Body, 100*1024) 45 + 46 + // 3. Parse JSON body into DeleteCommentInput 47 + var input DeleteCommentInput 48 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 49 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 50 + return 51 + } 52 + 53 + // 4. Get OAuth session from context (injected by auth middleware) 54 + session := middleware.GetOAuthSession(r) 55 + if session == nil { 56 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 57 + return 58 + } 59 + 60 + // 5. Convert input to DeleteCommentRequest 61 + req := comments.DeleteCommentRequest{ 62 + URI: input.URI, 63 + } 64 + 65 + // 6. Call service to delete comment 66 + err := h.service.DeleteComment(r.Context(), session, req) 67 + if err != nil { 68 + handleServiceError(w, err) 69 + return 70 + } 71 + 72 + // 7. Return empty JSON object per lexicon specification 73 + output := DeleteCommentOutput{} 74 + 75 + w.Header().Set("Content-Type", "application/json") 76 + w.WriteHeader(http.StatusOK) 77 + if err := json.NewEncoder(w).Encode(output); err != nil { 78 + log.Printf("Failed to encode response: %v", err) 79 + } 80 + }
+34 -2
internal/api/handlers/comments/errors.go
··· 3 3 import ( 4 4 "Coves/internal/core/comments" 5 5 "encoding/json" 6 + "errors" 6 7 "log" 7 8 "net/http" 8 9 ) ··· 30 31 func handleServiceError(w http.ResponseWriter, err error) { 31 32 switch { 32 33 case comments.IsNotFound(err): 33 - writeError(w, http.StatusNotFound, "NotFound", err.Error()) 34 + // Map specific not found errors to appropriate messages 35 + switch { 36 + case errors.Is(err, comments.ErrCommentNotFound): 37 + writeError(w, http.StatusNotFound, "CommentNotFound", "Comment not found") 38 + case errors.Is(err, comments.ErrParentNotFound): 39 + writeError(w, http.StatusNotFound, "ParentNotFound", "Parent post or comment not found") 40 + case errors.Is(err, comments.ErrRootNotFound): 41 + writeError(w, http.StatusNotFound, "RootNotFound", "Root post not found") 42 + default: 43 + writeError(w, http.StatusNotFound, "NotFound", err.Error()) 44 + } 34 45 35 46 case comments.IsValidationError(err): 36 - writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 47 + // Map specific validation errors to appropriate messages 48 + switch { 49 + case errors.Is(err, comments.ErrInvalidReply): 50 + writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed") 51 + case errors.Is(err, comments.ErrContentTooLong): 52 + writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes") 53 + case errors.Is(err, comments.ErrContentEmpty): 54 + writeError(w, http.StatusBadRequest, "ContentEmpty", "Comment content is required") 55 + default: 56 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 57 + } 58 + 59 + case errors.Is(err, comments.ErrNotAuthorized): 60 + writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action") 61 + 62 + case errors.Is(err, comments.ErrBanned): 63 + writeError(w, http.StatusForbidden, "Banned", "User is banned from this community") 64 + 65 + // NOTE: IsConflict case removed - the PDS handles duplicate detection via CreateRecord, 66 + // so ErrCommentAlreadyExists is never returned from the service layer. If the PDS rejects 67 + // a duplicate record, it returns an auth/validation error which is handled by other cases. 68 + // Keeping this code would be dead code that never executes. 37 69 38 70 default: 39 71 // Don't leak internal error details to clients
+112
internal/api/handlers/comments/update_comment.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/comments" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // UpdateCommentHandler handles comment update requests 12 + type UpdateCommentHandler struct { 13 + service comments.Service 14 + } 15 + 16 + // NewUpdateCommentHandler creates a new handler for updating comments 17 + func NewUpdateCommentHandler(service comments.Service) *UpdateCommentHandler { 18 + return &UpdateCommentHandler{ 19 + service: service, 20 + } 21 + } 22 + 23 + // UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update 24 + type UpdateCommentInput struct { 25 + URI string `json:"uri"` 26 + Content string `json:"content"` 27 + Facets []interface{} `json:"facets,omitempty"` 28 + Embed interface{} `json:"embed,omitempty"` 29 + Langs []string `json:"langs,omitempty"` 30 + Labels interface{} `json:"labels,omitempty"` 31 + } 32 + 33 + // UpdateCommentOutput matches the lexicon output schema 34 + type UpdateCommentOutput struct { 35 + URI string `json:"uri"` 36 + CID string `json:"cid"` 37 + } 38 + 39 + // HandleUpdate handles comment update requests 40 + // POST /xrpc/social.coves.community.comment.update 41 + // 42 + // Request body: { "uri": "at://...", "content": "..." } 43 + // Response: { "uri": "at://...", "cid": "..." } 44 + func (h *UpdateCommentHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) { 45 + // 1. Check method is POST 46 + if r.Method != http.MethodPost { 47 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 48 + return 49 + } 50 + 51 + // 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments) 52 + r.Body = http.MaxBytesReader(w, r.Body, 100*1024) 53 + 54 + // 3. Parse JSON body into UpdateCommentInput 55 + var input UpdateCommentInput 56 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 57 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 58 + return 59 + } 60 + 61 + // 4. Get OAuth session from context (injected by auth middleware) 62 + session := middleware.GetOAuthSession(r) 63 + if session == nil { 64 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 65 + return 66 + } 67 + 68 + // 5. Convert labels interface{} to *comments.SelfLabels if provided 69 + var labels *comments.SelfLabels 70 + if input.Labels != nil { 71 + labelsJSON, err := json.Marshal(input.Labels) 72 + if err != nil { 73 + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format") 74 + return 75 + } 76 + var selfLabels comments.SelfLabels 77 + if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil { 78 + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure") 79 + return 80 + } 81 + labels = &selfLabels 82 + } 83 + 84 + // 6. Convert input to UpdateCommentRequest 85 + req := comments.UpdateCommentRequest{ 86 + URI: input.URI, 87 + Content: input.Content, 88 + Facets: input.Facets, 89 + Embed: input.Embed, 90 + Langs: input.Langs, 91 + Labels: labels, 92 + } 93 + 94 + // 7. Call service to update comment 95 + response, err := h.service.UpdateComment(r.Context(), session, req) 96 + if err != nil { 97 + handleServiceError(w, err) 98 + return 99 + } 100 + 101 + // 8. Return JSON response with URI and CID 102 + output := UpdateCommentOutput{ 103 + URI: response.URI, 104 + CID: response.CID, 105 + } 106 + 107 + w.Header().Set("Content-Type", "application/json") 108 + w.WriteHeader(http.StatusOK) 109 + if err := json.NewEncoder(w).Encode(output); err != nil { 110 + log.Printf("Failed to encode response: %v", err) 111 + } 112 + }
+35
internal/api/routes/comment.go
··· 1 + package routes 2 + 3 + import ( 4 + "Coves/internal/api/handlers/comments" 5 + "Coves/internal/api/middleware" 6 + commentsCore "Coves/internal/core/comments" 7 + 8 + "github.com/go-chi/chi/v5" 9 + ) 10 + 11 + // RegisterCommentRoutes registers comment-related XRPC endpoints on the router 12 + // Implements social.coves.community.comment.* lexicon endpoints 13 + // All write operations (create, update, delete) require authentication 14 + func RegisterCommentRoutes(r chi.Router, service commentsCore.Service, authMiddleware *middleware.OAuthAuthMiddleware) { 15 + // Initialize handlers 16 + createHandler := comments.NewCreateCommentHandler(service) 17 + updateHandler := comments.NewUpdateCommentHandler(service) 18 + deleteHandler := comments.NewDeleteCommentHandler(service) 19 + 20 + // Procedure endpoints (POST) - require authentication 21 + // social.coves.community.comment.create - create a new comment on a post or another comment 22 + r.With(authMiddleware.RequireAuth).Post( 23 + "/xrpc/social.coves.community.comment.create", 24 + createHandler.HandleCreate) 25 + 26 + // social.coves.community.comment.update - update an existing comment's content 27 + r.With(authMiddleware.RequireAuth).Post( 28 + "/xrpc/social.coves.community.comment.update", 29 + updateHandler.HandleUpdate) 30 + 31 + // social.coves.community.comment.delete - soft delete a comment 32 + r.With(authMiddleware.RequireAuth).Post( 33 + "/xrpc/social.coves.community.comment.delete", 34 + deleteHandler.HandleDelete) 35 + }
+109
internal/atproto/lexicon/social/coves/community/comment/create.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.community.comment.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a comment on a post or another comment. Comments support nested threading, rich text, embeds, and self-labeling.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["reply", "content"], 13 + "properties": { 14 + "reply": { 15 + "type": "object", 16 + "description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).", 17 + "required": ["root", "parent"], 18 + "properties": { 19 + "root": { 20 + "type": "ref", 21 + "ref": "com.atproto.repo.strongRef", 22 + "description": "Strong reference to the original post that started the thread" 23 + }, 24 + "parent": { 25 + "type": "ref", 26 + "ref": "com.atproto.repo.strongRef", 27 + "description": "Strong reference to the immediate parent (post or comment) being replied to" 28 + } 29 + } 30 + }, 31 + "content": { 32 + "type": "string", 33 + "maxGraphemes": 10000, 34 + "maxLength": 100000, 35 + "description": "Comment text content" 36 + }, 37 + "facets": { 38 + "type": "array", 39 + "description": "Annotations for rich text (mentions, links, etc.)", 40 + "items": { 41 + "type": "ref", 42 + "ref": "social.coves.richtext.facet" 43 + } 44 + }, 45 + "embed": { 46 + "type": "union", 47 + "description": "Embedded media or quoted posts", 48 + "refs": [ 49 + "social.coves.embed.images", 50 + "social.coves.embed.post" 51 + ] 52 + }, 53 + "langs": { 54 + "type": "array", 55 + "description": "Languages used in the comment content (ISO 639-1)", 56 + "maxLength": 3, 57 + "items": { 58 + "type": "string", 59 + "format": "language" 60 + } 61 + }, 62 + "labels": { 63 + "type": "ref", 64 + "ref": "com.atproto.label.defs#selfLabels", 65 + "description": "Self-applied content labels" 66 + } 67 + } 68 + } 69 + }, 70 + "output": { 71 + "encoding": "application/json", 72 + "schema": { 73 + "type": "object", 74 + "required": ["uri", "cid"], 75 + "properties": { 76 + "uri": { 77 + "type": "string", 78 + "format": "at-uri", 79 + "description": "AT-URI of the created comment" 80 + }, 81 + "cid": { 82 + "type": "string", 83 + "format": "cid", 84 + "description": "CID of the created comment record" 85 + } 86 + } 87 + } 88 + }, 89 + "errors": [ 90 + { 91 + "name": "InvalidReply", 92 + "description": "The reply reference is invalid, malformed, or refers to non-existent content" 93 + }, 94 + { 95 + "name": "ContentTooLong", 96 + "description": "Comment content exceeds maximum length constraints" 97 + }, 98 + { 99 + "name": "ContentEmpty", 100 + "description": "Comment content is empty or contains only whitespace" 101 + }, 102 + { 103 + "name": "NotAuthorized", 104 + "description": "User is not authorized to create comments on this content" 105 + } 106 + ] 107 + } 108 + } 109 + }
+41
internal/atproto/lexicon/social/coves/community/comment/delete.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.community.comment.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a comment. Only the comment author can delete their own comments.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["uri"], 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "AT-URI of the comment to delete" 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "properties": {} 27 + } 28 + }, 29 + "errors": [ 30 + { 31 + "name": "CommentNotFound", 32 + "description": "Comment with the specified URI does not exist" 33 + }, 34 + { 35 + "name": "NotAuthorized", 36 + "description": "User is not authorized to delete this comment (not the author)" 37 + } 38 + ] 39 + } 40 + } 41 + }
+97
internal/atproto/lexicon/social/coves/community/comment/update.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.community.comment.update", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update an existing comment's content, facets, embed, languages, or labels. Threading references (reply.root and reply.parent) are immutable and cannot be changed.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["uri", "content"], 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "AT-URI of the comment to update" 18 + }, 19 + "content": { 20 + "type": "string", 21 + "maxGraphemes": 10000, 22 + "maxLength": 100000, 23 + "description": "Updated comment text content" 24 + }, 25 + "facets": { 26 + "type": "array", 27 + "description": "Updated annotations for rich text (mentions, links, etc.)", 28 + "items": { 29 + "type": "ref", 30 + "ref": "social.coves.richtext.facet" 31 + } 32 + }, 33 + "embed": { 34 + "type": "union", 35 + "description": "Updated embedded media or quoted posts", 36 + "refs": [ 37 + "social.coves.embed.images", 38 + "social.coves.embed.post" 39 + ] 40 + }, 41 + "langs": { 42 + "type": "array", 43 + "description": "Updated languages used in the comment content (ISO 639-1)", 44 + "maxLength": 3, 45 + "items": { 46 + "type": "string", 47 + "format": "language" 48 + } 49 + }, 50 + "labels": { 51 + "type": "ref", 52 + "ref": "com.atproto.label.defs#selfLabels", 53 + "description": "Updated self-applied content labels" 54 + } 55 + } 56 + } 57 + }, 58 + "output": { 59 + "encoding": "application/json", 60 + "schema": { 61 + "type": "object", 62 + "required": ["uri", "cid"], 63 + "properties": { 64 + "uri": { 65 + "type": "string", 66 + "format": "at-uri", 67 + "description": "AT-URI of the updated comment (unchanged from input)" 68 + }, 69 + "cid": { 70 + "type": "string", 71 + "format": "cid", 72 + "description": "New CID of the updated comment record" 73 + } 74 + } 75 + } 76 + }, 77 + "errors": [ 78 + { 79 + "name": "CommentNotFound", 80 + "description": "Comment with the specified URI does not exist" 81 + }, 82 + { 83 + "name": "ContentTooLong", 84 + "description": "Updated comment content exceeds maximum length constraints" 85 + }, 86 + { 87 + "name": "ContentEmpty", 88 + "description": "Updated comment content is empty or contains only whitespace" 89 + }, 90 + { 91 + "name": "NotAuthorized", 92 + "description": "User is not authorized to update this comment (not the author)" 93 + } 94 + ] 95 + } 96 + } 97 + }
+8 -8
internal/core/comments/comment.go
··· 35 35 // This is the data structure that gets stored in the user's repository 36 36 // Matches social.coves.community.comment lexicon 37 37 type CommentRecord struct { 38 - Embed map[string]interface{} `json:"embed,omitempty"` 39 - Labels *SelfLabels `json:"labels,omitempty"` 40 - Reply ReplyRef `json:"reply"` 41 - Type string `json:"$type"` 42 - Content string `json:"content"` 43 - CreatedAt string `json:"createdAt"` 44 - Facets []interface{} `json:"facets,omitempty"` 45 - Langs []string `json:"langs,omitempty"` 38 + Embed interface{} `json:"embed,omitempty"` 39 + Labels *SelfLabels `json:"labels,omitempty"` 40 + Reply ReplyRef `json:"reply"` 41 + Type string `json:"$type"` 42 + Content string `json:"content"` 43 + CreatedAt string `json:"createdAt"` 44 + Facets []interface{} `json:"facets,omitempty"` 45 + Langs []string `json:"langs,omitempty"` 46 46 } 47 47 48 48 // ReplyRef represents the threading structure from the comment lexicon
+376 -4
internal/core/comments/comment_service.go
··· 9 9 "errors" 10 10 "fmt" 11 11 "log" 12 + "log/slog" 12 13 "net/url" 13 14 "strings" 14 15 "time" 16 + 17 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + "github.com/rivo/uniseg" 20 + 21 + oauthclient "Coves/internal/atproto/oauth" 22 + "Coves/internal/atproto/pds" 15 23 ) 16 24 17 25 const ( ··· 19 27 // This balances UX (showing enough context) with performance (limiting query size) 20 28 // Can be made configurable via constructor if needed in the future 21 29 DefaultRepliesPerParent = 5 30 + 31 + // commentCollection is the AT Protocol collection for comment records 32 + commentCollection = "social.coves.community.comment" 33 + 34 + // maxCommentGraphemes is the maximum length for comment content in graphemes 35 + maxCommentGraphemes = 10000 22 36 ) 37 + 38 + // PDSClientFactory creates PDS clients from session data. 39 + // Used to allow injection of different auth mechanisms (OAuth for production, password for tests). 40 + type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) 23 41 24 42 // Service defines the business logic interface for comment operations 25 43 // Orchestrates repository calls and builds view models for API responses ··· 27 45 // GetComments retrieves and builds a threaded comment tree for a post 28 46 // Supports hot, top, and new sorting with configurable depth and pagination 29 47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 48 + 49 + // CreateComment creates a new comment or reply 50 + CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) 51 + 52 + // UpdateComment updates an existing comment's content 53 + UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) 54 + 55 + // DeleteComment soft-deletes a comment 56 + DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error 30 57 } 31 58 32 59 // GetCommentsRequest defines the parameters for fetching comments ··· 43 70 // commentService implements the Service interface 44 71 // Coordinates between repository layer and view model construction 45 72 type commentService struct { 46 - commentRepo Repository // Comment data access 47 - userRepo users.UserRepository // User lookup for author hydration 48 - postRepo posts.Repository // Post lookup for building post views 49 - communityRepo communities.Repository // Community lookup for community hydration 73 + commentRepo Repository // Comment data access 74 + userRepo users.UserRepository // User lookup for author hydration 75 + postRepo posts.Repository // Post lookup for building post views 76 + communityRepo communities.Repository // Community lookup for community hydration 77 + oauthClient *oauthclient.OAuthClient // OAuth client for PDS authentication 78 + oauthStore oauth.ClientAuthStore // OAuth session store 79 + logger *slog.Logger // Structured logger 80 + pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth. 50 81 } 51 82 52 83 // NewCommentService creates a new comment service instance ··· 56 87 userRepo users.UserRepository, 57 88 postRepo posts.Repository, 58 89 communityRepo communities.Repository, 90 + oauthClient *oauthclient.OAuthClient, 91 + oauthStore oauth.ClientAuthStore, 92 + logger *slog.Logger, 59 93 ) Service { 94 + if logger == nil { 95 + logger = slog.Default() 96 + } 60 97 return &commentService{ 61 98 commentRepo: commentRepo, 62 99 userRepo: userRepo, 63 100 postRepo: postRepo, 64 101 communityRepo: communityRepo, 102 + oauthClient: oauthClient, 103 + oauthStore: oauthStore, 104 + logger: logger, 105 + } 106 + } 107 + 108 + // NewCommentServiceWithPDSFactory creates a comment service with a custom PDS client factory. 109 + // This is primarily for testing with password-based authentication. 110 + func NewCommentServiceWithPDSFactory( 111 + commentRepo Repository, 112 + userRepo users.UserRepository, 113 + postRepo posts.Repository, 114 + communityRepo communities.Repository, 115 + logger *slog.Logger, 116 + factory PDSClientFactory, 117 + ) Service { 118 + if logger == nil { 119 + logger = slog.Default() 120 + } 121 + return &commentService{ 122 + commentRepo: commentRepo, 123 + userRepo: userRepo, 124 + postRepo: postRepo, 125 + communityRepo: communityRepo, 126 + logger: logger, 127 + pdsClientFactory: factory, 65 128 } 66 129 } 67 130 ··· 431 494 } 432 495 433 496 return record 497 + } 498 + 499 + // getPDSClient creates a PDS client from an OAuth session. 500 + // If a custom factory was provided (for testing), uses that. 501 + // Otherwise, uses DPoP authentication via indigo's APIClient for proper OAuth token handling. 502 + func (s *commentService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 503 + // Use custom factory if provided (e.g., for testing with password auth) 504 + if s.pdsClientFactory != nil { 505 + return s.pdsClientFactory(ctx, session) 506 + } 507 + 508 + // Production path: use OAuth with DPoP 509 + if s.oauthClient == nil || s.oauthClient.ClientApp == nil { 510 + return nil, fmt.Errorf("OAuth client not configured") 511 + } 512 + 513 + client, err := pds.NewFromOAuthSession(ctx, s.oauthClient.ClientApp, session) 514 + if err != nil { 515 + return nil, fmt.Errorf("failed to create PDS client: %w", err) 516 + } 517 + 518 + return client, nil 519 + } 520 + 521 + // CreateComment creates a new comment on a post or reply to another comment 522 + func (s *commentService) CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) { 523 + // Validate content not empty 524 + content := strings.TrimSpace(req.Content) 525 + if content == "" { 526 + return nil, ErrContentEmpty 527 + } 528 + 529 + // Validate content length (max 10000 graphemes) 530 + if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes { 531 + return nil, ErrContentTooLong 532 + } 533 + 534 + // Validate reply references 535 + if err := validateReplyRef(req.Reply); err != nil { 536 + return nil, err 537 + } 538 + 539 + // Create PDS client for this session 540 + pdsClient, err := s.getPDSClient(ctx, session) 541 + if err != nil { 542 + s.logger.Error("failed to create PDS client", 543 + "error", err, 544 + "commenter", session.AccountDID) 545 + return nil, fmt.Errorf("failed to create PDS client: %w", err) 546 + } 547 + 548 + // Generate TID for the record key 549 + tid := syntax.NewTIDNow(0) 550 + 551 + // Build comment record following the lexicon schema 552 + record := CommentRecord{ 553 + Type: commentCollection, 554 + Reply: req.Reply, 555 + Content: content, 556 + Facets: req.Facets, 557 + Embed: req.Embed, 558 + Langs: req.Langs, 559 + Labels: req.Labels, 560 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 561 + } 562 + 563 + // Create the comment record on the user's PDS 564 + uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, tid.String(), record) 565 + if err != nil { 566 + s.logger.Error("failed to create comment on PDS", 567 + "error", err, 568 + "commenter", session.AccountDID, 569 + "root", req.Reply.Root.URI, 570 + "parent", req.Reply.Parent.URI) 571 + if pds.IsAuthError(err) { 572 + return nil, ErrNotAuthorized 573 + } 574 + return nil, fmt.Errorf("failed to create comment: %w", err) 575 + } 576 + 577 + s.logger.Info("comment created", 578 + "commenter", session.AccountDID, 579 + "uri", uri, 580 + "cid", cid, 581 + "root", req.Reply.Root.URI, 582 + "parent", req.Reply.Parent.URI) 583 + 584 + return &CreateCommentResponse{ 585 + URI: uri, 586 + CID: cid, 587 + }, nil 588 + } 589 + 590 + // UpdateComment updates an existing comment's content 591 + func (s *commentService) UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) { 592 + // Validate URI format 593 + if req.URI == "" { 594 + return nil, ErrCommentNotFound 595 + } 596 + if !strings.HasPrefix(req.URI, "at://") { 597 + return nil, ErrCommentNotFound 598 + } 599 + 600 + // Extract DID and rkey from URI (at://did/collection/rkey) 601 + parts := strings.Split(req.URI, "/") 602 + if len(parts) < 5 || parts[3] != commentCollection { 603 + return nil, ErrCommentNotFound 604 + } 605 + did := parts[2] 606 + rkey := parts[4] 607 + 608 + // Verify ownership: URI must belong to the authenticated user 609 + if did != session.AccountDID.String() { 610 + return nil, ErrNotAuthorized 611 + } 612 + 613 + // Validate new content 614 + content := strings.TrimSpace(req.Content) 615 + if content == "" { 616 + return nil, ErrContentEmpty 617 + } 618 + 619 + // Validate content length (max 10000 graphemes) 620 + if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes { 621 + return nil, ErrContentTooLong 622 + } 623 + 624 + // Create PDS client for this session 625 + pdsClient, err := s.getPDSClient(ctx, session) 626 + if err != nil { 627 + s.logger.Error("failed to create PDS client", 628 + "error", err, 629 + "commenter", session.AccountDID) 630 + return nil, fmt.Errorf("failed to create PDS client: %w", err) 631 + } 632 + 633 + // Fetch existing record from PDS to get the reply refs (immutable) 634 + existingRecord, err := pdsClient.GetRecord(ctx, commentCollection, rkey) 635 + if err != nil { 636 + s.logger.Error("failed to fetch existing comment from PDS", 637 + "error", err, 638 + "uri", req.URI, 639 + "rkey", rkey) 640 + if pds.IsAuthError(err) { 641 + return nil, ErrNotAuthorized 642 + } 643 + if errors.Is(err, pds.ErrNotFound) { 644 + return nil, ErrCommentNotFound 645 + } 646 + return nil, fmt.Errorf("failed to fetch existing comment: %w", err) 647 + } 648 + 649 + // Extract reply refs from existing record (must be preserved) 650 + replyData, ok := existingRecord.Value["reply"].(map[string]interface{}) 651 + if !ok { 652 + s.logger.Error("invalid reply structure in existing comment", 653 + "uri", req.URI) 654 + return nil, fmt.Errorf("invalid existing comment structure") 655 + } 656 + 657 + // Parse reply refs 658 + var reply ReplyRef 659 + replyJSON, err := json.Marshal(replyData) 660 + if err != nil { 661 + return nil, fmt.Errorf("failed to marshal reply data: %w", err) 662 + } 663 + if err := json.Unmarshal(replyJSON, &reply); err != nil { 664 + return nil, fmt.Errorf("failed to unmarshal reply data: %w", err) 665 + } 666 + 667 + // Extract original createdAt timestamp (immutable) 668 + createdAt, _ := existingRecord.Value["createdAt"].(string) 669 + if createdAt == "" { 670 + createdAt = time.Now().UTC().Format(time.RFC3339) 671 + } 672 + 673 + // Build updated comment record 674 + updatedRecord := CommentRecord{ 675 + Type: commentCollection, 676 + Reply: reply, // Preserve original reply refs 677 + Content: content, 678 + Facets: req.Facets, 679 + Embed: req.Embed, 680 + Langs: req.Langs, 681 + Labels: req.Labels, 682 + CreatedAt: createdAt, // Preserve original timestamp 683 + } 684 + 685 + // Update the record on PDS (putRecord) 686 + // Note: This creates a new CID even though the URI stays the same 687 + // TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking. 688 + // PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected. 689 + // However, PutRecord is not yet implemented in internal/atproto/pds/client.go. 690 + uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord) 691 + if err != nil { 692 + s.logger.Error("failed to update comment on PDS", 693 + "error", err, 694 + "uri", req.URI, 695 + "rkey", rkey) 696 + if pds.IsAuthError(err) { 697 + return nil, ErrNotAuthorized 698 + } 699 + return nil, fmt.Errorf("failed to update comment: %w", err) 700 + } 701 + 702 + s.logger.Info("comment updated", 703 + "commenter", session.AccountDID, 704 + "uri", uri, 705 + "new_cid", cid, 706 + "old_cid", existingRecord.CID) 707 + 708 + return &UpdateCommentResponse{ 709 + URI: uri, 710 + CID: cid, 711 + }, nil 712 + } 713 + 714 + // DeleteComment soft-deletes a comment by removing it from the user's PDS 715 + func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error { 716 + // Validate URI format 717 + if req.URI == "" { 718 + return ErrCommentNotFound 719 + } 720 + if !strings.HasPrefix(req.URI, "at://") { 721 + return ErrCommentNotFound 722 + } 723 + 724 + // Extract DID and rkey from URI (at://did/collection/rkey) 725 + parts := strings.Split(req.URI, "/") 726 + if len(parts) < 5 || parts[3] != commentCollection { 727 + return ErrCommentNotFound 728 + } 729 + did := parts[2] 730 + rkey := parts[4] 731 + 732 + // Verify ownership: URI must belong to the authenticated user 733 + if did != session.AccountDID.String() { 734 + return ErrNotAuthorized 735 + } 736 + 737 + // Create PDS client for this session 738 + pdsClient, err := s.getPDSClient(ctx, session) 739 + if err != nil { 740 + s.logger.Error("failed to create PDS client", 741 + "error", err, 742 + "commenter", session.AccountDID) 743 + return fmt.Errorf("failed to create PDS client: %w", err) 744 + } 745 + 746 + // Verify comment exists on PDS before deleting 747 + _, err = pdsClient.GetRecord(ctx, commentCollection, rkey) 748 + if err != nil { 749 + s.logger.Error("failed to verify comment exists on PDS", 750 + "error", err, 751 + "uri", req.URI, 752 + "rkey", rkey) 753 + if pds.IsAuthError(err) { 754 + return ErrNotAuthorized 755 + } 756 + if errors.Is(err, pds.ErrNotFound) { 757 + return ErrCommentNotFound 758 + } 759 + return fmt.Errorf("failed to verify comment: %w", err) 760 + } 761 + 762 + // Delete the comment record from user's PDS 763 + if err := pdsClient.DeleteRecord(ctx, commentCollection, rkey); err != nil { 764 + s.logger.Error("failed to delete comment on PDS", 765 + "error", err, 766 + "uri", req.URI, 767 + "rkey", rkey) 768 + if pds.IsAuthError(err) { 769 + return ErrNotAuthorized 770 + } 771 + return fmt.Errorf("failed to delete comment: %w", err) 772 + } 773 + 774 + s.logger.Info("comment deleted", 775 + "commenter", session.AccountDID, 776 + "uri", req.URI) 777 + 778 + return nil 779 + } 780 + 781 + // validateReplyRef validates that reply references are well-formed 782 + func validateReplyRef(reply ReplyRef) error { 783 + // Validate root reference 784 + if reply.Root.URI == "" { 785 + return ErrInvalidReply 786 + } 787 + if !strings.HasPrefix(reply.Root.URI, "at://") { 788 + return ErrInvalidReply 789 + } 790 + if reply.Root.CID == "" { 791 + return ErrInvalidReply 792 + } 793 + 794 + // Validate parent reference 795 + if reply.Parent.URI == "" { 796 + return ErrInvalidReply 797 + } 798 + if !strings.HasPrefix(reply.Parent.URI, "at://") { 799 + return ErrInvalidReply 800 + } 801 + if reply.Parent.CID == "" { 802 + return ErrInvalidReply 803 + } 804 + 805 + return nil 434 806 } 435 807 436 808 // buildPostView converts a Post entity to a PostView for the comment response
+22 -22
internal/core/comments/comment_service_test.go
··· 444 444 return []*Comment{}, nil, nil 445 445 } 446 446 447 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 447 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 448 448 449 449 // Execute 450 450 req := &GetCommentsRequest{ ··· 472 472 postRepo := newMockPostRepo() 473 473 communityRepo := newMockCommunityRepo() 474 474 475 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 475 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 476 476 477 477 tests := []struct { 478 478 name string ··· 516 516 postRepo := newMockPostRepo() 517 517 communityRepo := newMockCommunityRepo() 518 518 519 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 519 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 520 520 521 521 // Execute 522 522 req := &GetCommentsRequest{ ··· 559 559 return []*Comment{}, nil, nil 560 560 } 561 561 562 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 562 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 563 563 564 564 // Execute 565 565 req := &GetCommentsRequest{ ··· 622 622 }, nil 623 623 } 624 624 625 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 625 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 626 626 627 627 // Execute 628 628 req := &GetCommentsRequest{ ··· 679 679 return []*Comment{}, nil, nil 680 680 } 681 681 682 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 682 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 683 683 684 684 // Execute without viewer 685 685 req := &GetCommentsRequest{ ··· 745 745 } 746 746 } 747 747 748 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 748 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 749 749 750 750 req := &GetCommentsRequest{ 751 751 PostURI: postURI, ··· 794 794 return nil, nil, errors.New("database error") 795 795 } 796 796 797 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 797 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 798 798 799 799 // Execute 800 800 req := &GetCommentsRequest{ ··· 821 821 postRepo := newMockPostRepo() 822 822 communityRepo := newMockCommunityRepo() 823 823 824 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 824 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 825 825 826 826 // Execute 827 827 result := service.buildThreadViews(context.Background(), []*Comment{}, 10, "hot", nil) ··· 848 848 // Create a normal comment 849 849 normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 850 850 851 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 851 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 852 852 853 853 // Execute 854 854 result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil) ··· 882 882 }, nil 883 883 } 884 884 885 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 885 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 886 886 887 887 // Execute with depth > 0 to load replies 888 888 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 1, "hot", nil) ··· 909 909 // Comment with replies but depth = 0 910 910 parentComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 5) 911 911 912 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 912 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 913 913 914 914 // Execute with depth = 0 (should not load replies) 915 915 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 0, "hot", nil) ··· 934 934 935 935 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 936 936 937 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 937 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 938 938 939 939 // Execute 940 940 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 966 966 // Top-level comment (parent = root) 967 967 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 968 968 969 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 969 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 970 970 971 971 // Execute 972 972 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 991 991 // Nested comment (parent != root) 992 992 comment := createTestComment(childCommentURI, "did:plc:commenter123", "commenter.test", postURI, parentCommentURI, 0) 993 993 994 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 994 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 995 995 996 996 // Execute 997 997 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 1025 1025 }, 1026 1026 } 1027 1027 1028 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1028 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1029 1029 1030 1030 // Execute 1031 1031 result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User)) ··· 1054 1054 // Empty vote states 1055 1055 voteStates := map[string]interface{}{} 1056 1056 1057 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1057 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1058 1058 1059 1059 // Execute 1060 1060 result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User)) ··· 1252 1252 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1253 1253 comment.ContentFacets = &facetsJSON 1254 1254 1255 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1255 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1256 1256 1257 1257 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1258 1258 ··· 1272 1272 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1273 1273 comment.Embed = &embedJSON 1274 1274 1275 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1275 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1276 1276 1277 1277 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1278 1278 ··· 1294 1294 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1295 1295 comment.ContentLabels = &labelsJSON 1296 1296 1297 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1297 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1298 1298 1299 1299 record := service.buildCommentRecord(comment) 1300 1300 ··· 1313 1313 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1314 1314 comment.ContentFacets = &malformedJSON 1315 1315 1316 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1316 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1317 1317 1318 1318 // Should not panic, should log warning and return view with nil facets 1319 1319 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 1375 1375 comment.Embed = tt.embedValue 1376 1376 comment.ContentLabels = tt.labelsValue 1377 1377 1378 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1378 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1379 1379 1380 1380 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1381 1381
+1208
internal/core/comments/comment_write_service_test.go
··· 1 + package comments 2 + 3 + import ( 4 + "Coves/internal/atproto/pds" 5 + "context" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + // ================================================================================ 17 + // Mock PDS Client for Write Operations Testing 18 + // ================================================================================ 19 + 20 + // mockPDSClient implements the pds.Client interface for testing 21 + // It stores records in memory and allows simulating various PDS error conditions 22 + type mockPDSClient struct { 23 + records map[string]map[string]interface{} // collection -> rkey -> record 24 + createError error // Error to return on CreateRecord 25 + getError error // Error to return on GetRecord 26 + deleteError error // Error to return on DeleteRecord 27 + did string // DID of the authenticated user 28 + hostURL string // PDS host URL 29 + } 30 + 31 + // newMockPDSClient creates a new mock PDS client for testing 32 + func newMockPDSClient(did string) *mockPDSClient { 33 + return &mockPDSClient{ 34 + records: make(map[string]map[string]interface{}), 35 + did: did, 36 + hostURL: "https://pds.test.local", 37 + } 38 + } 39 + 40 + func (m *mockPDSClient) DID() string { 41 + return m.did 42 + } 43 + 44 + func (m *mockPDSClient) HostURL() string { 45 + return m.hostURL 46 + } 47 + 48 + func (m *mockPDSClient) CreateRecord(ctx context.Context, collection, rkey string, record interface{}) (string, string, error) { 49 + if m.createError != nil { 50 + return "", "", m.createError 51 + } 52 + 53 + // Generate rkey if not provided 54 + if rkey == "" { 55 + rkey = fmt.Sprintf("test_%d", time.Now().UnixNano()) 56 + } 57 + 58 + // Store record 59 + if m.records[collection] == nil { 60 + m.records[collection] = make(map[string]interface{}) 61 + } 62 + m.records[collection][rkey] = record 63 + 64 + // Generate response 65 + uri := fmt.Sprintf("at://%s/%s/%s", m.did, collection, rkey) 66 + cid := fmt.Sprintf("bafytest%d", time.Now().UnixNano()) 67 + 68 + return uri, cid, nil 69 + } 70 + 71 + func (m *mockPDSClient) GetRecord(ctx context.Context, collection, rkey string) (*pds.RecordResponse, error) { 72 + if m.getError != nil { 73 + return nil, m.getError 74 + } 75 + 76 + if m.records[collection] == nil { 77 + return nil, pds.ErrNotFound 78 + } 79 + 80 + record, ok := m.records[collection][rkey] 81 + if !ok { 82 + return nil, pds.ErrNotFound 83 + } 84 + 85 + uri := fmt.Sprintf("at://%s/%s/%s", m.did, collection, rkey) 86 + cid := fmt.Sprintf("bafytest%d", time.Now().UnixNano()) 87 + 88 + return &pds.RecordResponse{ 89 + URI: uri, 90 + CID: cid, 91 + Value: record.(map[string]interface{}), 92 + }, nil 93 + } 94 + 95 + func (m *mockPDSClient) DeleteRecord(ctx context.Context, collection, rkey string) error { 96 + if m.deleteError != nil { 97 + return m.deleteError 98 + } 99 + 100 + if m.records[collection] == nil { 101 + return pds.ErrNotFound 102 + } 103 + 104 + if _, ok := m.records[collection][rkey]; !ok { 105 + return pds.ErrNotFound 106 + } 107 + 108 + delete(m.records[collection], rkey) 109 + return nil 110 + } 111 + 112 + func (m *mockPDSClient) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*pds.ListRecordsResponse, error) { 113 + return &pds.ListRecordsResponse{}, nil 114 + } 115 + 116 + // mockPDSClientFactory creates mock PDS clients for testing 117 + type mockPDSClientFactory struct { 118 + client *mockPDSClient 119 + err error 120 + } 121 + 122 + func (f *mockPDSClientFactory) create(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 123 + if f.err != nil { 124 + return nil, f.err 125 + } 126 + if f.client == nil { 127 + f.client = newMockPDSClient(session.AccountDID.String()) 128 + } 129 + return f.client, nil 130 + } 131 + 132 + // ================================================================================ 133 + // Helper Functions 134 + // ================================================================================ 135 + 136 + // createTestSession creates a test OAuth session for a given DID 137 + func createTestSession(did string) *oauth.ClientSessionData { 138 + parsedDID, _ := syntax.ParseDID(did) 139 + return &oauth.ClientSessionData{ 140 + AccountDID: parsedDID, 141 + SessionID: "test-session-123", 142 + AccessToken: "test-access-token", 143 + HostURL: "https://pds.test.local", 144 + } 145 + } 146 + 147 + // ================================================================================ 148 + // CreateComment Tests 149 + // ================================================================================ 150 + 151 + func TestCreateComment_Success(t *testing.T) { 152 + // Setup 153 + ctx := context.Background() 154 + mockClient := newMockPDSClient("did:plc:test123") 155 + factory := &mockPDSClientFactory{client: mockClient} 156 + 157 + commentRepo := newMockCommentRepo() 158 + userRepo := newMockUserRepo() 159 + postRepo := newMockPostRepo() 160 + communityRepo := newMockCommunityRepo() 161 + 162 + service := NewCommentServiceWithPDSFactory( 163 + commentRepo, 164 + userRepo, 165 + postRepo, 166 + communityRepo, 167 + nil, 168 + factory.create, 169 + ) 170 + 171 + // Create request 172 + req := CreateCommentRequest{ 173 + Reply: ReplyRef{ 174 + Root: StrongRef{ 175 + URI: "at://did:plc:author/social.coves.community.post/root123", 176 + CID: "bafyroot", 177 + }, 178 + Parent: StrongRef{ 179 + URI: "at://did:plc:author/social.coves.community.post/root123", 180 + CID: "bafyroot", 181 + }, 182 + }, 183 + Content: "This is a test comment", 184 + Langs: []string{"en"}, 185 + } 186 + 187 + session := createTestSession("did:plc:test123") 188 + 189 + // Execute 190 + resp, err := service.CreateComment(ctx, session, req) 191 + 192 + // Verify 193 + if err != nil { 194 + t.Fatalf("Expected no error, got: %v", err) 195 + } 196 + if resp == nil { 197 + t.Fatal("Expected response, got nil") 198 + } 199 + if resp.URI == "" { 200 + t.Error("Expected URI to be set") 201 + } 202 + if resp.CID == "" { 203 + t.Error("Expected CID to be set") 204 + } 205 + if !strings.HasPrefix(resp.URI, "at://did:plc:test123") { 206 + t.Errorf("Expected URI to start with user's DID, got: %s", resp.URI) 207 + } 208 + } 209 + 210 + func TestCreateComment_EmptyContent(t *testing.T) { 211 + // Setup 212 + ctx := context.Background() 213 + mockClient := newMockPDSClient("did:plc:test123") 214 + factory := &mockPDSClientFactory{client: mockClient} 215 + 216 + commentRepo := newMockCommentRepo() 217 + userRepo := newMockUserRepo() 218 + postRepo := newMockPostRepo() 219 + communityRepo := newMockCommunityRepo() 220 + 221 + service := NewCommentServiceWithPDSFactory( 222 + commentRepo, 223 + userRepo, 224 + postRepo, 225 + communityRepo, 226 + nil, 227 + factory.create, 228 + ) 229 + 230 + req := CreateCommentRequest{ 231 + Reply: ReplyRef{ 232 + Root: StrongRef{ 233 + URI: "at://did:plc:author/social.coves.community.post/root123", 234 + CID: "bafyroot", 235 + }, 236 + Parent: StrongRef{ 237 + URI: "at://did:plc:author/social.coves.community.post/root123", 238 + CID: "bafyroot", 239 + }, 240 + }, 241 + Content: "", 242 + } 243 + 244 + session := createTestSession("did:plc:test123") 245 + 246 + // Execute 247 + _, err := service.CreateComment(ctx, session, req) 248 + 249 + // Verify 250 + if !errors.Is(err, ErrContentEmpty) { 251 + t.Errorf("Expected ErrContentEmpty, got: %v", err) 252 + } 253 + } 254 + 255 + func TestCreateComment_ContentTooLong(t *testing.T) { 256 + // Setup 257 + ctx := context.Background() 258 + mockClient := newMockPDSClient("did:plc:test123") 259 + factory := &mockPDSClientFactory{client: mockClient} 260 + 261 + commentRepo := newMockCommentRepo() 262 + userRepo := newMockUserRepo() 263 + postRepo := newMockPostRepo() 264 + communityRepo := newMockCommunityRepo() 265 + 266 + service := NewCommentServiceWithPDSFactory( 267 + commentRepo, 268 + userRepo, 269 + postRepo, 270 + communityRepo, 271 + nil, 272 + factory.create, 273 + ) 274 + 275 + // Create content with >10000 graphemes (using Unicode characters) 276 + longContent := strings.Repeat("あ", 10001) // Japanese character = 1 grapheme 277 + 278 + req := CreateCommentRequest{ 279 + Reply: ReplyRef{ 280 + Root: StrongRef{ 281 + URI: "at://did:plc:author/social.coves.community.post/root123", 282 + CID: "bafyroot", 283 + }, 284 + Parent: StrongRef{ 285 + URI: "at://did:plc:author/social.coves.community.post/root123", 286 + CID: "bafyroot", 287 + }, 288 + }, 289 + Content: longContent, 290 + } 291 + 292 + session := createTestSession("did:plc:test123") 293 + 294 + // Execute 295 + _, err := service.CreateComment(ctx, session, req) 296 + 297 + // Verify 298 + if !errors.Is(err, ErrContentTooLong) { 299 + t.Errorf("Expected ErrContentTooLong, got: %v", err) 300 + } 301 + } 302 + 303 + func TestCreateComment_InvalidReplyRootURI(t *testing.T) { 304 + // Setup 305 + ctx := context.Background() 306 + mockClient := newMockPDSClient("did:plc:test123") 307 + factory := &mockPDSClientFactory{client: mockClient} 308 + 309 + commentRepo := newMockCommentRepo() 310 + userRepo := newMockUserRepo() 311 + postRepo := newMockPostRepo() 312 + communityRepo := newMockCommunityRepo() 313 + 314 + service := NewCommentServiceWithPDSFactory( 315 + commentRepo, 316 + userRepo, 317 + postRepo, 318 + communityRepo, 319 + nil, 320 + factory.create, 321 + ) 322 + 323 + req := CreateCommentRequest{ 324 + Reply: ReplyRef{ 325 + Root: StrongRef{ 326 + URI: "invalid-uri", // Invalid AT-URI 327 + CID: "bafyroot", 328 + }, 329 + Parent: StrongRef{ 330 + URI: "at://did:plc:author/social.coves.community.post/root123", 331 + CID: "bafyroot", 332 + }, 333 + }, 334 + Content: "Test comment", 335 + } 336 + 337 + session := createTestSession("did:plc:test123") 338 + 339 + // Execute 340 + _, err := service.CreateComment(ctx, session, req) 341 + 342 + // Verify 343 + if !errors.Is(err, ErrInvalidReply) { 344 + t.Errorf("Expected ErrInvalidReply, got: %v", err) 345 + } 346 + } 347 + 348 + func TestCreateComment_InvalidReplyRootCID(t *testing.T) { 349 + // Setup 350 + ctx := context.Background() 351 + mockClient := newMockPDSClient("did:plc:test123") 352 + factory := &mockPDSClientFactory{client: mockClient} 353 + 354 + commentRepo := newMockCommentRepo() 355 + userRepo := newMockUserRepo() 356 + postRepo := newMockPostRepo() 357 + communityRepo := newMockCommunityRepo() 358 + 359 + service := NewCommentServiceWithPDSFactory( 360 + commentRepo, 361 + userRepo, 362 + postRepo, 363 + communityRepo, 364 + nil, 365 + factory.create, 366 + ) 367 + 368 + req := CreateCommentRequest{ 369 + Reply: ReplyRef{ 370 + Root: StrongRef{ 371 + URI: "at://did:plc:author/social.coves.community.post/root123", 372 + CID: "", // Empty CID 373 + }, 374 + Parent: StrongRef{ 375 + URI: "at://did:plc:author/social.coves.community.post/root123", 376 + CID: "bafyroot", 377 + }, 378 + }, 379 + Content: "Test comment", 380 + } 381 + 382 + session := createTestSession("did:plc:test123") 383 + 384 + // Execute 385 + _, err := service.CreateComment(ctx, session, req) 386 + 387 + // Verify 388 + if !errors.Is(err, ErrInvalidReply) { 389 + t.Errorf("Expected ErrInvalidReply, got: %v", err) 390 + } 391 + } 392 + 393 + func TestCreateComment_InvalidReplyParentURI(t *testing.T) { 394 + // Setup 395 + ctx := context.Background() 396 + mockClient := newMockPDSClient("did:plc:test123") 397 + factory := &mockPDSClientFactory{client: mockClient} 398 + 399 + commentRepo := newMockCommentRepo() 400 + userRepo := newMockUserRepo() 401 + postRepo := newMockPostRepo() 402 + communityRepo := newMockCommunityRepo() 403 + 404 + service := NewCommentServiceWithPDSFactory( 405 + commentRepo, 406 + userRepo, 407 + postRepo, 408 + communityRepo, 409 + nil, 410 + factory.create, 411 + ) 412 + 413 + req := CreateCommentRequest{ 414 + Reply: ReplyRef{ 415 + Root: StrongRef{ 416 + URI: "at://did:plc:author/social.coves.community.post/root123", 417 + CID: "bafyroot", 418 + }, 419 + Parent: StrongRef{ 420 + URI: "invalid-uri", // Invalid AT-URI 421 + CID: "bafyparent", 422 + }, 423 + }, 424 + Content: "Test comment", 425 + } 426 + 427 + session := createTestSession("did:plc:test123") 428 + 429 + // Execute 430 + _, err := service.CreateComment(ctx, session, req) 431 + 432 + // Verify 433 + if !errors.Is(err, ErrInvalidReply) { 434 + t.Errorf("Expected ErrInvalidReply, got: %v", err) 435 + } 436 + } 437 + 438 + func TestCreateComment_InvalidReplyParentCID(t *testing.T) { 439 + // Setup 440 + ctx := context.Background() 441 + mockClient := newMockPDSClient("did:plc:test123") 442 + factory := &mockPDSClientFactory{client: mockClient} 443 + 444 + commentRepo := newMockCommentRepo() 445 + userRepo := newMockUserRepo() 446 + postRepo := newMockPostRepo() 447 + communityRepo := newMockCommunityRepo() 448 + 449 + service := NewCommentServiceWithPDSFactory( 450 + commentRepo, 451 + userRepo, 452 + postRepo, 453 + communityRepo, 454 + nil, 455 + factory.create, 456 + ) 457 + 458 + req := CreateCommentRequest{ 459 + Reply: ReplyRef{ 460 + Root: StrongRef{ 461 + URI: "at://did:plc:author/social.coves.community.post/root123", 462 + CID: "bafyroot", 463 + }, 464 + Parent: StrongRef{ 465 + URI: "at://did:plc:author/social.coves.community.post/root123", 466 + CID: "", // Empty CID 467 + }, 468 + }, 469 + Content: "Test comment", 470 + } 471 + 472 + session := createTestSession("did:plc:test123") 473 + 474 + // Execute 475 + _, err := service.CreateComment(ctx, session, req) 476 + 477 + // Verify 478 + if !errors.Is(err, ErrInvalidReply) { 479 + t.Errorf("Expected ErrInvalidReply, got: %v", err) 480 + } 481 + } 482 + 483 + func TestCreateComment_PDSError(t *testing.T) { 484 + // Setup 485 + ctx := context.Background() 486 + mockClient := newMockPDSClient("did:plc:test123") 487 + mockClient.createError = errors.New("PDS connection failed") 488 + factory := &mockPDSClientFactory{client: mockClient} 489 + 490 + commentRepo := newMockCommentRepo() 491 + userRepo := newMockUserRepo() 492 + postRepo := newMockPostRepo() 493 + communityRepo := newMockCommunityRepo() 494 + 495 + service := NewCommentServiceWithPDSFactory( 496 + commentRepo, 497 + userRepo, 498 + postRepo, 499 + communityRepo, 500 + nil, 501 + factory.create, 502 + ) 503 + 504 + req := CreateCommentRequest{ 505 + Reply: ReplyRef{ 506 + Root: StrongRef{ 507 + URI: "at://did:plc:author/social.coves.community.post/root123", 508 + CID: "bafyroot", 509 + }, 510 + Parent: StrongRef{ 511 + URI: "at://did:plc:author/social.coves.community.post/root123", 512 + CID: "bafyroot", 513 + }, 514 + }, 515 + Content: "Test comment", 516 + } 517 + 518 + session := createTestSession("did:plc:test123") 519 + 520 + // Execute 521 + _, err := service.CreateComment(ctx, session, req) 522 + 523 + // Verify 524 + if err == nil { 525 + t.Fatal("Expected error, got nil") 526 + } 527 + if !strings.Contains(err.Error(), "failed to create comment") { 528 + t.Errorf("Expected PDS error to be wrapped, got: %v", err) 529 + } 530 + } 531 + 532 + // ================================================================================ 533 + // UpdateComment Tests 534 + // ================================================================================ 535 + 536 + func TestUpdateComment_Success(t *testing.T) { 537 + // Setup 538 + ctx := context.Background() 539 + mockClient := newMockPDSClient("did:plc:test123") 540 + factory := &mockPDSClientFactory{client: mockClient} 541 + 542 + commentRepo := newMockCommentRepo() 543 + userRepo := newMockUserRepo() 544 + postRepo := newMockPostRepo() 545 + communityRepo := newMockCommunityRepo() 546 + 547 + service := NewCommentServiceWithPDSFactory( 548 + commentRepo, 549 + userRepo, 550 + postRepo, 551 + communityRepo, 552 + nil, 553 + factory.create, 554 + ) 555 + 556 + // Pre-create a comment in the mock PDS 557 + rkey := "testcomment123" 558 + existingRecord := map[string]interface{}{ 559 + "$type": "social.coves.community.comment", 560 + "content": "Original content", 561 + "reply": map[string]interface{}{ 562 + "root": map[string]interface{}{ 563 + "uri": "at://did:plc:author/social.coves.community.post/root123", 564 + "cid": "bafyroot", 565 + }, 566 + "parent": map[string]interface{}{ 567 + "uri": "at://did:plc:author/social.coves.community.post/root123", 568 + "cid": "bafyroot", 569 + }, 570 + }, 571 + "createdAt": time.Now().Format(time.RFC3339), 572 + } 573 + if mockClient.records["social.coves.community.comment"] == nil { 574 + mockClient.records["social.coves.community.comment"] = make(map[string]interface{}) 575 + } 576 + mockClient.records["social.coves.community.comment"][rkey] = existingRecord 577 + 578 + req := UpdateCommentRequest{ 579 + URI: fmt.Sprintf("at://did:plc:test123/social.coves.community.comment/%s", rkey), 580 + Content: "Updated content", 581 + } 582 + 583 + session := createTestSession("did:plc:test123") 584 + 585 + // Execute 586 + resp, err := service.UpdateComment(ctx, session, req) 587 + 588 + // Verify 589 + if err != nil { 590 + t.Fatalf("Expected no error, got: %v", err) 591 + } 592 + if resp == nil { 593 + t.Fatal("Expected response, got nil") 594 + } 595 + if resp.CID == "" { 596 + t.Error("Expected new CID to be set") 597 + } 598 + } 599 + 600 + func TestUpdateComment_EmptyURI(t *testing.T) { 601 + // Setup 602 + ctx := context.Background() 603 + mockClient := newMockPDSClient("did:plc:test123") 604 + factory := &mockPDSClientFactory{client: mockClient} 605 + 606 + commentRepo := newMockCommentRepo() 607 + userRepo := newMockUserRepo() 608 + postRepo := newMockPostRepo() 609 + communityRepo := newMockCommunityRepo() 610 + 611 + service := NewCommentServiceWithPDSFactory( 612 + commentRepo, 613 + userRepo, 614 + postRepo, 615 + communityRepo, 616 + nil, 617 + factory.create, 618 + ) 619 + 620 + req := UpdateCommentRequest{ 621 + URI: "", 622 + Content: "Updated content", 623 + } 624 + 625 + session := createTestSession("did:plc:test123") 626 + 627 + // Execute 628 + _, err := service.UpdateComment(ctx, session, req) 629 + 630 + // Verify 631 + if !errors.Is(err, ErrCommentNotFound) { 632 + t.Errorf("Expected ErrCommentNotFound, got: %v", err) 633 + } 634 + } 635 + 636 + func TestUpdateComment_InvalidURIFormat(t *testing.T) { 637 + // Setup 638 + ctx := context.Background() 639 + mockClient := newMockPDSClient("did:plc:test123") 640 + factory := &mockPDSClientFactory{client: mockClient} 641 + 642 + commentRepo := newMockCommentRepo() 643 + userRepo := newMockUserRepo() 644 + postRepo := newMockPostRepo() 645 + communityRepo := newMockCommunityRepo() 646 + 647 + service := NewCommentServiceWithPDSFactory( 648 + commentRepo, 649 + userRepo, 650 + postRepo, 651 + communityRepo, 652 + nil, 653 + factory.create, 654 + ) 655 + 656 + req := UpdateCommentRequest{ 657 + URI: "invalid-uri", 658 + Content: "Updated content", 659 + } 660 + 661 + session := createTestSession("did:plc:test123") 662 + 663 + // Execute 664 + _, err := service.UpdateComment(ctx, session, req) 665 + 666 + // Verify 667 + if !errors.Is(err, ErrCommentNotFound) { 668 + t.Errorf("Expected ErrCommentNotFound, got: %v", err) 669 + } 670 + } 671 + 672 + func TestUpdateComment_NotOwner(t *testing.T) { 673 + // Setup 674 + ctx := context.Background() 675 + mockClient := newMockPDSClient("did:plc:test123") 676 + factory := &mockPDSClientFactory{client: mockClient} 677 + 678 + commentRepo := newMockCommentRepo() 679 + userRepo := newMockUserRepo() 680 + postRepo := newMockPostRepo() 681 + communityRepo := newMockCommunityRepo() 682 + 683 + service := NewCommentServiceWithPDSFactory( 684 + commentRepo, 685 + userRepo, 686 + postRepo, 687 + communityRepo, 688 + nil, 689 + factory.create, 690 + ) 691 + 692 + // Try to update a comment owned by a different user 693 + req := UpdateCommentRequest{ 694 + URI: "at://did:plc:otheruser/social.coves.community.comment/test123", 695 + Content: "Updated content", 696 + } 697 + 698 + session := createTestSession("did:plc:test123") 699 + 700 + // Execute 701 + _, err := service.UpdateComment(ctx, session, req) 702 + 703 + // Verify 704 + if !errors.Is(err, ErrNotAuthorized) { 705 + t.Errorf("Expected ErrNotAuthorized, got: %v", err) 706 + } 707 + } 708 + 709 + func TestUpdateComment_EmptyContent(t *testing.T) { 710 + // Setup 711 + ctx := context.Background() 712 + mockClient := newMockPDSClient("did:plc:test123") 713 + factory := &mockPDSClientFactory{client: mockClient} 714 + 715 + commentRepo := newMockCommentRepo() 716 + userRepo := newMockUserRepo() 717 + postRepo := newMockPostRepo() 718 + communityRepo := newMockCommunityRepo() 719 + 720 + service := NewCommentServiceWithPDSFactory( 721 + commentRepo, 722 + userRepo, 723 + postRepo, 724 + communityRepo, 725 + nil, 726 + factory.create, 727 + ) 728 + 729 + req := UpdateCommentRequest{ 730 + URI: "at://did:plc:test123/social.coves.community.comment/test123", 731 + Content: "", 732 + } 733 + 734 + session := createTestSession("did:plc:test123") 735 + 736 + // Execute 737 + _, err := service.UpdateComment(ctx, session, req) 738 + 739 + // Verify 740 + if !errors.Is(err, ErrContentEmpty) { 741 + t.Errorf("Expected ErrContentEmpty, got: %v", err) 742 + } 743 + } 744 + 745 + func TestUpdateComment_ContentTooLong(t *testing.T) { 746 + // Setup 747 + ctx := context.Background() 748 + mockClient := newMockPDSClient("did:plc:test123") 749 + factory := &mockPDSClientFactory{client: mockClient} 750 + 751 + commentRepo := newMockCommentRepo() 752 + userRepo := newMockUserRepo() 753 + postRepo := newMockPostRepo() 754 + communityRepo := newMockCommunityRepo() 755 + 756 + service := NewCommentServiceWithPDSFactory( 757 + commentRepo, 758 + userRepo, 759 + postRepo, 760 + communityRepo, 761 + nil, 762 + factory.create, 763 + ) 764 + 765 + longContent := strings.Repeat("あ", 10001) 766 + 767 + req := UpdateCommentRequest{ 768 + URI: "at://did:plc:test123/social.coves.community.comment/test123", 769 + Content: longContent, 770 + } 771 + 772 + session := createTestSession("did:plc:test123") 773 + 774 + // Execute 775 + _, err := service.UpdateComment(ctx, session, req) 776 + 777 + // Verify 778 + if !errors.Is(err, ErrContentTooLong) { 779 + t.Errorf("Expected ErrContentTooLong, got: %v", err) 780 + } 781 + } 782 + 783 + func TestUpdateComment_CommentNotFound(t *testing.T) { 784 + // Setup 785 + ctx := context.Background() 786 + mockClient := newMockPDSClient("did:plc:test123") 787 + mockClient.getError = pds.ErrNotFound 788 + factory := &mockPDSClientFactory{client: mockClient} 789 + 790 + commentRepo := newMockCommentRepo() 791 + userRepo := newMockUserRepo() 792 + postRepo := newMockPostRepo() 793 + communityRepo := newMockCommunityRepo() 794 + 795 + service := NewCommentServiceWithPDSFactory( 796 + commentRepo, 797 + userRepo, 798 + postRepo, 799 + communityRepo, 800 + nil, 801 + factory.create, 802 + ) 803 + 804 + req := UpdateCommentRequest{ 805 + URI: "at://did:plc:test123/social.coves.community.comment/nonexistent", 806 + Content: "Updated content", 807 + } 808 + 809 + session := createTestSession("did:plc:test123") 810 + 811 + // Execute 812 + _, err := service.UpdateComment(ctx, session, req) 813 + 814 + // Verify 815 + if !errors.Is(err, ErrCommentNotFound) { 816 + t.Errorf("Expected ErrCommentNotFound, got: %v", err) 817 + } 818 + } 819 + 820 + func TestUpdateComment_PreservesReplyRefs(t *testing.T) { 821 + // Setup 822 + ctx := context.Background() 823 + mockClient := newMockPDSClient("did:plc:test123") 824 + factory := &mockPDSClientFactory{client: mockClient} 825 + 826 + commentRepo := newMockCommentRepo() 827 + userRepo := newMockUserRepo() 828 + postRepo := newMockPostRepo() 829 + communityRepo := newMockCommunityRepo() 830 + 831 + service := NewCommentServiceWithPDSFactory( 832 + commentRepo, 833 + userRepo, 834 + postRepo, 835 + communityRepo, 836 + nil, 837 + factory.create, 838 + ) 839 + 840 + // Pre-create a comment in the mock PDS 841 + rkey := "testcomment123" 842 + originalRootURI := "at://did:plc:author/social.coves.community.post/originalroot" 843 + originalRootCID := "bafyoriginalroot" 844 + existingRecord := map[string]interface{}{ 845 + "$type": "social.coves.community.comment", 846 + "content": "Original content", 847 + "reply": map[string]interface{}{ 848 + "root": map[string]interface{}{ 849 + "uri": originalRootURI, 850 + "cid": originalRootCID, 851 + }, 852 + "parent": map[string]interface{}{ 853 + "uri": originalRootURI, 854 + "cid": originalRootCID, 855 + }, 856 + }, 857 + "createdAt": time.Now().Format(time.RFC3339), 858 + } 859 + if mockClient.records["social.coves.community.comment"] == nil { 860 + mockClient.records["social.coves.community.comment"] = make(map[string]interface{}) 861 + } 862 + mockClient.records["social.coves.community.comment"][rkey] = existingRecord 863 + 864 + req := UpdateCommentRequest{ 865 + URI: fmt.Sprintf("at://did:plc:test123/social.coves.community.comment/%s", rkey), 866 + Content: "Updated content", 867 + } 868 + 869 + session := createTestSession("did:plc:test123") 870 + 871 + // Execute 872 + resp, err := service.UpdateComment(ctx, session, req) 873 + 874 + // Verify 875 + if err != nil { 876 + t.Fatalf("Expected no error, got: %v", err) 877 + } 878 + 879 + // Verify reply refs were preserved by checking the updated record 880 + updatedRecordInterface := mockClient.records["social.coves.community.comment"][rkey] 881 + updatedRecord, ok := updatedRecordInterface.(CommentRecord) 882 + if !ok { 883 + // Try as map (from pre-existing record) 884 + recordMap := updatedRecordInterface.(map[string]interface{}) 885 + reply := recordMap["reply"].(map[string]interface{}) 886 + root := reply["root"].(map[string]interface{}) 887 + 888 + if root["uri"] != originalRootURI { 889 + t.Errorf("Expected root URI to be preserved as %s, got %s", originalRootURI, root["uri"]) 890 + } 891 + if root["cid"] != originalRootCID { 892 + t.Errorf("Expected root CID to be preserved as %s, got %s", originalRootCID, root["cid"]) 893 + } 894 + 895 + // Verify content was updated 896 + if recordMap["content"] != "Updated content" { 897 + t.Errorf("Expected content to be updated to 'Updated content', got %s", recordMap["content"]) 898 + } 899 + } else { 900 + // CommentRecord struct 901 + if updatedRecord.Reply.Root.URI != originalRootURI { 902 + t.Errorf("Expected root URI to be preserved as %s, got %s", originalRootURI, updatedRecord.Reply.Root.URI) 903 + } 904 + if updatedRecord.Reply.Root.CID != originalRootCID { 905 + t.Errorf("Expected root CID to be preserved as %s, got %s", originalRootCID, updatedRecord.Reply.Root.CID) 906 + } 907 + 908 + // Verify content was updated 909 + if updatedRecord.Content != "Updated content" { 910 + t.Errorf("Expected content to be updated to 'Updated content', got %s", updatedRecord.Content) 911 + } 912 + } 913 + 914 + // Verify response 915 + if resp == nil { 916 + t.Fatal("Expected response, got nil") 917 + } 918 + } 919 + 920 + // ================================================================================ 921 + // DeleteComment Tests 922 + // ================================================================================ 923 + 924 + func TestDeleteComment_Success(t *testing.T) { 925 + // Setup 926 + ctx := context.Background() 927 + mockClient := newMockPDSClient("did:plc:test123") 928 + factory := &mockPDSClientFactory{client: mockClient} 929 + 930 + commentRepo := newMockCommentRepo() 931 + userRepo := newMockUserRepo() 932 + postRepo := newMockPostRepo() 933 + communityRepo := newMockCommunityRepo() 934 + 935 + service := NewCommentServiceWithPDSFactory( 936 + commentRepo, 937 + userRepo, 938 + postRepo, 939 + communityRepo, 940 + nil, 941 + factory.create, 942 + ) 943 + 944 + // Pre-create a comment in the mock PDS 945 + rkey := "testcomment123" 946 + existingRecord := map[string]interface{}{ 947 + "$type": "social.coves.community.comment", 948 + "content": "Test content", 949 + } 950 + if mockClient.records["social.coves.community.comment"] == nil { 951 + mockClient.records["social.coves.community.comment"] = make(map[string]interface{}) 952 + } 953 + mockClient.records["social.coves.community.comment"][rkey] = existingRecord 954 + 955 + req := DeleteCommentRequest{ 956 + URI: fmt.Sprintf("at://did:plc:test123/social.coves.community.comment/%s", rkey), 957 + } 958 + 959 + session := createTestSession("did:plc:test123") 960 + 961 + // Execute 962 + err := service.DeleteComment(ctx, session, req) 963 + 964 + // Verify 965 + if err != nil { 966 + t.Fatalf("Expected no error, got: %v", err) 967 + } 968 + 969 + // Verify comment was deleted from mock PDS 970 + _, exists := mockClient.records["social.coves.community.comment"][rkey] 971 + if exists { 972 + t.Error("Expected comment to be deleted from PDS") 973 + } 974 + } 975 + 976 + func TestDeleteComment_EmptyURI(t *testing.T) { 977 + // Setup 978 + ctx := context.Background() 979 + mockClient := newMockPDSClient("did:plc:test123") 980 + factory := &mockPDSClientFactory{client: mockClient} 981 + 982 + commentRepo := newMockCommentRepo() 983 + userRepo := newMockUserRepo() 984 + postRepo := newMockPostRepo() 985 + communityRepo := newMockCommunityRepo() 986 + 987 + service := NewCommentServiceWithPDSFactory( 988 + commentRepo, 989 + userRepo, 990 + postRepo, 991 + communityRepo, 992 + nil, 993 + factory.create, 994 + ) 995 + 996 + req := DeleteCommentRequest{ 997 + URI: "", 998 + } 999 + 1000 + session := createTestSession("did:plc:test123") 1001 + 1002 + // Execute 1003 + err := service.DeleteComment(ctx, session, req) 1004 + 1005 + // Verify 1006 + if !errors.Is(err, ErrCommentNotFound) { 1007 + t.Errorf("Expected ErrCommentNotFound, got: %v", err) 1008 + } 1009 + } 1010 + 1011 + func TestDeleteComment_InvalidURIFormat(t *testing.T) { 1012 + // Setup 1013 + ctx := context.Background() 1014 + mockClient := newMockPDSClient("did:plc:test123") 1015 + factory := &mockPDSClientFactory{client: mockClient} 1016 + 1017 + commentRepo := newMockCommentRepo() 1018 + userRepo := newMockUserRepo() 1019 + postRepo := newMockPostRepo() 1020 + communityRepo := newMockCommunityRepo() 1021 + 1022 + service := NewCommentServiceWithPDSFactory( 1023 + commentRepo, 1024 + userRepo, 1025 + postRepo, 1026 + communityRepo, 1027 + nil, 1028 + factory.create, 1029 + ) 1030 + 1031 + req := DeleteCommentRequest{ 1032 + URI: "invalid-uri", 1033 + } 1034 + 1035 + session := createTestSession("did:plc:test123") 1036 + 1037 + // Execute 1038 + err := service.DeleteComment(ctx, session, req) 1039 + 1040 + // Verify 1041 + if !errors.Is(err, ErrCommentNotFound) { 1042 + t.Errorf("Expected ErrCommentNotFound, got: %v", err) 1043 + } 1044 + } 1045 + 1046 + func TestDeleteComment_NotOwner(t *testing.T) { 1047 + // Setup 1048 + ctx := context.Background() 1049 + mockClient := newMockPDSClient("did:plc:test123") 1050 + factory := &mockPDSClientFactory{client: mockClient} 1051 + 1052 + commentRepo := newMockCommentRepo() 1053 + userRepo := newMockUserRepo() 1054 + postRepo := newMockPostRepo() 1055 + communityRepo := newMockCommunityRepo() 1056 + 1057 + service := NewCommentServiceWithPDSFactory( 1058 + commentRepo, 1059 + userRepo, 1060 + postRepo, 1061 + communityRepo, 1062 + nil, 1063 + factory.create, 1064 + ) 1065 + 1066 + // Try to delete a comment owned by a different user 1067 + req := DeleteCommentRequest{ 1068 + URI: "at://did:plc:otheruser/social.coves.community.comment/test123", 1069 + } 1070 + 1071 + session := createTestSession("did:plc:test123") 1072 + 1073 + // Execute 1074 + err := service.DeleteComment(ctx, session, req) 1075 + 1076 + // Verify 1077 + if !errors.Is(err, ErrNotAuthorized) { 1078 + t.Errorf("Expected ErrNotAuthorized, got: %v", err) 1079 + } 1080 + } 1081 + 1082 + func TestDeleteComment_CommentNotFound(t *testing.T) { 1083 + // Setup 1084 + ctx := context.Background() 1085 + mockClient := newMockPDSClient("did:plc:test123") 1086 + mockClient.getError = pds.ErrNotFound 1087 + factory := &mockPDSClientFactory{client: mockClient} 1088 + 1089 + commentRepo := newMockCommentRepo() 1090 + userRepo := newMockUserRepo() 1091 + postRepo := newMockPostRepo() 1092 + communityRepo := newMockCommunityRepo() 1093 + 1094 + service := NewCommentServiceWithPDSFactory( 1095 + commentRepo, 1096 + userRepo, 1097 + postRepo, 1098 + communityRepo, 1099 + nil, 1100 + factory.create, 1101 + ) 1102 + 1103 + req := DeleteCommentRequest{ 1104 + URI: "at://did:plc:test123/social.coves.community.comment/nonexistent", 1105 + } 1106 + 1107 + session := createTestSession("did:plc:test123") 1108 + 1109 + // Execute 1110 + err := service.DeleteComment(ctx, session, req) 1111 + 1112 + // Verify 1113 + if !errors.Is(err, ErrCommentNotFound) { 1114 + t.Errorf("Expected ErrCommentNotFound, got: %v", err) 1115 + } 1116 + } 1117 + 1118 + // TestCreateComment_GraphemeCounting tests that we count graphemes correctly, not runes 1119 + // Flag emoji 🇺🇸 is 2 runes but 1 grapheme 1120 + // Emoji with skin tone 👋🏽 is 2 runes but 1 grapheme 1121 + func TestCreateComment_GraphemeCounting(t *testing.T) { 1122 + ctx := context.Background() 1123 + mockClient := newMockPDSClient("did:plc:test123") 1124 + factory := &mockPDSClientFactory{client: mockClient} 1125 + 1126 + commentRepo := newMockCommentRepo() 1127 + userRepo := newMockUserRepo() 1128 + postRepo := newMockPostRepo() 1129 + communityRepo := newMockCommunityRepo() 1130 + 1131 + service := NewCommentServiceWithPDSFactory( 1132 + commentRepo, 1133 + userRepo, 1134 + postRepo, 1135 + communityRepo, 1136 + nil, 1137 + factory.create, 1138 + ) 1139 + 1140 + // Flag emoji 🇺🇸 is 2 runes but 1 grapheme 1141 + // 10000 flag emojis = 10000 graphemes but 20000 runes 1142 + // This should succeed because we count graphemes 1143 + content := strings.Repeat("🇺🇸", 10000) 1144 + 1145 + req := CreateCommentRequest{ 1146 + Reply: ReplyRef{ 1147 + Root: StrongRef{ 1148 + URI: "at://did:plc:author/social.coves.community.post/root123", 1149 + CID: "bafyroot", 1150 + }, 1151 + Parent: StrongRef{ 1152 + URI: "at://did:plc:author/social.coves.community.post/root123", 1153 + CID: "bafyroot", 1154 + }, 1155 + }, 1156 + Content: content, 1157 + } 1158 + 1159 + session := createTestSession("did:plc:test123") 1160 + 1161 + // Should succeed - 10000 graphemes is exactly at the limit 1162 + _, err := service.CreateComment(ctx, session, req) 1163 + if err != nil { 1164 + t.Errorf("Expected success for 10000 graphemes, got error: %v", err) 1165 + } 1166 + 1167 + // Now test that 10001 graphemes fails 1168 + contentTooLong := strings.Repeat("🇺🇸", 10001) 1169 + reqTooLong := CreateCommentRequest{ 1170 + Reply: ReplyRef{ 1171 + Root: StrongRef{ 1172 + URI: "at://did:plc:author/social.coves.community.post/root123", 1173 + CID: "bafyroot", 1174 + }, 1175 + Parent: StrongRef{ 1176 + URI: "at://did:plc:author/social.coves.community.post/root123", 1177 + CID: "bafyroot", 1178 + }, 1179 + }, 1180 + Content: contentTooLong, 1181 + } 1182 + 1183 + _, err = service.CreateComment(ctx, session, reqTooLong) 1184 + if !errors.Is(err, ErrContentTooLong) { 1185 + t.Errorf("Expected ErrContentTooLong for 10001 graphemes, got: %v", err) 1186 + } 1187 + 1188 + // Also test emoji with skin tone modifier: 👋🏽 is 2 runes but 1 grapheme 1189 + contentWithSkinTone := strings.Repeat("👋🏽", 10000) 1190 + reqWithSkinTone := CreateCommentRequest{ 1191 + Reply: ReplyRef{ 1192 + Root: StrongRef{ 1193 + URI: "at://did:plc:author/social.coves.community.post/root123", 1194 + CID: "bafyroot", 1195 + }, 1196 + Parent: StrongRef{ 1197 + URI: "at://did:plc:author/social.coves.community.post/root123", 1198 + CID: "bafyroot", 1199 + }, 1200 + }, 1201 + Content: contentWithSkinTone, 1202 + } 1203 + 1204 + _, err = service.CreateComment(ctx, session, reqWithSkinTone) 1205 + if err != nil { 1206 + t.Errorf("Expected success for 10000 graphemes with skin tone modifier, got error: %v", err) 1207 + } 1208 + }
+2 -2
internal/core/comments/errors.go
··· 15 15 // ErrRootNotFound indicates the root post doesn't exist 16 16 ErrRootNotFound = errors.New("root post not found") 17 17 18 - // ErrContentTooLong indicates comment content exceeds 3000 graphemes 19 - ErrContentTooLong = errors.New("comment content exceeds 3000 graphemes") 18 + // ErrContentTooLong indicates comment content exceeds 10000 graphemes 19 + ErrContentTooLong = errors.New("comment content exceeds 10000 graphemes") 20 20 21 21 // ErrContentEmpty indicates comment content is empty 22 22 ErrContentEmpty = errors.New("comment content is required")
+38
internal/core/comments/types.go
··· 1 + package comments 2 + 3 + // CreateCommentRequest contains parameters for creating a comment 4 + type CreateCommentRequest struct { 5 + Reply ReplyRef `json:"reply"` 6 + Content string `json:"content"` 7 + Facets []interface{} `json:"facets,omitempty"` 8 + Embed interface{} `json:"embed,omitempty"` 9 + Langs []string `json:"langs,omitempty"` 10 + Labels *SelfLabels `json:"labels,omitempty"` 11 + } 12 + 13 + // CreateCommentResponse contains the result of creating a comment 14 + type CreateCommentResponse struct { 15 + URI string `json:"uri"` 16 + CID string `json:"cid"` 17 + } 18 + 19 + // UpdateCommentRequest contains parameters for updating a comment 20 + type UpdateCommentRequest struct { 21 + URI string `json:"uri"` 22 + Content string `json:"content"` 23 + Facets []interface{} `json:"facets,omitempty"` 24 + Embed interface{} `json:"embed,omitempty"` 25 + Langs []string `json:"langs,omitempty"` 26 + Labels *SelfLabels `json:"labels,omitempty"` 27 + } 28 + 29 + // UpdateCommentResponse contains the result of updating a comment 30 + type UpdateCommentResponse struct { 31 + URI string `json:"uri"` 32 + CID string `json:"cid"` 33 + } 34 + 35 + // DeleteCommentRequest contains parameters for deleting a comment 36 + type DeleteCommentRequest struct { 37 + URI string `json:"uri"` 38 + }
+4 -2
tests/integration/comment_query_test.go
··· 785 785 postRepo := postgres.NewPostRepository(db) 786 786 userRepo := postgres.NewUserRepository(db) 787 787 communityRepo := postgres.NewCommunityRepository(db) 788 - return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 788 + // Use factory constructor with nil factory - these tests only use the read path (GetComments) 789 + return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 789 790 } 790 791 791 792 // Helper: createTestCommentWithScore creates a comment with specific vote counts ··· 871 872 postRepo := postgres.NewPostRepository(db) 872 873 userRepo := postgres.NewUserRepository(db) 873 874 communityRepo := postgres.NewCommunityRepository(db) 874 - service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 875 + // Use factory constructor with nil factory - these tests only use the read path (GetComments) 876 + service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 875 877 return &testCommentServiceAdapter{service: service} 876 878 } 877 879
+6 -3
tests/integration/comment_vote_test.go
··· 417 417 } 418 418 419 419 // Query comments with viewer authentication 420 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 420 + // Use factory constructor with nil factory - this test only uses the read path (GetComments) 421 + commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 421 422 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 422 423 PostURI: testPostURI, 423 424 Sort: "new", ··· 499 500 } 500 501 501 502 // Query with authentication but no vote 502 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 503 + // Use factory constructor with nil factory - this test only uses the read path (GetComments) 504 + commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 503 505 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 504 506 PostURI: testPostURI, 505 507 Sort: "new", ··· 542 544 543 545 t.Run("Unauthenticated request has no viewer state", func(t *testing.T) { 544 546 // Query without authentication 545 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 547 + // Use factory constructor with nil factory - this test only uses the read path (GetComments) 548 + commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 546 549 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 547 550 PostURI: testPostURI, 548 551 Sort: "new",
+808
tests/integration/comment_write_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/atproto/jetstream" 5 + "Coves/internal/atproto/pds" 6 + "Coves/internal/atproto/utils" 7 + "Coves/internal/core/comments" 8 + "Coves/internal/db/postgres" 9 + "context" 10 + "database/sql" 11 + "encoding/json" 12 + "errors" 13 + "fmt" 14 + "io" 15 + "net/http" 16 + "os" 17 + "testing" 18 + "time" 19 + 20 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 21 + "github.com/bluesky-social/indigo/atproto/syntax" 22 + _ "github.com/lib/pq" 23 + "github.com/pressly/goose/v3" 24 + ) 25 + 26 + // TestCommentWrite_CreateTopLevelComment tests creating a comment on a post via E2E flow 27 + func TestCommentWrite_CreateTopLevelComment(t *testing.T) { 28 + // Skip in short mode since this requires real PDS 29 + if testing.Short() { 30 + t.Skip("Skipping E2E test in short mode") 31 + } 32 + 33 + // Setup test database 34 + dbURL := os.Getenv("TEST_DATABASE_URL") 35 + if dbURL == "" { 36 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 37 + } 38 + 39 + db, err := sql.Open("postgres", dbURL) 40 + if err != nil { 41 + t.Fatalf("Failed to connect to test database: %v", err) 42 + } 43 + defer func() { 44 + if closeErr := db.Close(); closeErr != nil { 45 + t.Logf("Failed to close database: %v", closeErr) 46 + } 47 + }() 48 + 49 + // Run migrations 50 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 51 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 52 + } 53 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 54 + t.Fatalf("Failed to run migrations: %v", migrateErr) 55 + } 56 + 57 + // Check if PDS is running 58 + pdsURL := getTestPDSURL() 59 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 60 + if err != nil { 61 + t.Skipf("PDS not running at %s: %v", pdsURL, err) 62 + } 63 + func() { 64 + if closeErr := healthResp.Body.Close(); closeErr != nil { 65 + t.Logf("Failed to close health response: %v", closeErr) 66 + } 67 + }() 68 + 69 + ctx := context.Background() 70 + 71 + // Setup repositories 72 + commentRepo := postgres.NewCommentRepository(db) 73 + postRepo := postgres.NewPostRepository(db) 74 + 75 + // Setup service with password-based PDS client factory for E2E testing 76 + // CommentPDSClientFactory creates a PDS client for comment operations 77 + commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 78 + if session.AccessToken == "" { 79 + return nil, fmt.Errorf("session has no access token") 80 + } 81 + if session.HostURL == "" { 82 + return nil, fmt.Errorf("session has no host URL") 83 + } 84 + 85 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 86 + } 87 + 88 + commentService := comments.NewCommentServiceWithPDSFactory( 89 + commentRepo, 90 + nil, // userRepo not needed for write ops 91 + postRepo, 92 + nil, // communityRepo not needed for write ops 93 + nil, // logger 94 + commentPDSFactory, 95 + ) 96 + 97 + // Create test user on PDS 98 + testUserHandle := fmt.Sprintf("commenter-%d.local.coves.dev", time.Now().Unix()) 99 + testUserEmail := fmt.Sprintf("commenter-%d@test.local", time.Now().Unix()) 100 + testUserPassword := "test-password-123" 101 + 102 + t.Logf("Creating test user on PDS: %s", testUserHandle) 103 + pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) 104 + if err != nil { 105 + t.Fatalf("Failed to create test user on PDS: %v", err) 106 + } 107 + t.Logf("Test user created: DID=%s", userDID) 108 + 109 + // Index user in AppView 110 + testUser := createTestUser(t, db, testUserHandle, userDID) 111 + 112 + // Create test community and post to comment on 113 + testCommunityDID, err := createFeedTestCommunity(db, ctx, "test-community", "owner.test") 114 + if err != nil { 115 + t.Fatalf("Failed to create test community: %v", err) 116 + } 117 + 118 + postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post", 0, time.Now()) 119 + postCID := "bafypost123" 120 + 121 + // Create mock OAuth session for service layer 122 + mockStore := NewMockOAuthStore() 123 + mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL) 124 + 125 + // ==================================================================================== 126 + // TEST: Create top-level comment on post 127 + // ==================================================================================== 128 + t.Logf("\n📝 Creating top-level comment via service...") 129 + 130 + commentReq := comments.CreateCommentRequest{ 131 + Reply: comments.ReplyRef{ 132 + Root: comments.StrongRef{ 133 + URI: postURI, 134 + CID: postCID, 135 + }, 136 + Parent: comments.StrongRef{ 137 + URI: postURI, 138 + CID: postCID, 139 + }, 140 + }, 141 + Content: "This is a test comment on the post", 142 + Langs: []string{"en"}, 143 + } 144 + 145 + // Get session from store 146 + parsedDID, _ := parseTestDID(userDID) 147 + session, err := mockStore.GetSession(ctx, parsedDID, "session-"+userDID) 148 + if err != nil { 149 + t.Fatalf("Failed to get session: %v", err) 150 + } 151 + 152 + commentResp, err := commentService.CreateComment(ctx, session, commentReq) 153 + if err != nil { 154 + t.Fatalf("Failed to create comment: %v", err) 155 + } 156 + 157 + t.Logf("✅ Comment created:") 158 + t.Logf(" URI: %s", commentResp.URI) 159 + t.Logf(" CID: %s", commentResp.CID) 160 + 161 + // Verify comment record was written to PDS 162 + t.Logf("\n🔍 Verifying comment record on PDS...") 163 + rkey := utils.ExtractRKeyFromURI(commentResp.URI) 164 + collection := "social.coves.community.comment" 165 + 166 + pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 167 + pdsURL, userDID, collection, rkey)) 168 + if pdsErr != nil { 169 + t.Fatalf("Failed to fetch comment record from PDS: %v", pdsErr) 170 + } 171 + defer func() { 172 + if closeErr := pdsResp.Body.Close(); closeErr != nil { 173 + t.Logf("Failed to close PDS response: %v", closeErr) 174 + } 175 + }() 176 + 177 + if pdsResp.StatusCode != http.StatusOK { 178 + body, _ := io.ReadAll(pdsResp.Body) 179 + t.Fatalf("Comment record not found on PDS: status %d, body: %s", pdsResp.StatusCode, string(body)) 180 + } 181 + 182 + var pdsRecord struct { 183 + Value map[string]interface{} `json:"value"` 184 + CID string `json:"cid"` 185 + } 186 + if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil { 187 + t.Fatalf("Failed to decode PDS record: %v", decodeErr) 188 + } 189 + 190 + t.Logf("✅ Comment record found on PDS:") 191 + t.Logf(" CID: %s", pdsRecord.CID) 192 + t.Logf(" Content: %v", pdsRecord.Value["content"]) 193 + 194 + // Verify content 195 + if pdsRecord.Value["content"] != "This is a test comment on the post" { 196 + t.Errorf("Expected content 'This is a test comment on the post', got %v", pdsRecord.Value["content"]) 197 + } 198 + 199 + // Simulate Jetstream consumer indexing the comment 200 + t.Logf("\n🔄 Simulating Jetstream consumer indexing comment...") 201 + commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db) 202 + 203 + commentEvent := jetstream.JetstreamEvent{ 204 + Did: userDID, 205 + TimeUS: time.Now().UnixMicro(), 206 + Kind: "commit", 207 + Commit: &jetstream.CommitEvent{ 208 + Rev: "test-comment-rev", 209 + Operation: "create", 210 + Collection: "social.coves.community.comment", 211 + RKey: rkey, 212 + CID: pdsRecord.CID, 213 + Record: map[string]interface{}{ 214 + "$type": "social.coves.community.comment", 215 + "reply": map[string]interface{}{ 216 + "root": map[string]interface{}{ 217 + "uri": postURI, 218 + "cid": postCID, 219 + }, 220 + "parent": map[string]interface{}{ 221 + "uri": postURI, 222 + "cid": postCID, 223 + }, 224 + }, 225 + "content": "This is a test comment on the post", 226 + "createdAt": time.Now().Format(time.RFC3339), 227 + }, 228 + }, 229 + } 230 + 231 + if handleErr := commentConsumer.HandleEvent(ctx, &commentEvent); handleErr != nil { 232 + t.Fatalf("Failed to handle comment event: %v", handleErr) 233 + } 234 + 235 + // Verify comment was indexed in AppView 236 + t.Logf("\n🔍 Verifying comment indexed in AppView...") 237 + indexedComment, err := commentRepo.GetByURI(ctx, commentResp.URI) 238 + if err != nil { 239 + t.Fatalf("Comment not indexed in AppView: %v", err) 240 + } 241 + 242 + t.Logf("✅ Comment indexed in AppView:") 243 + t.Logf(" CommenterDID: %s", indexedComment.CommenterDID) 244 + t.Logf(" Content: %s", indexedComment.Content) 245 + t.Logf(" RootURI: %s", indexedComment.RootURI) 246 + t.Logf(" ParentURI: %s", indexedComment.ParentURI) 247 + 248 + // Verify comment details 249 + if indexedComment.CommenterDID != userDID { 250 + t.Errorf("Expected commenter_did %s, got %s", userDID, indexedComment.CommenterDID) 251 + } 252 + if indexedComment.RootURI != postURI { 253 + t.Errorf("Expected root_uri %s, got %s", postURI, indexedComment.RootURI) 254 + } 255 + if indexedComment.ParentURI != postURI { 256 + t.Errorf("Expected parent_uri %s, got %s", postURI, indexedComment.ParentURI) 257 + } 258 + if indexedComment.Content != "This is a test comment on the post" { 259 + t.Errorf("Expected content 'This is a test comment on the post', got %s", indexedComment.Content) 260 + } 261 + 262 + // Verify post comment count updated 263 + t.Logf("\n🔍 Verifying post comment count updated...") 264 + updatedPost, err := postRepo.GetByURI(ctx, postURI) 265 + if err != nil { 266 + t.Fatalf("Failed to get updated post: %v", err) 267 + } 268 + 269 + if updatedPost.CommentCount != 1 { 270 + t.Errorf("Expected comment_count = 1, got %d", updatedPost.CommentCount) 271 + } 272 + 273 + t.Logf("✅ TRUE E2E COMMENT CREATE FLOW COMPLETE:") 274 + t.Logf(" Client → Service → PDS Write → Jetstream → Consumer → AppView ✓") 275 + t.Logf(" ✓ Comment written to PDS") 276 + t.Logf(" ✓ Comment indexed in AppView") 277 + t.Logf(" ✓ Post comment count updated") 278 + } 279 + 280 + // TestCommentWrite_CreateNestedReply tests creating a reply to another comment 281 + func TestCommentWrite_CreateNestedReply(t *testing.T) { 282 + if testing.Short() { 283 + t.Skip("Skipping E2E test in short mode") 284 + } 285 + 286 + db := setupTestDB(t) 287 + defer func() { _ = db.Close() }() 288 + 289 + ctx := context.Background() 290 + pdsURL := getTestPDSURL() 291 + 292 + // Setup repositories and service 293 + commentRepo := postgres.NewCommentRepository(db) 294 + postRepo := postgres.NewPostRepository(db) 295 + 296 + // CommentPDSClientFactory creates a PDS client for comment operations 297 + commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 298 + if session.AccessToken == "" { 299 + return nil, fmt.Errorf("session has no access token") 300 + } 301 + if session.HostURL == "" { 302 + return nil, fmt.Errorf("session has no host URL") 303 + } 304 + 305 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 306 + } 307 + 308 + commentService := comments.NewCommentServiceWithPDSFactory( 309 + commentRepo, 310 + nil, 311 + postRepo, 312 + nil, 313 + nil, 314 + commentPDSFactory, 315 + ) 316 + 317 + // Create test user 318 + testUserHandle := fmt.Sprintf("replier-%d.local.coves.dev", time.Now().Unix()) 319 + testUserEmail := fmt.Sprintf("replier-%d@test.local", time.Now().Unix()) 320 + testUserPassword := "test-password-123" 321 + 322 + pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) 323 + if err != nil { 324 + t.Skipf("PDS not available: %v", err) 325 + } 326 + 327 + testUser := createTestUser(t, db, testUserHandle, userDID) 328 + 329 + // Create test post and parent comment 330 + testCommunityDID, _ := createFeedTestCommunity(db, ctx, "reply-community", "owner.test") 331 + postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post", 0, time.Now()) 332 + postCID := "bafypost456" 333 + 334 + // Create parent comment directly in DB (simulating already-indexed comment) 335 + parentCommentURI := fmt.Sprintf("at://%s/social.coves.community.comment/parent123", userDID) 336 + parentCommentCID := "bafyparent123" 337 + _, err = db.ExecContext(ctx, ` 338 + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) 339 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) 340 + `, parentCommentURI, parentCommentCID, "parent123", userDID, postURI, postCID, postURI, postCID, "Parent comment") 341 + if err != nil { 342 + t.Fatalf("Failed to create parent comment: %v", err) 343 + } 344 + 345 + // Setup OAuth 346 + mockStore := NewMockOAuthStore() 347 + mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL) 348 + 349 + // Create nested reply 350 + t.Logf("\n📝 Creating nested reply...") 351 + replyReq := comments.CreateCommentRequest{ 352 + Reply: comments.ReplyRef{ 353 + Root: comments.StrongRef{ 354 + URI: postURI, 355 + CID: postCID, 356 + }, 357 + Parent: comments.StrongRef{ 358 + URI: parentCommentURI, 359 + CID: parentCommentCID, 360 + }, 361 + }, 362 + Content: "This is a reply to the parent comment", 363 + Langs: []string{"en"}, 364 + } 365 + 366 + parsedDID, _ := parseTestDID(userDID) 367 + session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID) 368 + 369 + replyResp, err := commentService.CreateComment(ctx, session, replyReq) 370 + if err != nil { 371 + t.Fatalf("Failed to create reply: %v", err) 372 + } 373 + 374 + t.Logf("✅ Reply created: %s", replyResp.URI) 375 + 376 + // Simulate Jetstream indexing 377 + rkey := utils.ExtractRKeyFromURI(replyResp.URI) 378 + commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db) 379 + 380 + replyEvent := jetstream.JetstreamEvent{ 381 + Did: userDID, 382 + TimeUS: time.Now().UnixMicro(), 383 + Kind: "commit", 384 + Commit: &jetstream.CommitEvent{ 385 + Rev: "test-reply-rev", 386 + Operation: "create", 387 + Collection: "social.coves.community.comment", 388 + RKey: rkey, 389 + CID: replyResp.CID, 390 + Record: map[string]interface{}{ 391 + "$type": "social.coves.community.comment", 392 + "reply": map[string]interface{}{ 393 + "root": map[string]interface{}{ 394 + "uri": postURI, 395 + "cid": postCID, 396 + }, 397 + "parent": map[string]interface{}{ 398 + "uri": parentCommentURI, 399 + "cid": parentCommentCID, 400 + }, 401 + }, 402 + "content": "This is a reply to the parent comment", 403 + "createdAt": time.Now().Format(time.RFC3339), 404 + }, 405 + }, 406 + } 407 + 408 + if handleErr := commentConsumer.HandleEvent(ctx, &replyEvent); handleErr != nil { 409 + t.Fatalf("Failed to handle reply event: %v", handleErr) 410 + } 411 + 412 + // Verify reply was indexed with correct parent 413 + indexedReply, err := commentRepo.GetByURI(ctx, replyResp.URI) 414 + if err != nil { 415 + t.Fatalf("Reply not indexed: %v", err) 416 + } 417 + 418 + if indexedReply.RootURI != postURI { 419 + t.Errorf("Expected root_uri %s, got %s", postURI, indexedReply.RootURI) 420 + } 421 + if indexedReply.ParentURI != parentCommentURI { 422 + t.Errorf("Expected parent_uri %s, got %s", parentCommentURI, indexedReply.ParentURI) 423 + } 424 + 425 + t.Logf("✅ NESTED REPLY FLOW COMPLETE:") 426 + t.Logf(" ✓ Reply created with correct parent reference") 427 + t.Logf(" ✓ Reply indexed in AppView") 428 + } 429 + 430 + // TestCommentWrite_UpdateComment tests updating an existing comment 431 + func TestCommentWrite_UpdateComment(t *testing.T) { 432 + if testing.Short() { 433 + t.Skip("Skipping E2E test in short mode") 434 + } 435 + 436 + db := setupTestDB(t) 437 + defer func() { _ = db.Close() }() 438 + 439 + ctx := context.Background() 440 + pdsURL := getTestPDSURL() 441 + 442 + // Setup repositories and service 443 + commentRepo := postgres.NewCommentRepository(db) 444 + 445 + // CommentPDSClientFactory creates a PDS client for comment operations 446 + commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 447 + if session.AccessToken == "" { 448 + return nil, fmt.Errorf("session has no access token") 449 + } 450 + if session.HostURL == "" { 451 + return nil, fmt.Errorf("session has no host URL") 452 + } 453 + 454 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 455 + } 456 + 457 + commentService := comments.NewCommentServiceWithPDSFactory( 458 + commentRepo, 459 + nil, 460 + nil, 461 + nil, 462 + nil, 463 + commentPDSFactory, 464 + ) 465 + 466 + // Create test user 467 + testUserHandle := fmt.Sprintf("updater-%d.local.coves.dev", time.Now().Unix()) 468 + testUserEmail := fmt.Sprintf("updater-%d@test.local", time.Now().Unix()) 469 + testUserPassword := "test-password-123" 470 + 471 + pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) 472 + if err != nil { 473 + t.Skipf("PDS not available: %v", err) 474 + } 475 + 476 + // Setup OAuth 477 + mockStore := NewMockOAuthStore() 478 + mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL) 479 + 480 + parsedDID, _ := parseTestDID(userDID) 481 + session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID) 482 + 483 + // First, create a comment to update 484 + t.Logf("\n📝 Creating initial comment...") 485 + createReq := comments.CreateCommentRequest{ 486 + Reply: comments.ReplyRef{ 487 + Root: comments.StrongRef{ 488 + URI: "at://did:plc:test/social.coves.community.post/test123", 489 + CID: "bafypost", 490 + }, 491 + Parent: comments.StrongRef{ 492 + URI: "at://did:plc:test/social.coves.community.post/test123", 493 + CID: "bafypost", 494 + }, 495 + }, 496 + Content: "Original content", 497 + Langs: []string{"en"}, 498 + } 499 + 500 + createResp, err := commentService.CreateComment(ctx, session, createReq) 501 + if err != nil { 502 + t.Fatalf("Failed to create comment: %v", err) 503 + } 504 + 505 + t.Logf("✅ Initial comment created: %s", createResp.URI) 506 + 507 + // Now update the comment 508 + t.Logf("\n📝 Updating comment...") 509 + updateReq := comments.UpdateCommentRequest{ 510 + URI: createResp.URI, 511 + Content: "Updated content - this has been edited", 512 + } 513 + 514 + updateResp, err := commentService.UpdateComment(ctx, session, updateReq) 515 + if err != nil { 516 + t.Fatalf("Failed to update comment: %v", err) 517 + } 518 + 519 + t.Logf("✅ Comment updated:") 520 + t.Logf(" URI: %s", updateResp.URI) 521 + t.Logf(" New CID: %s", updateResp.CID) 522 + 523 + // Verify the update on PDS 524 + rkey := utils.ExtractRKeyFromURI(updateResp.URI) 525 + pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s", 526 + pdsURL, userDID, rkey)) 527 + defer pdsResp.Body.Close() 528 + 529 + var pdsRecord struct { 530 + Value map[string]interface{} `json:"value"` 531 + CID string `json:"cid"` 532 + } 533 + json.NewDecoder(pdsResp.Body).Decode(&pdsRecord) 534 + 535 + if pdsRecord.Value["content"] != "Updated content - this has been edited" { 536 + t.Errorf("Expected updated content, got %v", pdsRecord.Value["content"]) 537 + } 538 + 539 + t.Logf("✅ UPDATE FLOW COMPLETE:") 540 + t.Logf(" ✓ Comment updated on PDS") 541 + t.Logf(" ✓ New CID generated") 542 + t.Logf(" ✓ Content verified") 543 + } 544 + 545 + // TestCommentWrite_DeleteComment tests deleting a comment 546 + func TestCommentWrite_DeleteComment(t *testing.T) { 547 + if testing.Short() { 548 + t.Skip("Skipping E2E test in short mode") 549 + } 550 + 551 + db := setupTestDB(t) 552 + defer func() { _ = db.Close() }() 553 + 554 + ctx := context.Background() 555 + pdsURL := getTestPDSURL() 556 + 557 + // Setup repositories and service 558 + commentRepo := postgres.NewCommentRepository(db) 559 + 560 + // CommentPDSClientFactory creates a PDS client for comment operations 561 + commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 562 + if session.AccessToken == "" { 563 + return nil, fmt.Errorf("session has no access token") 564 + } 565 + if session.HostURL == "" { 566 + return nil, fmt.Errorf("session has no host URL") 567 + } 568 + 569 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 570 + } 571 + 572 + commentService := comments.NewCommentServiceWithPDSFactory( 573 + commentRepo, 574 + nil, 575 + nil, 576 + nil, 577 + nil, 578 + commentPDSFactory, 579 + ) 580 + 581 + // Create test user 582 + testUserHandle := fmt.Sprintf("deleter-%d.local.coves.dev", time.Now().Unix()) 583 + testUserEmail := fmt.Sprintf("deleter-%d@test.local", time.Now().Unix()) 584 + testUserPassword := "test-password-123" 585 + 586 + pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword) 587 + if err != nil { 588 + t.Skipf("PDS not available: %v", err) 589 + } 590 + 591 + // Setup OAuth 592 + mockStore := NewMockOAuthStore() 593 + mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL) 594 + 595 + parsedDID, _ := parseTestDID(userDID) 596 + session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID) 597 + 598 + // First, create a comment to delete 599 + t.Logf("\n📝 Creating comment to delete...") 600 + createReq := comments.CreateCommentRequest{ 601 + Reply: comments.ReplyRef{ 602 + Root: comments.StrongRef{ 603 + URI: "at://did:plc:test/social.coves.community.post/test123", 604 + CID: "bafypost", 605 + }, 606 + Parent: comments.StrongRef{ 607 + URI: "at://did:plc:test/social.coves.community.post/test123", 608 + CID: "bafypost", 609 + }, 610 + }, 611 + Content: "This comment will be deleted", 612 + Langs: []string{"en"}, 613 + } 614 + 615 + createResp, err := commentService.CreateComment(ctx, session, createReq) 616 + if err != nil { 617 + t.Fatalf("Failed to create comment: %v", err) 618 + } 619 + 620 + t.Logf("✅ Comment created: %s", createResp.URI) 621 + 622 + // Now delete the comment 623 + t.Logf("\n📝 Deleting comment...") 624 + deleteReq := comments.DeleteCommentRequest{ 625 + URI: createResp.URI, 626 + } 627 + 628 + err = commentService.DeleteComment(ctx, session, deleteReq) 629 + if err != nil { 630 + t.Fatalf("Failed to delete comment: %v", err) 631 + } 632 + 633 + t.Logf("✅ Comment deleted") 634 + 635 + // Verify deletion on PDS 636 + rkey := utils.ExtractRKeyFromURI(createResp.URI) 637 + pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s", 638 + pdsURL, userDID, rkey)) 639 + defer pdsResp.Body.Close() 640 + 641 + if pdsResp.StatusCode != http.StatusBadRequest && pdsResp.StatusCode != http.StatusNotFound { 642 + t.Errorf("Expected 400 or 404 for deleted comment, got %d", pdsResp.StatusCode) 643 + } 644 + 645 + t.Logf("✅ DELETE FLOW COMPLETE:") 646 + t.Logf(" ✓ Comment deleted from PDS") 647 + t.Logf(" ✓ Record no longer accessible") 648 + } 649 + 650 + // TestCommentWrite_CannotUpdateOthersComment tests authorization for updates 651 + func TestCommentWrite_CannotUpdateOthersComment(t *testing.T) { 652 + if testing.Short() { 653 + t.Skip("Skipping E2E test in short mode") 654 + } 655 + 656 + db := setupTestDB(t) 657 + defer func() { _ = db.Close() }() 658 + 659 + ctx := context.Background() 660 + pdsURL := getTestPDSURL() 661 + 662 + // CommentPDSClientFactory creates a PDS client for comment operations 663 + commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 664 + if session.AccessToken == "" { 665 + return nil, fmt.Errorf("session has no access token") 666 + } 667 + if session.HostURL == "" { 668 + return nil, fmt.Errorf("session has no host URL") 669 + } 670 + 671 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 672 + } 673 + 674 + // Setup service 675 + commentService := comments.NewCommentServiceWithPDSFactory( 676 + nil, 677 + nil, 678 + nil, 679 + nil, 680 + nil, 681 + commentPDSFactory, 682 + ) 683 + 684 + // Create first user (comment owner) 685 + ownerHandle := fmt.Sprintf("owner-%d.local.coves.dev", time.Now().Unix()) 686 + ownerEmail := fmt.Sprintf("owner-%d@test.local", time.Now().Unix()) 687 + _, ownerDID, err := createPDSAccount(pdsURL, ownerHandle, ownerEmail, "password123") 688 + if err != nil { 689 + t.Skipf("PDS not available: %v", err) 690 + } 691 + 692 + // Create second user (attacker) 693 + attackerHandle := fmt.Sprintf("attacker-%d.local.coves.dev", time.Now().Unix()) 694 + attackerEmail := fmt.Sprintf("attacker-%d@test.local", time.Now().Unix()) 695 + attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123") 696 + if err != nil { 697 + t.Skipf("PDS not available: %v", err) 698 + } 699 + 700 + // Setup OAuth for attacker 701 + mockStore := NewMockOAuthStore() 702 + mockStore.AddSessionWithPDS(attackerDID, "session-"+attackerDID, attackerToken, pdsURL) 703 + 704 + parsedDID, _ := parseTestDID(attackerDID) 705 + session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+attackerDID) 706 + 707 + // Try to update comment owned by different user 708 + t.Logf("\n🚨 Attempting to update another user's comment...") 709 + updateReq := comments.UpdateCommentRequest{ 710 + URI: fmt.Sprintf("at://%s/social.coves.community.comment/test123", ownerDID), 711 + Content: "Malicious update attempt", 712 + } 713 + 714 + _, err = commentService.UpdateComment(ctx, session, updateReq) 715 + 716 + // Verify authorization error 717 + if err == nil { 718 + t.Fatal("Expected authorization error, got nil") 719 + } 720 + if !errors.Is(err, comments.ErrNotAuthorized) { 721 + t.Errorf("Expected ErrNotAuthorized, got: %v", err) 722 + } 723 + 724 + t.Logf("✅ AUTHORIZATION CHECK PASSED:") 725 + t.Logf(" ✓ User cannot update others' comments") 726 + } 727 + 728 + // TestCommentWrite_CannotDeleteOthersComment tests authorization for deletes 729 + func TestCommentWrite_CannotDeleteOthersComment(t *testing.T) { 730 + if testing.Short() { 731 + t.Skip("Skipping E2E test in short mode") 732 + } 733 + 734 + db := setupTestDB(t) 735 + defer func() { _ = db.Close() }() 736 + 737 + ctx := context.Background() 738 + pdsURL := getTestPDSURL() 739 + 740 + // CommentPDSClientFactory creates a PDS client for comment operations 741 + commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 742 + if session.AccessToken == "" { 743 + return nil, fmt.Errorf("session has no access token") 744 + } 745 + if session.HostURL == "" { 746 + return nil, fmt.Errorf("session has no host URL") 747 + } 748 + 749 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 750 + } 751 + 752 + // Setup service 753 + commentService := comments.NewCommentServiceWithPDSFactory( 754 + nil, 755 + nil, 756 + nil, 757 + nil, 758 + nil, 759 + commentPDSFactory, 760 + ) 761 + 762 + // Create first user (comment owner) 763 + ownerHandle := fmt.Sprintf("owner-%d.local.coves.dev", time.Now().Unix()) 764 + ownerEmail := fmt.Sprintf("owner-%d@test.local", time.Now().Unix()) 765 + _, ownerDID, err := createPDSAccount(pdsURL, ownerHandle, ownerEmail, "password123") 766 + if err != nil { 767 + t.Skipf("PDS not available: %v", err) 768 + } 769 + 770 + // Create second user (attacker) 771 + attackerHandle := fmt.Sprintf("attacker-%d.local.coves.dev", time.Now().Unix()) 772 + attackerEmail := fmt.Sprintf("attacker-%d@test.local", time.Now().Unix()) 773 + attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123") 774 + if err != nil { 775 + t.Skipf("PDS not available: %v", err) 776 + } 777 + 778 + // Setup OAuth for attacker 779 + mockStore := NewMockOAuthStore() 780 + mockStore.AddSessionWithPDS(attackerDID, "session-"+attackerDID, attackerToken, pdsURL) 781 + 782 + parsedDID, _ := parseTestDID(attackerDID) 783 + session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+attackerDID) 784 + 785 + // Try to delete comment owned by different user 786 + t.Logf("\n🚨 Attempting to delete another user's comment...") 787 + deleteReq := comments.DeleteCommentRequest{ 788 + URI: fmt.Sprintf("at://%s/social.coves.community.comment/test123", ownerDID), 789 + } 790 + 791 + err = commentService.DeleteComment(ctx, session, deleteReq) 792 + 793 + // Verify authorization error 794 + if err == nil { 795 + t.Fatal("Expected authorization error, got nil") 796 + } 797 + if !errors.Is(err, comments.ErrNotAuthorized) { 798 + t.Errorf("Expected ErrNotAuthorized, got: %v", err) 799 + } 800 + 801 + t.Logf("✅ AUTHORIZATION CHECK PASSED:") 802 + t.Logf(" ✓ User cannot delete others' comments") 803 + } 804 + 805 + // Helper function to parse DID for testing 806 + func parseTestDID(did string) (syntax.DID, error) { 807 + return syntax.ParseDID(did) 808 + }
+2 -1
tests/integration/concurrent_scenarios_test.go
··· 454 454 } 455 455 456 456 // Verify all comments are retrievable via service 457 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 457 + // Use factory constructor with nil factory - this test only uses the read path (GetComments) 458 + commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) 458 459 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 459 460 PostURI: postURI, 460 461 Sort: "new",