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 voteService := votes.NewService(voteRepo, oauthClient, oauthStore, voteCache, nil) 409 log.Println("✅ Vote service initialized (with OAuth authentication and vote cache)") 410 411 - // Initialize comment service (for query API) 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)") 415 416 // Initialize feed service 417 feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) ··· 528 529 routes.RegisterVoteRoutes(r, voteService, authMiddleware) 530 log.Println("Vote XRPC endpoints registered with OAuth authentication") 531 532 routes.RegisterCommunityFeedRoutes(r, feedService, voteService, authMiddleware) 533 log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)")
··· 408 voteService := votes.NewService(voteRepo, oauthClient, oauthStore, voteCache, nil) 409 log.Println("✅ Vote service initialized (with OAuth authentication and vote cache)") 410 411 + // Initialize comment service (for query and write APIs) 412 // Requires user and community repos for proper author/community hydration per lexicon 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)") 416 417 // Initialize feed service 418 feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) ··· 529 530 routes.RegisterVoteRoutes(r, voteService, authMiddleware) 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") 539 540 routes.RegisterCommunityFeedRoutes(r, feedService, voteService, authMiddleware) 541 log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)")
+124
docs/PRD_BACKLOG.md
··· 649 650 ## 🔵 P3: Technical Debt 651 652 ### Consolidate Environment Variable Validation 653 **Added:** 2025-10-11 | **Effort:** 2-3 hours 654
··· 649 650 ## 🔵 P3: Technical Debt 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 + 776 ### Consolidate Environment Variable Validation 777 **Added:** 2025-10-11 | **Effort:** 2-3 hours 778
+1 -1
go.mod
··· 72 github.com/prometheus/client_model v0.5.0 // indirect 73 github.com/prometheus/common v0.45.0 // indirect 74 github.com/prometheus/procfs v0.12.0 // indirect 75 - github.com/rivo/uniseg v0.1.0 // indirect 76 github.com/segmentio/asm v1.2.0 // indirect 77 github.com/sethvargo/go-retry v0.3.0 // indirect 78 github.com/spaolacci/murmur3 v1.1.0 // indirect
··· 72 github.com/prometheus/client_model v0.5.0 // indirect 73 github.com/prometheus/common v0.45.0 // indirect 74 github.com/prometheus/procfs v0.12.0 // indirect 75 + github.com/rivo/uniseg v0.4.7 // indirect 76 github.com/segmentio/asm v1.2.0 // indirect 77 github.com/sethvargo/go-retry v0.3.0 // indirect 78 github.com/spaolacci/murmur3 v1.1.0 // indirect
+2
go.sum
··· 159 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 160 github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= 161 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 162 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 163 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 164 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
··· 159 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 160 github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= 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= 164 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 165 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 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 import ( 4 "Coves/internal/core/comments" 5 "encoding/json" 6 "log" 7 "net/http" 8 ) ··· 30 func handleServiceError(w http.ResponseWriter, err error) { 31 switch { 32 case comments.IsNotFound(err): 33 - writeError(w, http.StatusNotFound, "NotFound", err.Error()) 34 35 case comments.IsValidationError(err): 36 - writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 37 38 default: 39 // Don't leak internal error details to clients
··· 3 import ( 4 "Coves/internal/core/comments" 5 "encoding/json" 6 + "errors" 7 "log" 8 "net/http" 9 ) ··· 31 func handleServiceError(w http.ResponseWriter, err error) { 32 switch { 33 case comments.IsNotFound(err): 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 + } 45 46 case comments.IsValidationError(err): 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. 69 70 default: 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 // This is the data structure that gets stored in the user's repository 36 // Matches social.coves.community.comment lexicon 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"` 46 } 47 48 // ReplyRef represents the threading structure from the comment lexicon
··· 35 // This is the data structure that gets stored in the user's repository 36 // Matches social.coves.community.comment lexicon 37 type CommentRecord struct { 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 } 47 48 // ReplyRef represents the threading structure from the comment lexicon
+376 -4
internal/core/comments/comment_service.go
··· 9 "errors" 10 "fmt" 11 "log" 12 "net/url" 13 "strings" 14 "time" 15 ) 16 17 const ( ··· 19 // This balances UX (showing enough context) with performance (limiting query size) 20 // Can be made configurable via constructor if needed in the future 21 DefaultRepliesPerParent = 5 22 ) 23 24 // Service defines the business logic interface for comment operations 25 // Orchestrates repository calls and builds view models for API responses ··· 27 // GetComments retrieves and builds a threaded comment tree for a post 28 // Supports hot, top, and new sorting with configurable depth and pagination 29 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 30 } 31 32 // GetCommentsRequest defines the parameters for fetching comments ··· 43 // commentService implements the Service interface 44 // Coordinates between repository layer and view model construction 45 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 50 } 51 52 // NewCommentService creates a new comment service instance ··· 56 userRepo users.UserRepository, 57 postRepo posts.Repository, 58 communityRepo communities.Repository, 59 ) Service { 60 return &commentService{ 61 commentRepo: commentRepo, 62 userRepo: userRepo, 63 postRepo: postRepo, 64 communityRepo: communityRepo, 65 } 66 } 67 ··· 431 } 432 433 return record 434 } 435 436 // buildPostView converts a Post entity to a PostView for the comment response
··· 9 "errors" 10 "fmt" 11 "log" 12 + "log/slog" 13 "net/url" 14 "strings" 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" 23 ) 24 25 const ( ··· 27 // This balances UX (showing enough context) with performance (limiting query size) 28 // Can be made configurable via constructor if needed in the future 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 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) 41 42 // Service defines the business logic interface for comment operations 43 // Orchestrates repository calls and builds view models for API responses ··· 45 // GetComments retrieves and builds a threaded comment tree for a post 46 // Supports hot, top, and new sorting with configurable depth and pagination 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 57 } 58 59 // GetCommentsRequest defines the parameters for fetching comments ··· 70 // commentService implements the Service interface 71 // Coordinates between repository layer and view model construction 72 type commentService struct { 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. 81 } 82 83 // NewCommentService creates a new comment service instance ··· 87 userRepo users.UserRepository, 88 postRepo posts.Repository, 89 communityRepo communities.Repository, 90 + oauthClient *oauthclient.OAuthClient, 91 + oauthStore oauth.ClientAuthStore, 92 + logger *slog.Logger, 93 ) Service { 94 + if logger == nil { 95 + logger = slog.Default() 96 + } 97 return &commentService{ 98 commentRepo: commentRepo, 99 userRepo: userRepo, 100 postRepo: postRepo, 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, 128 } 129 } 130 ··· 494 } 495 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 806 } 807 808 // buildPostView converts a Post entity to a PostView for the comment response
+22 -22
internal/core/comments/comment_service_test.go
··· 444 return []*Comment{}, nil, nil 445 } 446 447 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 448 449 // Execute 450 req := &GetCommentsRequest{ ··· 472 postRepo := newMockPostRepo() 473 communityRepo := newMockCommunityRepo() 474 475 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 476 477 tests := []struct { 478 name string ··· 516 postRepo := newMockPostRepo() 517 communityRepo := newMockCommunityRepo() 518 519 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 520 521 // Execute 522 req := &GetCommentsRequest{ ··· 559 return []*Comment{}, nil, nil 560 } 561 562 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 563 564 // Execute 565 req := &GetCommentsRequest{ ··· 622 }, nil 623 } 624 625 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 626 627 // Execute 628 req := &GetCommentsRequest{ ··· 679 return []*Comment{}, nil, nil 680 } 681 682 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 683 684 // Execute without viewer 685 req := &GetCommentsRequest{ ··· 745 } 746 } 747 748 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 749 750 req := &GetCommentsRequest{ 751 PostURI: postURI, ··· 794 return nil, nil, errors.New("database error") 795 } 796 797 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 798 799 // Execute 800 req := &GetCommentsRequest{ ··· 821 postRepo := newMockPostRepo() 822 communityRepo := newMockCommunityRepo() 823 824 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 825 826 // Execute 827 result := service.buildThreadViews(context.Background(), []*Comment{}, 10, "hot", nil) ··· 848 // Create a normal comment 849 normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 850 851 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 852 853 // Execute 854 result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil) ··· 882 }, nil 883 } 884 885 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 886 887 // Execute with depth > 0 to load replies 888 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 1, "hot", nil) ··· 909 // Comment with replies but depth = 0 910 parentComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 5) 911 912 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 913 914 // Execute with depth = 0 (should not load replies) 915 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 0, "hot", nil) ··· 934 935 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 936 937 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 938 939 // Execute 940 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 966 // Top-level comment (parent = root) 967 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 968 969 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 970 971 // Execute 972 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 991 // Nested comment (parent != root) 992 comment := createTestComment(childCommentURI, "did:plc:commenter123", "commenter.test", postURI, parentCommentURI, 0) 993 994 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 995 996 // Execute 997 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 1025 }, 1026 } 1027 1028 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1029 1030 // Execute 1031 result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User)) ··· 1054 // Empty vote states 1055 voteStates := map[string]interface{}{} 1056 1057 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1058 1059 // Execute 1060 result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User)) ··· 1252 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1253 comment.ContentFacets = &facetsJSON 1254 1255 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1256 1257 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1258 ··· 1272 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1273 comment.Embed = &embedJSON 1274 1275 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1276 1277 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1278 ··· 1294 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1295 comment.ContentLabels = &labelsJSON 1296 1297 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1298 1299 record := service.buildCommentRecord(comment) 1300 ··· 1313 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1314 comment.ContentFacets = &malformedJSON 1315 1316 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1317 1318 // Should not panic, should log warning and return view with nil facets 1319 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 1375 comment.Embed = tt.embedValue 1376 comment.ContentLabels = tt.labelsValue 1377 1378 - service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService) 1379 1380 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1381
··· 444 return []*Comment{}, nil, nil 445 } 446 447 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 448 449 // Execute 450 req := &GetCommentsRequest{ ··· 472 postRepo := newMockPostRepo() 473 communityRepo := newMockCommunityRepo() 474 475 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 476 477 tests := []struct { 478 name string ··· 516 postRepo := newMockPostRepo() 517 communityRepo := newMockCommunityRepo() 518 519 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 520 521 // Execute 522 req := &GetCommentsRequest{ ··· 559 return []*Comment{}, nil, nil 560 } 561 562 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 563 564 // Execute 565 req := &GetCommentsRequest{ ··· 622 }, nil 623 } 624 625 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 626 627 // Execute 628 req := &GetCommentsRequest{ ··· 679 return []*Comment{}, nil, nil 680 } 681 682 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 683 684 // Execute without viewer 685 req := &GetCommentsRequest{ ··· 745 } 746 } 747 748 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 749 750 req := &GetCommentsRequest{ 751 PostURI: postURI, ··· 794 return nil, nil, errors.New("database error") 795 } 796 797 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil) 798 799 // Execute 800 req := &GetCommentsRequest{ ··· 821 postRepo := newMockPostRepo() 822 communityRepo := newMockCommunityRepo() 823 824 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 825 826 // Execute 827 result := service.buildThreadViews(context.Background(), []*Comment{}, 10, "hot", nil) ··· 848 // Create a normal comment 849 normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 850 851 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 852 853 // Execute 854 result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil) ··· 882 }, nil 883 } 884 885 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 886 887 // Execute with depth > 0 to load replies 888 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 1, "hot", nil) ··· 909 // Comment with replies but depth = 0 910 parentComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 5) 911 912 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 913 914 // Execute with depth = 0 (should not load replies) 915 result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 0, "hot", nil) ··· 934 935 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 936 937 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 938 939 // Execute 940 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 966 // Top-level comment (parent = root) 967 comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 968 969 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 970 971 // Execute 972 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 991 // Nested comment (parent != root) 992 comment := createTestComment(childCommentURI, "did:plc:commenter123", "commenter.test", postURI, parentCommentURI, 0) 993 994 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 995 996 // Execute 997 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 1025 }, 1026 } 1027 1028 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1029 1030 // Execute 1031 result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User)) ··· 1054 // Empty vote states 1055 voteStates := map[string]interface{}{} 1056 1057 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1058 1059 // Execute 1060 result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User)) ··· 1252 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1253 comment.ContentFacets = &facetsJSON 1254 1255 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1256 1257 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1258 ··· 1272 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1273 comment.Embed = &embedJSON 1274 1275 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1276 1277 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 1278 ··· 1294 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1295 comment.ContentLabels = &labelsJSON 1296 1297 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1298 1299 record := service.buildCommentRecord(comment) 1300 ··· 1313 comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0) 1314 comment.ContentFacets = &malformedJSON 1315 1316 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1317 1318 // Should not panic, should log warning and return view with nil facets 1319 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) ··· 1375 comment.Embed = tt.embedValue 1376 comment.ContentLabels = tt.labelsValue 1377 1378 + service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService) 1379 1380 result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User)) 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 // ErrRootNotFound indicates the root post doesn't exist 16 ErrRootNotFound = errors.New("root post not found") 17 18 - // ErrContentTooLong indicates comment content exceeds 3000 graphemes 19 - ErrContentTooLong = errors.New("comment content exceeds 3000 graphemes") 20 21 // ErrContentEmpty indicates comment content is empty 22 ErrContentEmpty = errors.New("comment content is required")
··· 15 // ErrRootNotFound indicates the root post doesn't exist 16 ErrRootNotFound = errors.New("root post not found") 17 18 + // ErrContentTooLong indicates comment content exceeds 10000 graphemes 19 + ErrContentTooLong = errors.New("comment content exceeds 10000 graphemes") 20 21 // ErrContentEmpty indicates comment content is empty 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 postRepo := postgres.NewPostRepository(db) 786 userRepo := postgres.NewUserRepository(db) 787 communityRepo := postgres.NewCommunityRepository(db) 788 - return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 789 } 790 791 // Helper: createTestCommentWithScore creates a comment with specific vote counts ··· 871 postRepo := postgres.NewPostRepository(db) 872 userRepo := postgres.NewUserRepository(db) 873 communityRepo := postgres.NewCommunityRepository(db) 874 - service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 875 return &testCommentServiceAdapter{service: service} 876 } 877
··· 785 postRepo := postgres.NewPostRepository(db) 786 userRepo := postgres.NewUserRepository(db) 787 communityRepo := postgres.NewCommunityRepository(db) 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) 790 } 791 792 // Helper: createTestCommentWithScore creates a comment with specific vote counts ··· 872 postRepo := postgres.NewPostRepository(db) 873 userRepo := postgres.NewUserRepository(db) 874 communityRepo := postgres.NewCommunityRepository(db) 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) 877 return &testCommentServiceAdapter{service: service} 878 } 879
+6 -3
tests/integration/comment_vote_test.go
··· 417 } 418 419 // Query comments with viewer authentication 420 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 421 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 422 PostURI: testPostURI, 423 Sort: "new", ··· 499 } 500 501 // Query with authentication but no vote 502 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 503 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 504 PostURI: testPostURI, 505 Sort: "new", ··· 542 543 t.Run("Unauthenticated request has no viewer state", func(t *testing.T) { 544 // Query without authentication 545 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 546 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 547 PostURI: testPostURI, 548 Sort: "new",
··· 417 } 418 419 // Query comments with viewer authentication 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) 422 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 423 PostURI: testPostURI, 424 Sort: "new", ··· 500 } 501 502 // Query with authentication but no vote 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) 505 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 506 PostURI: testPostURI, 507 Sort: "new", ··· 544 545 t.Run("Unauthenticated request has no viewer state", func(t *testing.T) { 546 // Query without authentication 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) 549 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 550 PostURI: testPostURI, 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 } 455 456 // Verify all comments are retrievable via service 457 - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 458 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 459 PostURI: postURI, 460 Sort: "new",
··· 454 } 455 456 // Verify all comments are retrievable via service 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) 459 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 460 PostURI: postURI, 461 Sort: "new",