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

+11 -3
cmd/server/main.go
···
voteService := votes.NewService(voteRepo, oauthClient, oauthStore, voteCache, nil)
log.Println("✅ Vote service initialized (with OAuth authentication and vote cache)")
-
// Initialize comment service (for query API)
+
// Initialize comment service (for query and write APIs)
// Requires user and community repos for proper author/community hydration per lexicon
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
-
log.Println("✅ Comment service initialized (with author/community hydration)")
+
// OAuth client and store are needed for write operations (create, update, delete)
+
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo, oauthClient, oauthStore, nil)
+
log.Println("✅ Comment service initialized (with author/community hydration and write support)")
// Initialize feed service
feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret)
···
routes.RegisterVoteRoutes(r, voteService, authMiddleware)
log.Println("Vote XRPC endpoints registered with OAuth authentication")
+
+
// Register comment write routes (create, update, delete)
+
routes.RegisterCommentRoutes(r, commentService, authMiddleware)
+
log.Println("Comment write XRPC endpoints registered")
+
log.Println(" - POST /xrpc/social.coves.community.comment.create")
+
log.Println(" - POST /xrpc/social.coves.community.comment.update")
+
log.Println(" - POST /xrpc/social.coves.community.comment.delete")
routes.RegisterCommunityFeedRoutes(r, feedService, voteService, authMiddleware)
log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)")
+124
docs/PRD_BACKLOG.md
···
## 🔵 P3: Technical Debt
+
### Implement PutRecord in PDS Client
+
**Added:** 2025-12-04 | **Effort:** 2-3 hours | **Priority:** Technical Debt
+
**Status:** 📋 TODO
+
+
**Problem:**
+
The PDS client (`internal/atproto/pds/client.go`) only has `CreateRecord` but lacks `PutRecord`. This means updates use `CreateRecord` with an existing rkey, which:
+
1. Loses optimistic locking (no CID swap check)
+
2. Is semantically incorrect (creates vs updates)
+
3. Could cause race conditions on concurrent updates
+
+
**atProto Best Practice:**
+
- `com.atproto.repo.putRecord` should be used for updates
+
- Accepts `swapRecord` (expected CID) for optimistic locking
+
- Returns conflict error if CID doesn't match (concurrent modification detected)
+
+
**Solution:**
+
Add `PutRecord` method to the PDS client interface:
+
+
```go
+
// Client interface addition
+
type Client interface {
+
// ... existing methods ...
+
+
// PutRecord creates or updates a record with optional optimistic locking.
+
// If swapRecord is provided, the operation fails if the current CID doesn't match.
+
PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (uri string, cid string, err error)
+
}
+
+
// Implementation
+
func (c *client) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) {
+
payload := map[string]any{
+
"repo": c.did,
+
"collection": collection,
+
"rkey": rkey,
+
"record": record,
+
}
+
+
// Optional: optimistic locking via CID swap check
+
if swapRecord != "" {
+
payload["swapRecord"] = swapRecord
+
}
+
+
var result struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), payload, &result)
+
if err != nil {
+
return "", "", wrapAPIError(err, "putRecord")
+
}
+
+
return result.URI, result.CID, nil
+
}
+
```
+
+
**Error Handling:**
+
Add new error type for conflict detection:
+
```go
+
var ErrConflict = errors.New("record was modified by another operation")
+
```
+
+
Map HTTP 409 in `wrapAPIError`:
+
```go
+
case 409:
+
return fmt.Errorf("%s: %w: %s", operation, ErrConflict, apiErr.Message)
+
```
+
+
**Files to Modify:**
+
- `internal/atproto/pds/client.go` - Add `PutRecord` method and interface
+
- `internal/atproto/pds/errors.go` - Add `ErrConflict` error type
+
+
**Testing:**
+
- Unit test: Verify payload includes `swapRecord` when provided
+
- Integration test: Concurrent updates detect conflict
+
- Integration test: Update without `swapRecord` still works (backwards compatible)
+
+
**Blocked By:** Nothing
+
**Blocks:** "Migrate UpdateComment to use PutRecord"
+
+
---
+
+
### Migrate UpdateComment to Use PutRecord
+
**Added:** 2025-12-04 | **Effort:** 1 hour | **Priority:** Technical Debt
+
**Status:** 📋 TODO (Blocked)
+
**Blocked By:** "Implement PutRecord in PDS Client"
+
+
**Problem:**
+
`UpdateComment` in `internal/core/comments/comment_service.go` uses `CreateRecord` for updates instead of `PutRecord`. This lacks optimistic locking and is semantically incorrect.
+
+
**Current Code (lines 687-690):**
+
```go
+
// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.
+
// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.
+
// However, PutRecord is not yet implemented in internal/atproto/pds/client.go.
+
uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord)
+
```
+
+
**Solution:**
+
Once `PutRecord` is implemented in the PDS client, update to:
+
```go
+
// Use PutRecord with optimistic locking via existing CID
+
uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)
+
if err != nil {
+
if errors.Is(err, pds.ErrConflict) {
+
// Record was modified by another operation - return appropriate error
+
return nil, fmt.Errorf("comment was modified, please refresh and try again: %w", err)
+
}
+
// ... existing error handling
+
}
+
```
+
+
**Files to Modify:**
+
- `internal/core/comments/comment_service.go` - UpdateComment method
+
- `internal/core/comments/errors.go` - Add `ErrConcurrentModification` if needed
+
+
**Testing:**
+
- Unit test: Verify `PutRecord` is called with correct CID
+
- Integration test: Simulate concurrent update, verify conflict handling
+
+
**Impact:** Proper optimistic locking prevents lost updates from race conditions
+
+
---
+
### Consolidate Environment Variable Validation
**Added:** 2025-10-11 | **Effort:** 2-3 hours
+1 -1
go.mod
···
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
-
github.com/rivo/uniseg v0.1.0 // indirect
+
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
+2
go.sum
···
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+130
internal/api/handlers/comments/create_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// CreateCommentHandler handles comment creation requests
+
type CreateCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewCreateCommentHandler creates a new handler for creating comments
+
func NewCreateCommentHandler(service comments.Service) *CreateCommentHandler {
+
return &CreateCommentHandler{
+
service: service,
+
}
+
}
+
+
// CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create
+
type CreateCommentInput struct {
+
Reply struct {
+
Root struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"root"`
+
Parent struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"parent"`
+
} `json:"reply"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels interface{} `json:"labels,omitempty"`
+
}
+
+
// CreateCommentOutput matches the lexicon output schema
+
type CreateCommentOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleCreate handles comment creation requests
+
// POST /xrpc/social.coves.community.comment.create
+
//
+
// Request body: { "reply": { "root": {...}, "parent": {...} }, "content": "..." }
+
// Response: { "uri": "at://...", "cid": "..." }
+
func (h *CreateCommentHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into CreateCommentInput
+
var input CreateCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert labels interface{} to *comments.SelfLabels if provided
+
var labels *comments.SelfLabels
+
if input.Labels != nil {
+
labelsJSON, err := json.Marshal(input.Labels)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format")
+
return
+
}
+
var selfLabels comments.SelfLabels
+
if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure")
+
return
+
}
+
labels = &selfLabels
+
}
+
+
// 6. Convert input to CreateCommentRequest
+
req := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: input.Reply.Root.URI,
+
CID: input.Reply.Root.CID,
+
},
+
Parent: comments.StrongRef{
+
URI: input.Reply.Parent.URI,
+
CID: input.Reply.Parent.CID,
+
},
+
},
+
Content: input.Content,
+
Facets: input.Facets,
+
Embed: input.Embed,
+
Langs: input.Langs,
+
Labels: labels,
+
}
+
+
// 7. Call service to create comment
+
response, err := h.service.CreateComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 8. Return JSON response with URI and CID
+
output := CreateCommentOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+80
internal/api/handlers/comments/delete_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// DeleteCommentHandler handles comment deletion requests
+
type DeleteCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewDeleteCommentHandler creates a new handler for deleting comments
+
func NewDeleteCommentHandler(service comments.Service) *DeleteCommentHandler {
+
return &DeleteCommentHandler{
+
service: service,
+
}
+
}
+
+
// DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete
+
type DeleteCommentInput struct {
+
URI string `json:"uri"`
+
}
+
+
// DeleteCommentOutput is empty per lexicon specification
+
type DeleteCommentOutput struct{}
+
+
// HandleDelete handles comment deletion requests
+
// POST /xrpc/social.coves.community.comment.delete
+
//
+
// Request body: { "uri": "at://..." }
+
// Response: {}
+
func (h *DeleteCommentHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into DeleteCommentInput
+
var input DeleteCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert input to DeleteCommentRequest
+
req := comments.DeleteCommentRequest{
+
URI: input.URI,
+
}
+
+
// 6. Call service to delete comment
+
err := h.service.DeleteComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 7. Return empty JSON object per lexicon specification
+
output := DeleteCommentOutput{}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+34 -2
internal/api/handlers/comments/errors.go
···
import (
"Coves/internal/core/comments"
"encoding/json"
+
"errors"
"log"
"net/http"
)
···
func handleServiceError(w http.ResponseWriter, err error) {
switch {
case comments.IsNotFound(err):
-
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
// Map specific not found errors to appropriate messages
+
switch {
+
case errors.Is(err, comments.ErrCommentNotFound):
+
writeError(w, http.StatusNotFound, "CommentNotFound", "Comment not found")
+
case errors.Is(err, comments.ErrParentNotFound):
+
writeError(w, http.StatusNotFound, "ParentNotFound", "Parent post or comment not found")
+
case errors.Is(err, comments.ErrRootNotFound):
+
writeError(w, http.StatusNotFound, "RootNotFound", "Root post not found")
+
default:
+
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
}
case comments.IsValidationError(err):
-
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
// Map specific validation errors to appropriate messages
+
switch {
+
case errors.Is(err, comments.ErrInvalidReply):
+
writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed")
+
case errors.Is(err, comments.ErrContentTooLong):
+
writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes")
+
case errors.Is(err, comments.ErrContentEmpty):
+
writeError(w, http.StatusBadRequest, "ContentEmpty", "Comment content is required")
+
default:
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
}
+
+
case errors.Is(err, comments.ErrNotAuthorized):
+
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action")
+
+
case errors.Is(err, comments.ErrBanned):
+
writeError(w, http.StatusForbidden, "Banned", "User is banned from this community")
+
+
// NOTE: IsConflict case removed - the PDS handles duplicate detection via CreateRecord,
+
// so ErrCommentAlreadyExists is never returned from the service layer. If the PDS rejects
+
// a duplicate record, it returns an auth/validation error which is handled by other cases.
+
// Keeping this code would be dead code that never executes.
default:
// Don't leak internal error details to clients
+112
internal/api/handlers/comments/update_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// UpdateCommentHandler handles comment update requests
+
type UpdateCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewUpdateCommentHandler creates a new handler for updating comments
+
func NewUpdateCommentHandler(service comments.Service) *UpdateCommentHandler {
+
return &UpdateCommentHandler{
+
service: service,
+
}
+
}
+
+
// UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update
+
type UpdateCommentInput struct {
+
URI string `json:"uri"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels interface{} `json:"labels,omitempty"`
+
}
+
+
// UpdateCommentOutput matches the lexicon output schema
+
type UpdateCommentOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleUpdate handles comment update requests
+
// POST /xrpc/social.coves.community.comment.update
+
//
+
// Request body: { "uri": "at://...", "content": "..." }
+
// Response: { "uri": "at://...", "cid": "..." }
+
func (h *UpdateCommentHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into UpdateCommentInput
+
var input UpdateCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert labels interface{} to *comments.SelfLabels if provided
+
var labels *comments.SelfLabels
+
if input.Labels != nil {
+
labelsJSON, err := json.Marshal(input.Labels)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format")
+
return
+
}
+
var selfLabels comments.SelfLabels
+
if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure")
+
return
+
}
+
labels = &selfLabels
+
}
+
+
// 6. Convert input to UpdateCommentRequest
+
req := comments.UpdateCommentRequest{
+
URI: input.URI,
+
Content: input.Content,
+
Facets: input.Facets,
+
Embed: input.Embed,
+
Langs: input.Langs,
+
Labels: labels,
+
}
+
+
// 7. Call service to update comment
+
response, err := h.service.UpdateComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 8. Return JSON response with URI and CID
+
output := UpdateCommentOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+35
internal/api/routes/comment.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/comments"
+
"Coves/internal/api/middleware"
+
commentsCore "Coves/internal/core/comments"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterCommentRoutes registers comment-related XRPC endpoints on the router
+
// Implements social.coves.community.comment.* lexicon endpoints
+
// All write operations (create, update, delete) require authentication
+
func RegisterCommentRoutes(r chi.Router, service commentsCore.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+
// Initialize handlers
+
createHandler := comments.NewCreateCommentHandler(service)
+
updateHandler := comments.NewUpdateCommentHandler(service)
+
deleteHandler := comments.NewDeleteCommentHandler(service)
+
+
// Procedure endpoints (POST) - require authentication
+
// social.coves.community.comment.create - create a new comment on a post or another comment
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.create",
+
createHandler.HandleCreate)
+
+
// social.coves.community.comment.update - update an existing comment's content
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.update",
+
updateHandler.HandleUpdate)
+
+
// social.coves.community.comment.delete - soft delete a comment
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.delete",
+
deleteHandler.HandleDelete)
+
}
+109
internal/atproto/lexicon/social/coves/community/comment/create.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.create",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a comment on a post or another comment. Comments support nested threading, rich text, embeds, and self-labeling.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["reply", "content"],
+
"properties": {
+
"reply": {
+
"type": "object",
+
"description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).",
+
"required": ["root", "parent"],
+
"properties": {
+
"root": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the original post that started the thread"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the immediate parent (post or comment) being replied to"
+
}
+
}
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the created comment"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the created comment record"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InvalidReply",
+
"description": "The reply reference is invalid, malformed, or refers to non-existent content"
+
},
+
{
+
"name": "ContentTooLong",
+
"description": "Comment content exceeds maximum length constraints"
+
},
+
{
+
"name": "ContentEmpty",
+
"description": "Comment content is empty or contains only whitespace"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to create comments on this content"
+
}
+
]
+
}
+
}
+
}
+41
internal/atproto/lexicon/social/coves/community/comment/delete.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.delete",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Delete a comment. Only the comment author can delete their own comments.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment to delete"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"properties": {}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommentNotFound",
+
"description": "Comment with the specified URI does not exist"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to delete this comment (not the author)"
+
}
+
]
+
}
+
}
+
}
+97
internal/atproto/lexicon/social/coves/community/comment/update.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.update",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"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.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "content"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment to update"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Updated comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Updated annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Updated embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Updated languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Updated self-applied content labels"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the updated comment (unchanged from input)"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "New CID of the updated comment record"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommentNotFound",
+
"description": "Comment with the specified URI does not exist"
+
},
+
{
+
"name": "ContentTooLong",
+
"description": "Updated comment content exceeds maximum length constraints"
+
},
+
{
+
"name": "ContentEmpty",
+
"description": "Updated comment content is empty or contains only whitespace"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to update this comment (not the author)"
+
}
+
]
+
}
+
}
+
}
+8 -8
internal/core/comments/comment.go
···
// This is the data structure that gets stored in the user's repository
// Matches social.coves.community.comment lexicon
type CommentRecord struct {
-
Embed map[string]interface{} `json:"embed,omitempty"`
-
Labels *SelfLabels `json:"labels,omitempty"`
-
Reply ReplyRef `json:"reply"`
-
Type string `json:"$type"`
-
Content string `json:"content"`
-
CreatedAt string `json:"createdAt"`
-
Facets []interface{} `json:"facets,omitempty"`
-
Langs []string `json:"langs,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
Reply ReplyRef `json:"reply"`
+
Type string `json:"$type"`
+
Content string `json:"content"`
+
CreatedAt string `json:"createdAt"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Langs []string `json:"langs,omitempty"`
}
// ReplyRef represents the threading structure from the comment lexicon
+376 -4
internal/core/comments/comment_service.go
···
"errors"
"fmt"
"log"
+
"log/slog"
"net/url"
"strings"
"time"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/rivo/uniseg"
+
+
oauthclient "Coves/internal/atproto/oauth"
+
"Coves/internal/atproto/pds"
)
const (
···
// This balances UX (showing enough context) with performance (limiting query size)
// Can be made configurable via constructor if needed in the future
DefaultRepliesPerParent = 5
+
+
// commentCollection is the AT Protocol collection for comment records
+
commentCollection = "social.coves.community.comment"
+
+
// maxCommentGraphemes is the maximum length for comment content in graphemes
+
maxCommentGraphemes = 10000
)
+
+
// PDSClientFactory creates PDS clients from session data.
+
// Used to allow injection of different auth mechanisms (OAuth for production, password for tests).
+
type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error)
// Service defines the business logic interface for comment operations
// Orchestrates repository calls and builds view models for API responses
···
// GetComments retrieves and builds a threaded comment tree for a post
// Supports hot, top, and new sorting with configurable depth and pagination
GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
+
+
// CreateComment creates a new comment or reply
+
CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error)
+
+
// UpdateComment updates an existing comment's content
+
UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error)
+
+
// DeleteComment soft-deletes a comment
+
DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error
}
// GetCommentsRequest defines the parameters for fetching comments
···
// commentService implements the Service interface
// Coordinates between repository layer and view model construction
type commentService struct {
-
commentRepo Repository // Comment data access
-
userRepo users.UserRepository // User lookup for author hydration
-
postRepo posts.Repository // Post lookup for building post views
-
communityRepo communities.Repository // Community lookup for community hydration
+
commentRepo Repository // Comment data access
+
userRepo users.UserRepository // User lookup for author hydration
+
postRepo posts.Repository // Post lookup for building post views
+
communityRepo communities.Repository // Community lookup for community hydration
+
oauthClient *oauthclient.OAuthClient // OAuth client for PDS authentication
+
oauthStore oauth.ClientAuthStore // OAuth session store
+
logger *slog.Logger // Structured logger
+
pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth.
}
// NewCommentService creates a new comment service instance
···
userRepo users.UserRepository,
postRepo posts.Repository,
communityRepo communities.Repository,
+
oauthClient *oauthclient.OAuthClient,
+
oauthStore oauth.ClientAuthStore,
+
logger *slog.Logger,
) Service {
+
if logger == nil {
+
logger = slog.Default()
+
}
return &commentService{
commentRepo: commentRepo,
userRepo: userRepo,
postRepo: postRepo,
communityRepo: communityRepo,
+
oauthClient: oauthClient,
+
oauthStore: oauthStore,
+
logger: logger,
+
}
+
}
+
+
// NewCommentServiceWithPDSFactory creates a comment service with a custom PDS client factory.
+
// This is primarily for testing with password-based authentication.
+
func NewCommentServiceWithPDSFactory(
+
commentRepo Repository,
+
userRepo users.UserRepository,
+
postRepo posts.Repository,
+
communityRepo communities.Repository,
+
logger *slog.Logger,
+
factory PDSClientFactory,
+
) Service {
+
if logger == nil {
+
logger = slog.Default()
+
}
+
return &commentService{
+
commentRepo: commentRepo,
+
userRepo: userRepo,
+
postRepo: postRepo,
+
communityRepo: communityRepo,
+
logger: logger,
+
pdsClientFactory: factory,
}
}
···
}
return record
+
}
+
+
// getPDSClient creates a PDS client from an OAuth session.
+
// If a custom factory was provided (for testing), uses that.
+
// Otherwise, uses DPoP authentication via indigo's APIClient for proper OAuth token handling.
+
func (s *commentService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) {
+
// Use custom factory if provided (e.g., for testing with password auth)
+
if s.pdsClientFactory != nil {
+
return s.pdsClientFactory(ctx, session)
+
}
+
+
// Production path: use OAuth with DPoP
+
if s.oauthClient == nil || s.oauthClient.ClientApp == nil {
+
return nil, fmt.Errorf("OAuth client not configured")
+
}
+
+
client, err := pds.NewFromOAuthSession(ctx, s.oauthClient.ClientApp, session)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create PDS client: %w", err)
+
}
+
+
return client, nil
+
}
+
+
// CreateComment creates a new comment on a post or reply to another comment
+
func (s *commentService) CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) {
+
// Validate content not empty
+
content := strings.TrimSpace(req.Content)
+
if content == "" {
+
return nil, ErrContentEmpty
+
}
+
+
// Validate content length (max 10000 graphemes)
+
if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes {
+
return nil, ErrContentTooLong
+
}
+
+
// Validate reply references
+
if err := validateReplyRef(req.Reply); err != nil {
+
return nil, err
+
}
+
+
// Create PDS client for this session
+
pdsClient, err := s.getPDSClient(ctx, session)
+
if err != nil {
+
s.logger.Error("failed to create PDS client",
+
"error", err,
+
"commenter", session.AccountDID)
+
return nil, fmt.Errorf("failed to create PDS client: %w", err)
+
}
+
+
// Generate TID for the record key
+
tid := syntax.NewTIDNow(0)
+
+
// Build comment record following the lexicon schema
+
record := CommentRecord{
+
Type: commentCollection,
+
Reply: req.Reply,
+
Content: content,
+
Facets: req.Facets,
+
Embed: req.Embed,
+
Langs: req.Langs,
+
Labels: req.Labels,
+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
+
}
+
+
// Create the comment record on the user's PDS
+
uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, tid.String(), record)
+
if err != nil {
+
s.logger.Error("failed to create comment on PDS",
+
"error", err,
+
"commenter", session.AccountDID,
+
"root", req.Reply.Root.URI,
+
"parent", req.Reply.Parent.URI)
+
if pds.IsAuthError(err) {
+
return nil, ErrNotAuthorized
+
}
+
return nil, fmt.Errorf("failed to create comment: %w", err)
+
}
+
+
s.logger.Info("comment created",
+
"commenter", session.AccountDID,
+
"uri", uri,
+
"cid", cid,
+
"root", req.Reply.Root.URI,
+
"parent", req.Reply.Parent.URI)
+
+
return &CreateCommentResponse{
+
URI: uri,
+
CID: cid,
+
}, nil
+
}
+
+
// UpdateComment updates an existing comment's content
+
func (s *commentService) UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) {
+
// Validate URI format
+
if req.URI == "" {
+
return nil, ErrCommentNotFound
+
}
+
if !strings.HasPrefix(req.URI, "at://") {
+
return nil, ErrCommentNotFound
+
}
+
+
// Extract DID and rkey from URI (at://did/collection/rkey)
+
parts := strings.Split(req.URI, "/")
+
if len(parts) < 5 || parts[3] != commentCollection {
+
return nil, ErrCommentNotFound
+
}
+
did := parts[2]
+
rkey := parts[4]
+
+
// Verify ownership: URI must belong to the authenticated user
+
if did != session.AccountDID.String() {
+
return nil, ErrNotAuthorized
+
}
+
+
// Validate new content
+
content := strings.TrimSpace(req.Content)
+
if content == "" {
+
return nil, ErrContentEmpty
+
}
+
+
// Validate content length (max 10000 graphemes)
+
if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes {
+
return nil, ErrContentTooLong
+
}
+
+
// Create PDS client for this session
+
pdsClient, err := s.getPDSClient(ctx, session)
+
if err != nil {
+
s.logger.Error("failed to create PDS client",
+
"error", err,
+
"commenter", session.AccountDID)
+
return nil, fmt.Errorf("failed to create PDS client: %w", err)
+
}
+
+
// Fetch existing record from PDS to get the reply refs (immutable)
+
existingRecord, err := pdsClient.GetRecord(ctx, commentCollection, rkey)
+
if err != nil {
+
s.logger.Error("failed to fetch existing comment from PDS",
+
"error", err,
+
"uri", req.URI,
+
"rkey", rkey)
+
if pds.IsAuthError(err) {
+
return nil, ErrNotAuthorized
+
}
+
if errors.Is(err, pds.ErrNotFound) {
+
return nil, ErrCommentNotFound
+
}
+
return nil, fmt.Errorf("failed to fetch existing comment: %w", err)
+
}
+
+
// Extract reply refs from existing record (must be preserved)
+
replyData, ok := existingRecord.Value["reply"].(map[string]interface{})
+
if !ok {
+
s.logger.Error("invalid reply structure in existing comment",
+
"uri", req.URI)
+
return nil, fmt.Errorf("invalid existing comment structure")
+
}
+
+
// Parse reply refs
+
var reply ReplyRef
+
replyJSON, err := json.Marshal(replyData)
+
if err != nil {
+
return nil, fmt.Errorf("failed to marshal reply data: %w", err)
+
}
+
if err := json.Unmarshal(replyJSON, &reply); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal reply data: %w", err)
+
}
+
+
// Extract original createdAt timestamp (immutable)
+
createdAt, _ := existingRecord.Value["createdAt"].(string)
+
if createdAt == "" {
+
createdAt = time.Now().UTC().Format(time.RFC3339)
+
}
+
+
// Build updated comment record
+
updatedRecord := CommentRecord{
+
Type: commentCollection,
+
Reply: reply, // Preserve original reply refs
+
Content: content,
+
Facets: req.Facets,
+
Embed: req.Embed,
+
Langs: req.Langs,
+
Labels: req.Labels,
+
CreatedAt: createdAt, // Preserve original timestamp
+
}
+
+
// Update the record on PDS (putRecord)
+
// Note: This creates a new CID even though the URI stays the same
+
// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.
+
// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.
+
// However, PutRecord is not yet implemented in internal/atproto/pds/client.go.
+
uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord)
+
if err != nil {
+
s.logger.Error("failed to update comment on PDS",
+
"error", err,
+
"uri", req.URI,
+
"rkey", rkey)
+
if pds.IsAuthError(err) {
+
return nil, ErrNotAuthorized
+
}
+
return nil, fmt.Errorf("failed to update comment: %w", err)
+
}
+
+
s.logger.Info("comment updated",
+
"commenter", session.AccountDID,
+
"uri", uri,
+
"new_cid", cid,
+
"old_cid", existingRecord.CID)
+
+
return &UpdateCommentResponse{
+
URI: uri,
+
CID: cid,
+
}, nil
+
}
+
+
// DeleteComment soft-deletes a comment by removing it from the user's PDS
+
func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error {
+
// Validate URI format
+
if req.URI == "" {
+
return ErrCommentNotFound
+
}
+
if !strings.HasPrefix(req.URI, "at://") {
+
return ErrCommentNotFound
+
}
+
+
// Extract DID and rkey from URI (at://did/collection/rkey)
+
parts := strings.Split(req.URI, "/")
+
if len(parts) < 5 || parts[3] != commentCollection {
+
return ErrCommentNotFound
+
}
+
did := parts[2]
+
rkey := parts[4]
+
+
// Verify ownership: URI must belong to the authenticated user
+
if did != session.AccountDID.String() {
+
return ErrNotAuthorized
+
}
+
+
// Create PDS client for this session
+
pdsClient, err := s.getPDSClient(ctx, session)
+
if err != nil {
+
s.logger.Error("failed to create PDS client",
+
"error", err,
+
"commenter", session.AccountDID)
+
return fmt.Errorf("failed to create PDS client: %w", err)
+
}
+
+
// Verify comment exists on PDS before deleting
+
_, err = pdsClient.GetRecord(ctx, commentCollection, rkey)
+
if err != nil {
+
s.logger.Error("failed to verify comment exists on PDS",
+
"error", err,
+
"uri", req.URI,
+
"rkey", rkey)
+
if pds.IsAuthError(err) {
+
return ErrNotAuthorized
+
}
+
if errors.Is(err, pds.ErrNotFound) {
+
return ErrCommentNotFound
+
}
+
return fmt.Errorf("failed to verify comment: %w", err)
+
}
+
+
// Delete the comment record from user's PDS
+
if err := pdsClient.DeleteRecord(ctx, commentCollection, rkey); err != nil {
+
s.logger.Error("failed to delete comment on PDS",
+
"error", err,
+
"uri", req.URI,
+
"rkey", rkey)
+
if pds.IsAuthError(err) {
+
return ErrNotAuthorized
+
}
+
return fmt.Errorf("failed to delete comment: %w", err)
+
}
+
+
s.logger.Info("comment deleted",
+
"commenter", session.AccountDID,
+
"uri", req.URI)
+
+
return nil
+
}
+
+
// validateReplyRef validates that reply references are well-formed
+
func validateReplyRef(reply ReplyRef) error {
+
// Validate root reference
+
if reply.Root.URI == "" {
+
return ErrInvalidReply
+
}
+
if !strings.HasPrefix(reply.Root.URI, "at://") {
+
return ErrInvalidReply
+
}
+
if reply.Root.CID == "" {
+
return ErrInvalidReply
+
}
+
+
// Validate parent reference
+
if reply.Parent.URI == "" {
+
return ErrInvalidReply
+
}
+
if !strings.HasPrefix(reply.Parent.URI, "at://") {
+
return ErrInvalidReply
+
}
+
if reply.Parent.CID == "" {
+
return ErrInvalidReply
+
}
+
+
return nil
}
// buildPostView converts a Post entity to a PostView for the comment response
+22 -22
internal/core/comments/comment_service_test.go
···
return []*Comment{}, nil, nil
}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
// Execute
req := &GetCommentsRequest{
···
postRepo := newMockPostRepo()
communityRepo := newMockCommunityRepo()
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
tests := []struct {
name string
···
postRepo := newMockPostRepo()
communityRepo := newMockCommunityRepo()
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
// Execute
req := &GetCommentsRequest{
···
return []*Comment{}, nil, nil
}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
// Execute
req := &GetCommentsRequest{
···
}, nil
}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
// Execute
req := &GetCommentsRequest{
···
return []*Comment{}, nil, nil
}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
// Execute without viewer
req := &GetCommentsRequest{
···
}
}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
req := &GetCommentsRequest{
PostURI: postURI,
···
return nil, nil, errors.New("database error")
}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil)
// Execute
req := &GetCommentsRequest{
···
postRepo := newMockPostRepo()
communityRepo := newMockCommunityRepo()
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute
result := service.buildThreadViews(context.Background(), []*Comment{}, 10, "hot", nil)
···
// Create a normal comment
normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute
result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil)
···
}, nil
}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute with depth > 0 to load replies
result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 1, "hot", nil)
···
// Comment with replies but depth = 0
parentComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 5)
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute with depth = 0 (should not load replies)
result := service.buildThreadViews(context.Background(), []*Comment{parentComment}, 0, "hot", nil)
···
comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute
result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User))
···
// Top-level comment (parent = root)
comment := createTestComment(commentURI, "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute
result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User))
···
// Nested comment (parent != root)
comment := createTestComment(childCommentURI, "did:plc:commenter123", "commenter.test", postURI, parentCommentURI, 0)
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute
result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User))
···
},
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute
result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User))
···
// Empty vote states
voteStates := map[string]interface{}{}
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Execute
result := service.buildCommentView(comment, &viewerDID, voteStates, make(map[string]*users.User))
···
comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
comment.ContentFacets = &facetsJSON
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User))
···
comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
comment.Embed = &embedJSON
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User))
···
comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
comment.ContentLabels = &labelsJSON
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
record := service.buildCommentRecord(comment)
···
comment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)
comment.ContentFacets = &malformedJSON
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
// Should not panic, should log warning and return view with nil facets
result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User))
···
comment.Embed = tt.embedValue
comment.ContentLabels = tt.labelsValue
-
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo).(*commentService)
+
service := NewCommentService(commentRepo, userRepo, postRepo, communityRepo, nil, nil, nil).(*commentService)
result := service.buildCommentView(comment, nil, nil, make(map[string]*users.User))
+1208
internal/core/comments/comment_write_service_test.go
···
+
package comments
+
+
import (
+
"Coves/internal/atproto/pds"
+
"context"
+
"errors"
+
"fmt"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// ================================================================================
+
// Mock PDS Client for Write Operations Testing
+
// ================================================================================
+
+
// mockPDSClient implements the pds.Client interface for testing
+
// It stores records in memory and allows simulating various PDS error conditions
+
type mockPDSClient struct {
+
records map[string]map[string]interface{} // collection -> rkey -> record
+
createError error // Error to return on CreateRecord
+
getError error // Error to return on GetRecord
+
deleteError error // Error to return on DeleteRecord
+
did string // DID of the authenticated user
+
hostURL string // PDS host URL
+
}
+
+
// newMockPDSClient creates a new mock PDS client for testing
+
func newMockPDSClient(did string) *mockPDSClient {
+
return &mockPDSClient{
+
records: make(map[string]map[string]interface{}),
+
did: did,
+
hostURL: "https://pds.test.local",
+
}
+
}
+
+
func (m *mockPDSClient) DID() string {
+
return m.did
+
}
+
+
func (m *mockPDSClient) HostURL() string {
+
return m.hostURL
+
}
+
+
func (m *mockPDSClient) CreateRecord(ctx context.Context, collection, rkey string, record interface{}) (string, string, error) {
+
if m.createError != nil {
+
return "", "", m.createError
+
}
+
+
// Generate rkey if not provided
+
if rkey == "" {
+
rkey = fmt.Sprintf("test_%d", time.Now().UnixNano())
+
}
+
+
// Store record
+
if m.records[collection] == nil {
+
m.records[collection] = make(map[string]interface{})
+
}
+
m.records[collection][rkey] = record
+
+
// Generate response
+
uri := fmt.Sprintf("at://%s/%s/%s", m.did, collection, rkey)
+
cid := fmt.Sprintf("bafytest%d", time.Now().UnixNano())
+
+
return uri, cid, nil
+
}
+
+
func (m *mockPDSClient) GetRecord(ctx context.Context, collection, rkey string) (*pds.RecordResponse, error) {
+
if m.getError != nil {
+
return nil, m.getError
+
}
+
+
if m.records[collection] == nil {
+
return nil, pds.ErrNotFound
+
}
+
+
record, ok := m.records[collection][rkey]
+
if !ok {
+
return nil, pds.ErrNotFound
+
}
+
+
uri := fmt.Sprintf("at://%s/%s/%s", m.did, collection, rkey)
+
cid := fmt.Sprintf("bafytest%d", time.Now().UnixNano())
+
+
return &pds.RecordResponse{
+
URI: uri,
+
CID: cid,
+
Value: record.(map[string]interface{}),
+
}, nil
+
}
+
+
func (m *mockPDSClient) DeleteRecord(ctx context.Context, collection, rkey string) error {
+
if m.deleteError != nil {
+
return m.deleteError
+
}
+
+
if m.records[collection] == nil {
+
return pds.ErrNotFound
+
}
+
+
if _, ok := m.records[collection][rkey]; !ok {
+
return pds.ErrNotFound
+
}
+
+
delete(m.records[collection], rkey)
+
return nil
+
}
+
+
func (m *mockPDSClient) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*pds.ListRecordsResponse, error) {
+
return &pds.ListRecordsResponse{}, nil
+
}
+
+
// mockPDSClientFactory creates mock PDS clients for testing
+
type mockPDSClientFactory struct {
+
client *mockPDSClient
+
err error
+
}
+
+
func (f *mockPDSClientFactory) create(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) {
+
if f.err != nil {
+
return nil, f.err
+
}
+
if f.client == nil {
+
f.client = newMockPDSClient(session.AccountDID.String())
+
}
+
return f.client, nil
+
}
+
+
// ================================================================================
+
// Helper Functions
+
// ================================================================================
+
+
// createTestSession creates a test OAuth session for a given DID
+
func createTestSession(did string) *oauth.ClientSessionData {
+
parsedDID, _ := syntax.ParseDID(did)
+
return &oauth.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: "test-session-123",
+
AccessToken: "test-access-token",
+
HostURL: "https://pds.test.local",
+
}
+
}
+
+
// ================================================================================
+
// CreateComment Tests
+
// ================================================================================
+
+
func TestCreateComment_Success(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Create request
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: "This is a test comment",
+
Langs: []string{"en"},
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
resp, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if err != nil {
+
t.Fatalf("Expected no error, got: %v", err)
+
}
+
if resp == nil {
+
t.Fatal("Expected response, got nil")
+
}
+
if resp.URI == "" {
+
t.Error("Expected URI to be set")
+
}
+
if resp.CID == "" {
+
t.Error("Expected CID to be set")
+
}
+
if !strings.HasPrefix(resp.URI, "at://did:plc:test123") {
+
t.Errorf("Expected URI to start with user's DID, got: %s", resp.URI)
+
}
+
}
+
+
func TestCreateComment_EmptyContent(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: "",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrContentEmpty) {
+
t.Errorf("Expected ErrContentEmpty, got: %v", err)
+
}
+
}
+
+
func TestCreateComment_ContentTooLong(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Create content with >10000 graphemes (using Unicode characters)
+
longContent := strings.Repeat("あ", 10001) // Japanese character = 1 grapheme
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: longContent,
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrContentTooLong) {
+
t.Errorf("Expected ErrContentTooLong, got: %v", err)
+
}
+
}
+
+
func TestCreateComment_InvalidReplyRootURI(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "invalid-uri", // Invalid AT-URI
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: "Test comment",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrInvalidReply) {
+
t.Errorf("Expected ErrInvalidReply, got: %v", err)
+
}
+
}
+
+
func TestCreateComment_InvalidReplyRootCID(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "", // Empty CID
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: "Test comment",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrInvalidReply) {
+
t.Errorf("Expected ErrInvalidReply, got: %v", err)
+
}
+
}
+
+
func TestCreateComment_InvalidReplyParentURI(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "invalid-uri", // Invalid AT-URI
+
CID: "bafyparent",
+
},
+
},
+
Content: "Test comment",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrInvalidReply) {
+
t.Errorf("Expected ErrInvalidReply, got: %v", err)
+
}
+
}
+
+
func TestCreateComment_InvalidReplyParentCID(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "", // Empty CID
+
},
+
},
+
Content: "Test comment",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrInvalidReply) {
+
t.Errorf("Expected ErrInvalidReply, got: %v", err)
+
}
+
}
+
+
func TestCreateComment_PDSError(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
mockClient.createError = errors.New("PDS connection failed")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: "Test comment",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.CreateComment(ctx, session, req)
+
+
// Verify
+
if err == nil {
+
t.Fatal("Expected error, got nil")
+
}
+
if !strings.Contains(err.Error(), "failed to create comment") {
+
t.Errorf("Expected PDS error to be wrapped, got: %v", err)
+
}
+
}
+
+
// ================================================================================
+
// UpdateComment Tests
+
// ================================================================================
+
+
func TestUpdateComment_Success(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Pre-create a comment in the mock PDS
+
rkey := "testcomment123"
+
existingRecord := map[string]interface{}{
+
"$type": "social.coves.community.comment",
+
"content": "Original content",
+
"reply": map[string]interface{}{
+
"root": map[string]interface{}{
+
"uri": "at://did:plc:author/social.coves.community.post/root123",
+
"cid": "bafyroot",
+
},
+
"parent": map[string]interface{}{
+
"uri": "at://did:plc:author/social.coves.community.post/root123",
+
"cid": "bafyroot",
+
},
+
},
+
"createdAt": time.Now().Format(time.RFC3339),
+
}
+
if mockClient.records["social.coves.community.comment"] == nil {
+
mockClient.records["social.coves.community.comment"] = make(map[string]interface{})
+
}
+
mockClient.records["social.coves.community.comment"][rkey] = existingRecord
+
+
req := UpdateCommentRequest{
+
URI: fmt.Sprintf("at://did:plc:test123/social.coves.community.comment/%s", rkey),
+
Content: "Updated content",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
resp, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if err != nil {
+
t.Fatalf("Expected no error, got: %v", err)
+
}
+
if resp == nil {
+
t.Fatal("Expected response, got nil")
+
}
+
if resp.CID == "" {
+
t.Error("Expected new CID to be set")
+
}
+
}
+
+
func TestUpdateComment_EmptyURI(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := UpdateCommentRequest{
+
URI: "",
+
Content: "Updated content",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrCommentNotFound) {
+
t.Errorf("Expected ErrCommentNotFound, got: %v", err)
+
}
+
}
+
+
func TestUpdateComment_InvalidURIFormat(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := UpdateCommentRequest{
+
URI: "invalid-uri",
+
Content: "Updated content",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrCommentNotFound) {
+
t.Errorf("Expected ErrCommentNotFound, got: %v", err)
+
}
+
}
+
+
func TestUpdateComment_NotOwner(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Try to update a comment owned by a different user
+
req := UpdateCommentRequest{
+
URI: "at://did:plc:otheruser/social.coves.community.comment/test123",
+
Content: "Updated content",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrNotAuthorized) {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
}
+
}
+
+
func TestUpdateComment_EmptyContent(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := UpdateCommentRequest{
+
URI: "at://did:plc:test123/social.coves.community.comment/test123",
+
Content: "",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrContentEmpty) {
+
t.Errorf("Expected ErrContentEmpty, got: %v", err)
+
}
+
}
+
+
func TestUpdateComment_ContentTooLong(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
longContent := strings.Repeat("あ", 10001)
+
+
req := UpdateCommentRequest{
+
URI: "at://did:plc:test123/social.coves.community.comment/test123",
+
Content: longContent,
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrContentTooLong) {
+
t.Errorf("Expected ErrContentTooLong, got: %v", err)
+
}
+
}
+
+
func TestUpdateComment_CommentNotFound(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
mockClient.getError = pds.ErrNotFound
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := UpdateCommentRequest{
+
URI: "at://did:plc:test123/social.coves.community.comment/nonexistent",
+
Content: "Updated content",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
_, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrCommentNotFound) {
+
t.Errorf("Expected ErrCommentNotFound, got: %v", err)
+
}
+
}
+
+
func TestUpdateComment_PreservesReplyRefs(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Pre-create a comment in the mock PDS
+
rkey := "testcomment123"
+
originalRootURI := "at://did:plc:author/social.coves.community.post/originalroot"
+
originalRootCID := "bafyoriginalroot"
+
existingRecord := map[string]interface{}{
+
"$type": "social.coves.community.comment",
+
"content": "Original content",
+
"reply": map[string]interface{}{
+
"root": map[string]interface{}{
+
"uri": originalRootURI,
+
"cid": originalRootCID,
+
},
+
"parent": map[string]interface{}{
+
"uri": originalRootURI,
+
"cid": originalRootCID,
+
},
+
},
+
"createdAt": time.Now().Format(time.RFC3339),
+
}
+
if mockClient.records["social.coves.community.comment"] == nil {
+
mockClient.records["social.coves.community.comment"] = make(map[string]interface{})
+
}
+
mockClient.records["social.coves.community.comment"][rkey] = existingRecord
+
+
req := UpdateCommentRequest{
+
URI: fmt.Sprintf("at://did:plc:test123/social.coves.community.comment/%s", rkey),
+
Content: "Updated content",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
resp, err := service.UpdateComment(ctx, session, req)
+
+
// Verify
+
if err != nil {
+
t.Fatalf("Expected no error, got: %v", err)
+
}
+
+
// Verify reply refs were preserved by checking the updated record
+
updatedRecordInterface := mockClient.records["social.coves.community.comment"][rkey]
+
updatedRecord, ok := updatedRecordInterface.(CommentRecord)
+
if !ok {
+
// Try as map (from pre-existing record)
+
recordMap := updatedRecordInterface.(map[string]interface{})
+
reply := recordMap["reply"].(map[string]interface{})
+
root := reply["root"].(map[string]interface{})
+
+
if root["uri"] != originalRootURI {
+
t.Errorf("Expected root URI to be preserved as %s, got %s", originalRootURI, root["uri"])
+
}
+
if root["cid"] != originalRootCID {
+
t.Errorf("Expected root CID to be preserved as %s, got %s", originalRootCID, root["cid"])
+
}
+
+
// Verify content was updated
+
if recordMap["content"] != "Updated content" {
+
t.Errorf("Expected content to be updated to 'Updated content', got %s", recordMap["content"])
+
}
+
} else {
+
// CommentRecord struct
+
if updatedRecord.Reply.Root.URI != originalRootURI {
+
t.Errorf("Expected root URI to be preserved as %s, got %s", originalRootURI, updatedRecord.Reply.Root.URI)
+
}
+
if updatedRecord.Reply.Root.CID != originalRootCID {
+
t.Errorf("Expected root CID to be preserved as %s, got %s", originalRootCID, updatedRecord.Reply.Root.CID)
+
}
+
+
// Verify content was updated
+
if updatedRecord.Content != "Updated content" {
+
t.Errorf("Expected content to be updated to 'Updated content', got %s", updatedRecord.Content)
+
}
+
}
+
+
// Verify response
+
if resp == nil {
+
t.Fatal("Expected response, got nil")
+
}
+
}
+
+
// ================================================================================
+
// DeleteComment Tests
+
// ================================================================================
+
+
func TestDeleteComment_Success(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Pre-create a comment in the mock PDS
+
rkey := "testcomment123"
+
existingRecord := map[string]interface{}{
+
"$type": "social.coves.community.comment",
+
"content": "Test content",
+
}
+
if mockClient.records["social.coves.community.comment"] == nil {
+
mockClient.records["social.coves.community.comment"] = make(map[string]interface{})
+
}
+
mockClient.records["social.coves.community.comment"][rkey] = existingRecord
+
+
req := DeleteCommentRequest{
+
URI: fmt.Sprintf("at://did:plc:test123/social.coves.community.comment/%s", rkey),
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
err := service.DeleteComment(ctx, session, req)
+
+
// Verify
+
if err != nil {
+
t.Fatalf("Expected no error, got: %v", err)
+
}
+
+
// Verify comment was deleted from mock PDS
+
_, exists := mockClient.records["social.coves.community.comment"][rkey]
+
if exists {
+
t.Error("Expected comment to be deleted from PDS")
+
}
+
}
+
+
func TestDeleteComment_EmptyURI(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := DeleteCommentRequest{
+
URI: "",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
err := service.DeleteComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrCommentNotFound) {
+
t.Errorf("Expected ErrCommentNotFound, got: %v", err)
+
}
+
}
+
+
func TestDeleteComment_InvalidURIFormat(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := DeleteCommentRequest{
+
URI: "invalid-uri",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
err := service.DeleteComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrCommentNotFound) {
+
t.Errorf("Expected ErrCommentNotFound, got: %v", err)
+
}
+
}
+
+
func TestDeleteComment_NotOwner(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Try to delete a comment owned by a different user
+
req := DeleteCommentRequest{
+
URI: "at://did:plc:otheruser/social.coves.community.comment/test123",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
err := service.DeleteComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrNotAuthorized) {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
}
+
}
+
+
func TestDeleteComment_CommentNotFound(t *testing.T) {
+
// Setup
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
mockClient.getError = pds.ErrNotFound
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
req := DeleteCommentRequest{
+
URI: "at://did:plc:test123/social.coves.community.comment/nonexistent",
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Execute
+
err := service.DeleteComment(ctx, session, req)
+
+
// Verify
+
if !errors.Is(err, ErrCommentNotFound) {
+
t.Errorf("Expected ErrCommentNotFound, got: %v", err)
+
}
+
}
+
+
// TestCreateComment_GraphemeCounting tests that we count graphemes correctly, not runes
+
// Flag emoji 🇺🇸 is 2 runes but 1 grapheme
+
// Emoji with skin tone 👋🏽 is 2 runes but 1 grapheme
+
func TestCreateComment_GraphemeCounting(t *testing.T) {
+
ctx := context.Background()
+
mockClient := newMockPDSClient("did:plc:test123")
+
factory := &mockPDSClientFactory{client: mockClient}
+
+
commentRepo := newMockCommentRepo()
+
userRepo := newMockUserRepo()
+
postRepo := newMockPostRepo()
+
communityRepo := newMockCommunityRepo()
+
+
service := NewCommentServiceWithPDSFactory(
+
commentRepo,
+
userRepo,
+
postRepo,
+
communityRepo,
+
nil,
+
factory.create,
+
)
+
+
// Flag emoji 🇺🇸 is 2 runes but 1 grapheme
+
// 10000 flag emojis = 10000 graphemes but 20000 runes
+
// This should succeed because we count graphemes
+
content := strings.Repeat("🇺🇸", 10000)
+
+
req := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: content,
+
}
+
+
session := createTestSession("did:plc:test123")
+
+
// Should succeed - 10000 graphemes is exactly at the limit
+
_, err := service.CreateComment(ctx, session, req)
+
if err != nil {
+
t.Errorf("Expected success for 10000 graphemes, got error: %v", err)
+
}
+
+
// Now test that 10001 graphemes fails
+
contentTooLong := strings.Repeat("🇺🇸", 10001)
+
reqTooLong := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: contentTooLong,
+
}
+
+
_, err = service.CreateComment(ctx, session, reqTooLong)
+
if !errors.Is(err, ErrContentTooLong) {
+
t.Errorf("Expected ErrContentTooLong for 10001 graphemes, got: %v", err)
+
}
+
+
// Also test emoji with skin tone modifier: 👋🏽 is 2 runes but 1 grapheme
+
contentWithSkinTone := strings.Repeat("👋🏽", 10000)
+
reqWithSkinTone := CreateCommentRequest{
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
Parent: StrongRef{
+
URI: "at://did:plc:author/social.coves.community.post/root123",
+
CID: "bafyroot",
+
},
+
},
+
Content: contentWithSkinTone,
+
}
+
+
_, err = service.CreateComment(ctx, session, reqWithSkinTone)
+
if err != nil {
+
t.Errorf("Expected success for 10000 graphemes with skin tone modifier, got error: %v", err)
+
}
+
}
+2 -2
internal/core/comments/errors.go
···
// ErrRootNotFound indicates the root post doesn't exist
ErrRootNotFound = errors.New("root post not found")
-
// ErrContentTooLong indicates comment content exceeds 3000 graphemes
-
ErrContentTooLong = errors.New("comment content exceeds 3000 graphemes")
+
// ErrContentTooLong indicates comment content exceeds 10000 graphemes
+
ErrContentTooLong = errors.New("comment content exceeds 10000 graphemes")
// ErrContentEmpty indicates comment content is empty
ErrContentEmpty = errors.New("comment content is required")
+38
internal/core/comments/types.go
···
+
package comments
+
+
// CreateCommentRequest contains parameters for creating a comment
+
type CreateCommentRequest struct {
+
Reply ReplyRef `json:"reply"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
}
+
+
// CreateCommentResponse contains the result of creating a comment
+
type CreateCommentResponse struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// UpdateCommentRequest contains parameters for updating a comment
+
type UpdateCommentRequest struct {
+
URI string `json:"uri"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
}
+
+
// UpdateCommentResponse contains the result of updating a comment
+
type UpdateCommentResponse struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// DeleteCommentRequest contains parameters for deleting a comment
+
type DeleteCommentRequest struct {
+
URI string `json:"uri"`
+
}
+4 -2
tests/integration/comment_query_test.go
···
postRepo := postgres.NewPostRepository(db)
userRepo := postgres.NewUserRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
-
return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - these tests only use the read path (GetComments)
+
return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
}
// Helper: createTestCommentWithScore creates a comment with specific vote counts
···
postRepo := postgres.NewPostRepository(db)
userRepo := postgres.NewUserRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
-
service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - these tests only use the read path (GetComments)
+
service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
return &testCommentServiceAdapter{service: service}
}
+6 -3
tests/integration/comment_vote_test.go
···
}
// Query comments with viewer authentication
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
···
}
// Query with authentication but no vote
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
···
t.Run("Unauthenticated request has no viewer state", func(t *testing.T) {
// Query without authentication
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
+808
tests/integration/comment_write_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/pds"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"errors"
+
"fmt"
+
"io"
+
"net/http"
+
"os"
+
"testing"
+
"time"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
)
+
+
// TestCommentWrite_CreateTopLevelComment tests creating a comment on a post via E2E flow
+
func TestCommentWrite_CreateTopLevelComment(t *testing.T) {
+
// Skip in short mode since this requires real PDS
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() {
+
if closeErr := db.Close(); closeErr != nil {
+
t.Logf("Failed to close database: %v", closeErr)
+
}
+
}()
+
+
// Run migrations
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
// Check if PDS is running
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
func() {
+
if closeErr := healthResp.Body.Close(); closeErr != nil {
+
t.Logf("Failed to close health response: %v", closeErr)
+
}
+
}()
+
+
ctx := context.Background()
+
+
// Setup repositories
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Setup service with password-based PDS client factory for E2E testing
+
// CommentPDSClientFactory creates a PDS client for comment operations
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil, // userRepo not needed for write ops
+
postRepo,
+
nil, // communityRepo not needed for write ops
+
nil, // logger
+
commentPDSFactory,
+
)
+
+
// Create test user on PDS
+
testUserHandle := fmt.Sprintf("commenter-%d.local.coves.dev", time.Now().Unix())
+
testUserEmail := fmt.Sprintf("commenter-%d@test.local", time.Now().Unix())
+
testUserPassword := "test-password-123"
+
+
t.Logf("Creating test user on PDS: %s", testUserHandle)
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
t.Logf("Test user created: DID=%s", userDID)
+
+
// Index user in AppView
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
// Create test community and post to comment on
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "test-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post", 0, time.Now())
+
postCID := "bafypost123"
+
+
// Create mock OAuth session for service layer
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
// ====================================================================================
+
// TEST: Create top-level comment on post
+
// ====================================================================================
+
t.Logf("\n📝 Creating top-level comment via service...")
+
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: postURI,
+
CID: postCID,
+
},
+
Parent: comments.StrongRef{
+
URI: postURI,
+
CID: postCID,
+
},
+
},
+
Content: "This is a test comment on the post",
+
Langs: []string{"en"},
+
}
+
+
// Get session from store
+
parsedDID, _ := parseTestDID(userDID)
+
session, err := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if err != nil {
+
t.Fatalf("Failed to get session: %v", err)
+
}
+
+
commentResp, err := commentService.CreateComment(ctx, session, commentReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
t.Logf("✅ Comment created:")
+
t.Logf(" URI: %s", commentResp.URI)
+
t.Logf(" CID: %s", commentResp.CID)
+
+
// Verify comment record was written to PDS
+
t.Logf("\n🔍 Verifying comment record on PDS...")
+
rkey := utils.ExtractRKeyFromURI(commentResp.URI)
+
collection := "social.coves.community.comment"
+
+
pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
+
pdsURL, userDID, collection, rkey))
+
if pdsErr != nil {
+
t.Fatalf("Failed to fetch comment record from PDS: %v", pdsErr)
+
}
+
defer func() {
+
if closeErr := pdsResp.Body.Close(); closeErr != nil {
+
t.Logf("Failed to close PDS response: %v", closeErr)
+
}
+
}()
+
+
if pdsResp.StatusCode != http.StatusOK {
+
body, _ := io.ReadAll(pdsResp.Body)
+
t.Fatalf("Comment record not found on PDS: status %d, body: %s", pdsResp.StatusCode, string(body))
+
}
+
+
var pdsRecord struct {
+
Value map[string]interface{} `json:"value"`
+
CID string `json:"cid"`
+
}
+
if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil {
+
t.Fatalf("Failed to decode PDS record: %v", decodeErr)
+
}
+
+
t.Logf("✅ Comment record found on PDS:")
+
t.Logf(" CID: %s", pdsRecord.CID)
+
t.Logf(" Content: %v", pdsRecord.Value["content"])
+
+
// Verify content
+
if pdsRecord.Value["content"] != "This is a test comment on the post" {
+
t.Errorf("Expected content 'This is a test comment on the post', got %v", pdsRecord.Value["content"])
+
}
+
+
// Simulate Jetstream consumer indexing the comment
+
t.Logf("\n🔄 Simulating Jetstream consumer indexing comment...")
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
commentEvent := jetstream.JetstreamEvent{
+
Did: userDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-comment-rev",
+
Operation: "create",
+
Collection: "social.coves.community.comment",
+
RKey: rkey,
+
CID: pdsRecord.CID,
+
Record: map[string]interface{}{
+
"$type": "social.coves.community.comment",
+
"reply": map[string]interface{}{
+
"root": map[string]interface{}{
+
"uri": postURI,
+
"cid": postCID,
+
},
+
"parent": map[string]interface{}{
+
"uri": postURI,
+
"cid": postCID,
+
},
+
},
+
"content": "This is a test comment on the post",
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
if handleErr := commentConsumer.HandleEvent(ctx, &commentEvent); handleErr != nil {
+
t.Fatalf("Failed to handle comment event: %v", handleErr)
+
}
+
+
// Verify comment was indexed in AppView
+
t.Logf("\n🔍 Verifying comment indexed in AppView...")
+
indexedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Comment not indexed in AppView: %v", err)
+
}
+
+
t.Logf("✅ Comment indexed in AppView:")
+
t.Logf(" CommenterDID: %s", indexedComment.CommenterDID)
+
t.Logf(" Content: %s", indexedComment.Content)
+
t.Logf(" RootURI: %s", indexedComment.RootURI)
+
t.Logf(" ParentURI: %s", indexedComment.ParentURI)
+
+
// Verify comment details
+
if indexedComment.CommenterDID != userDID {
+
t.Errorf("Expected commenter_did %s, got %s", userDID, indexedComment.CommenterDID)
+
}
+
if indexedComment.RootURI != postURI {
+
t.Errorf("Expected root_uri %s, got %s", postURI, indexedComment.RootURI)
+
}
+
if indexedComment.ParentURI != postURI {
+
t.Errorf("Expected parent_uri %s, got %s", postURI, indexedComment.ParentURI)
+
}
+
if indexedComment.Content != "This is a test comment on the post" {
+
t.Errorf("Expected content 'This is a test comment on the post', got %s", indexedComment.Content)
+
}
+
+
// Verify post comment count updated
+
t.Logf("\n🔍 Verifying post comment count updated...")
+
updatedPost, err := postRepo.GetByURI(ctx, postURI)
+
if err != nil {
+
t.Fatalf("Failed to get updated post: %v", err)
+
}
+
+
if updatedPost.CommentCount != 1 {
+
t.Errorf("Expected comment_count = 1, got %d", updatedPost.CommentCount)
+
}
+
+
t.Logf("✅ TRUE E2E COMMENT CREATE FLOW COMPLETE:")
+
t.Logf(" Client → Service → PDS Write → Jetstream → Consumer → AppView ✓")
+
t.Logf(" ✓ Comment written to PDS")
+
t.Logf(" ✓ Comment indexed in AppView")
+
t.Logf(" ✓ Post comment count updated")
+
}
+
+
// TestCommentWrite_CreateNestedReply tests creating a reply to another comment
+
func TestCommentWrite_CreateNestedReply(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
ctx := context.Background()
+
pdsURL := getTestPDSURL()
+
+
// Setup repositories and service
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// CommentPDSClientFactory creates a PDS client for comment operations
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create test user
+
testUserHandle := fmt.Sprintf("replier-%d.local.coves.dev", time.Now().Unix())
+
testUserEmail := fmt.Sprintf("replier-%d@test.local", time.Now().Unix())
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
// Create test post and parent comment
+
testCommunityDID, _ := createFeedTestCommunity(db, ctx, "reply-community", "owner.test")
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post", 0, time.Now())
+
postCID := "bafypost456"
+
+
// Create parent comment directly in DB (simulating already-indexed comment)
+
parentCommentURI := fmt.Sprintf("at://%s/social.coves.community.comment/parent123", userDID)
+
parentCommentCID := "bafyparent123"
+
_, err = db.ExecContext(ctx, `
+
INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at)
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
+
`, parentCommentURI, parentCommentCID, "parent123", userDID, postURI, postCID, postURI, postCID, "Parent comment")
+
if err != nil {
+
t.Fatalf("Failed to create parent comment: %v", err)
+
}
+
+
// Setup OAuth
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
// Create nested reply
+
t.Logf("\n📝 Creating nested reply...")
+
replyReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: postURI,
+
CID: postCID,
+
},
+
Parent: comments.StrongRef{
+
URI: parentCommentURI,
+
CID: parentCommentCID,
+
},
+
},
+
Content: "This is a reply to the parent comment",
+
Langs: []string{"en"},
+
}
+
+
parsedDID, _ := parseTestDID(userDID)
+
session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
+
replyResp, err := commentService.CreateComment(ctx, session, replyReq)
+
if err != nil {
+
t.Fatalf("Failed to create reply: %v", err)
+
}
+
+
t.Logf("✅ Reply created: %s", replyResp.URI)
+
+
// Simulate Jetstream indexing
+
rkey := utils.ExtractRKeyFromURI(replyResp.URI)
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
replyEvent := jetstream.JetstreamEvent{
+
Did: userDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-reply-rev",
+
Operation: "create",
+
Collection: "social.coves.community.comment",
+
RKey: rkey,
+
CID: replyResp.CID,
+
Record: map[string]interface{}{
+
"$type": "social.coves.community.comment",
+
"reply": map[string]interface{}{
+
"root": map[string]interface{}{
+
"uri": postURI,
+
"cid": postCID,
+
},
+
"parent": map[string]interface{}{
+
"uri": parentCommentURI,
+
"cid": parentCommentCID,
+
},
+
},
+
"content": "This is a reply to the parent comment",
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
if handleErr := commentConsumer.HandleEvent(ctx, &replyEvent); handleErr != nil {
+
t.Fatalf("Failed to handle reply event: %v", handleErr)
+
}
+
+
// Verify reply was indexed with correct parent
+
indexedReply, err := commentRepo.GetByURI(ctx, replyResp.URI)
+
if err != nil {
+
t.Fatalf("Reply not indexed: %v", err)
+
}
+
+
if indexedReply.RootURI != postURI {
+
t.Errorf("Expected root_uri %s, got %s", postURI, indexedReply.RootURI)
+
}
+
if indexedReply.ParentURI != parentCommentURI {
+
t.Errorf("Expected parent_uri %s, got %s", parentCommentURI, indexedReply.ParentURI)
+
}
+
+
t.Logf("✅ NESTED REPLY FLOW COMPLETE:")
+
t.Logf(" ✓ Reply created with correct parent reference")
+
t.Logf(" ✓ Reply indexed in AppView")
+
}
+
+
// TestCommentWrite_UpdateComment tests updating an existing comment
+
func TestCommentWrite_UpdateComment(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
ctx := context.Background()
+
pdsURL := getTestPDSURL()
+
+
// Setup repositories and service
+
commentRepo := postgres.NewCommentRepository(db)
+
+
// CommentPDSClientFactory creates a PDS client for comment operations
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
nil,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create test user
+
testUserHandle := fmt.Sprintf("updater-%d.local.coves.dev", time.Now().Unix())
+
testUserEmail := fmt.Sprintf("updater-%d@test.local", time.Now().Unix())
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
// Setup OAuth
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
parsedDID, _ := parseTestDID(userDID)
+
session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
+
// First, create a comment to update
+
t.Logf("\n📝 Creating initial comment...")
+
createReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: "at://did:plc:test/social.coves.community.post/test123",
+
CID: "bafypost",
+
},
+
Parent: comments.StrongRef{
+
URI: "at://did:plc:test/social.coves.community.post/test123",
+
CID: "bafypost",
+
},
+
},
+
Content: "Original content",
+
Langs: []string{"en"},
+
}
+
+
createResp, err := commentService.CreateComment(ctx, session, createReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
t.Logf("✅ Initial comment created: %s", createResp.URI)
+
+
// Now update the comment
+
t.Logf("\n📝 Updating comment...")
+
updateReq := comments.UpdateCommentRequest{
+
URI: createResp.URI,
+
Content: "Updated content - this has been edited",
+
}
+
+
updateResp, err := commentService.UpdateComment(ctx, session, updateReq)
+
if err != nil {
+
t.Fatalf("Failed to update comment: %v", err)
+
}
+
+
t.Logf("✅ Comment updated:")
+
t.Logf(" URI: %s", updateResp.URI)
+
t.Logf(" New CID: %s", updateResp.CID)
+
+
// Verify the update on PDS
+
rkey := utils.ExtractRKeyFromURI(updateResp.URI)
+
pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",
+
pdsURL, userDID, rkey))
+
defer pdsResp.Body.Close()
+
+
var pdsRecord struct {
+
Value map[string]interface{} `json:"value"`
+
CID string `json:"cid"`
+
}
+
json.NewDecoder(pdsResp.Body).Decode(&pdsRecord)
+
+
if pdsRecord.Value["content"] != "Updated content - this has been edited" {
+
t.Errorf("Expected updated content, got %v", pdsRecord.Value["content"])
+
}
+
+
t.Logf("✅ UPDATE FLOW COMPLETE:")
+
t.Logf(" ✓ Comment updated on PDS")
+
t.Logf(" ✓ New CID generated")
+
t.Logf(" ✓ Content verified")
+
}
+
+
// TestCommentWrite_DeleteComment tests deleting a comment
+
func TestCommentWrite_DeleteComment(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
ctx := context.Background()
+
pdsURL := getTestPDSURL()
+
+
// Setup repositories and service
+
commentRepo := postgres.NewCommentRepository(db)
+
+
// CommentPDSClientFactory creates a PDS client for comment operations
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
nil,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create test user
+
testUserHandle := fmt.Sprintf("deleter-%d.local.coves.dev", time.Now().Unix())
+
testUserEmail := fmt.Sprintf("deleter-%d@test.local", time.Now().Unix())
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
// Setup OAuth
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
parsedDID, _ := parseTestDID(userDID)
+
session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
+
// First, create a comment to delete
+
t.Logf("\n📝 Creating comment to delete...")
+
createReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: "at://did:plc:test/social.coves.community.post/test123",
+
CID: "bafypost",
+
},
+
Parent: comments.StrongRef{
+
URI: "at://did:plc:test/social.coves.community.post/test123",
+
CID: "bafypost",
+
},
+
},
+
Content: "This comment will be deleted",
+
Langs: []string{"en"},
+
}
+
+
createResp, err := commentService.CreateComment(ctx, session, createReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
t.Logf("✅ Comment created: %s", createResp.URI)
+
+
// Now delete the comment
+
t.Logf("\n📝 Deleting comment...")
+
deleteReq := comments.DeleteCommentRequest{
+
URI: createResp.URI,
+
}
+
+
err = commentService.DeleteComment(ctx, session, deleteReq)
+
if err != nil {
+
t.Fatalf("Failed to delete comment: %v", err)
+
}
+
+
t.Logf("✅ Comment deleted")
+
+
// Verify deletion on PDS
+
rkey := utils.ExtractRKeyFromURI(createResp.URI)
+
pdsResp, _ := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",
+
pdsURL, userDID, rkey))
+
defer pdsResp.Body.Close()
+
+
if pdsResp.StatusCode != http.StatusBadRequest && pdsResp.StatusCode != http.StatusNotFound {
+
t.Errorf("Expected 400 or 404 for deleted comment, got %d", pdsResp.StatusCode)
+
}
+
+
t.Logf("✅ DELETE FLOW COMPLETE:")
+
t.Logf(" ✓ Comment deleted from PDS")
+
t.Logf(" ✓ Record no longer accessible")
+
}
+
+
// TestCommentWrite_CannotUpdateOthersComment tests authorization for updates
+
func TestCommentWrite_CannotUpdateOthersComment(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
ctx := context.Background()
+
pdsURL := getTestPDSURL()
+
+
// CommentPDSClientFactory creates a PDS client for comment operations
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
// Setup service
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
nil,
+
nil,
+
nil,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create first user (comment owner)
+
ownerHandle := fmt.Sprintf("owner-%d.local.coves.dev", time.Now().Unix())
+
ownerEmail := fmt.Sprintf("owner-%d@test.local", time.Now().Unix())
+
_, ownerDID, err := createPDSAccount(pdsURL, ownerHandle, ownerEmail, "password123")
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
// Create second user (attacker)
+
attackerHandle := fmt.Sprintf("attacker-%d.local.coves.dev", time.Now().Unix())
+
attackerEmail := fmt.Sprintf("attacker-%d@test.local", time.Now().Unix())
+
attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123")
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
// Setup OAuth for attacker
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(attackerDID, "session-"+attackerDID, attackerToken, pdsURL)
+
+
parsedDID, _ := parseTestDID(attackerDID)
+
session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+attackerDID)
+
+
// Try to update comment owned by different user
+
t.Logf("\n🚨 Attempting to update another user's comment...")
+
updateReq := comments.UpdateCommentRequest{
+
URI: fmt.Sprintf("at://%s/social.coves.community.comment/test123", ownerDID),
+
Content: "Malicious update attempt",
+
}
+
+
_, err = commentService.UpdateComment(ctx, session, updateReq)
+
+
// Verify authorization error
+
if err == nil {
+
t.Fatal("Expected authorization error, got nil")
+
}
+
if !errors.Is(err, comments.ErrNotAuthorized) {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
}
+
+
t.Logf("✅ AUTHORIZATION CHECK PASSED:")
+
t.Logf(" ✓ User cannot update others' comments")
+
}
+
+
// TestCommentWrite_CannotDeleteOthersComment tests authorization for deletes
+
func TestCommentWrite_CannotDeleteOthersComment(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
ctx := context.Background()
+
pdsURL := getTestPDSURL()
+
+
// CommentPDSClientFactory creates a PDS client for comment operations
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
// Setup service
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
nil,
+
nil,
+
nil,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create first user (comment owner)
+
ownerHandle := fmt.Sprintf("owner-%d.local.coves.dev", time.Now().Unix())
+
ownerEmail := fmt.Sprintf("owner-%d@test.local", time.Now().Unix())
+
_, ownerDID, err := createPDSAccount(pdsURL, ownerHandle, ownerEmail, "password123")
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
// Create second user (attacker)
+
attackerHandle := fmt.Sprintf("attacker-%d.local.coves.dev", time.Now().Unix())
+
attackerEmail := fmt.Sprintf("attacker-%d@test.local", time.Now().Unix())
+
attackerToken, attackerDID, err := createPDSAccount(pdsURL, attackerHandle, attackerEmail, "password123")
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
// Setup OAuth for attacker
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(attackerDID, "session-"+attackerDID, attackerToken, pdsURL)
+
+
parsedDID, _ := parseTestDID(attackerDID)
+
session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+attackerDID)
+
+
// Try to delete comment owned by different user
+
t.Logf("\n🚨 Attempting to delete another user's comment...")
+
deleteReq := comments.DeleteCommentRequest{
+
URI: fmt.Sprintf("at://%s/social.coves.community.comment/test123", ownerDID),
+
}
+
+
err = commentService.DeleteComment(ctx, session, deleteReq)
+
+
// Verify authorization error
+
if err == nil {
+
t.Fatal("Expected authorization error, got nil")
+
}
+
if !errors.Is(err, comments.ErrNotAuthorized) {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
}
+
+
t.Logf("✅ AUTHORIZATION CHECK PASSED:")
+
t.Logf(" ✓ User cannot delete others' comments")
+
}
+
+
// Helper function to parse DID for testing
+
func parseTestDID(did string) (syntax.DID, error) {
+
return syntax.ParseDID(did)
+
}
+2 -1
tests/integration/concurrent_scenarios_test.go
···
}
// Verify all comments are retrievable via service
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: postURI,
Sort: "new",