A community based topic aggregation platform built on atproto

feat(userblocks): add user-to-user blocking with feed/comment filtering

Implement one-directional user blocking following the atProto write-forward
pattern. Blocks are written to the blocker's PDS repo and indexed via the
Jetstream firehose consumer into a new user_blocks table.

Changes:
- Add social.coves.actor.block lexicon and DB migration (029)
- Add userblocks domain package (service, repository, interfaces, errors)
- Add XRPC endpoints: blockUser, unblockUser, getBlockedUsers
- Extend Jetstream consumer to index block create/delete events
- Filter blocked users' posts from timeline, discover, and community feeds
- Filter blocked users' comments from comment threads
- Hydrate viewer.blocking on profile responses for authenticated viewers
- Refactor test helpers: DRY PDS client factories, atomic counters

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

+4907 -82
+3
.claude/settings.json
··· 1 1 { 2 + "env": { 3 + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" 4 + }, 2 5 "hooks": { 3 6 "PostToolUse": [ 4 7 {
+24 -3
cmd/server/main.go
··· 41 41 "Coves/internal/core/timeline" 42 42 "Coves/internal/core/unfurl" 43 43 "Coves/internal/core/adminreports" 44 + "Coves/internal/core/userblocks" 44 45 "Coves/internal/core/users" 45 46 "Coves/internal/core/votes" 46 47 ··· 219 220 "repo:social.coves.actor.profile?action=create&action=update&action=delete", 220 221 // Votes 221 222 "repo:social.coves.feed.vote?action=create&action=delete", 223 + // User blocks 224 + "repo:social.coves.actor.block?action=create&action=delete", 222 225 }, 223 226 DevMode: isDevMode, 224 227 AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode ··· 365 368 // Start Jetstream consumer for read-forward user indexing 366 369 jetstreamURL := os.Getenv("JETSTREAM_URL") 367 370 if jetstreamURL == "" { 368 - jetstreamURL = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=social.coves.actor.profile" 371 + jetstreamURL = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=social.coves.actor.profile&wantedCollections=social.coves.actor.block" 369 372 } 370 373 371 374 pdsFilter := os.Getenv("JETSTREAM_PDS_FILTER") // Optional: filter to specific PDS ··· 376 379 consumerOpts = append(consumerOpts, jetstream.WithSessionHandleUpdater(sessionUpdater)) 377 380 log.Println("✅ OAuth session handle sync enabled for identity changes") 378 381 } 382 + 383 + // Wire user block repo into user consumer for indexing social.coves.actor.block events 384 + userBlockRepo := postgresRepo.NewUserBlockRepository(db) 385 + consumerOpts = append(consumerOpts, jetstream.WithUserBlockRepo(userBlockRepo)) 386 + 379 387 userConsumer := jetstream.NewUserEventConsumer(userService, identityResolver, jetstreamURL, pdsFilter, consumerOpts...) 380 388 ctx := context.Background() 381 389 go func() { ··· 611 619 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo, oauthClient, oauthStore, nil) 612 620 log.Println("✅ Comment service initialized (with author/community hydration and write support)") 613 621 622 + // Initialize user block service (user-to-user blocking) 623 + // userBlockRepo already created above for the Jetstream consumer 624 + userBlockService := userblocks.NewService(userBlockRepo, nil, oauthClient, oauthStore, nil) 625 + log.Println("✅ User block service initialized (with OAuth authentication)") 626 + 614 627 // Initialize admin report service (off-protocol reporting for serious content issues) 615 628 adminReportRepo := postgresRepo.NewAdminReportRepository(db) 616 629 adminReportService := adminreports.NewService(adminReportRepo) ··· 778 791 log.Println(" - Updating: Post comment counts and comment reply counts atomically") 779 792 780 793 // Register XRPC routes 781 - routes.RegisterUserRoutes(r, userService, authMiddleware, oauthClient.ClientApp) 794 + routes.RegisterUserRoutesWithOptions(r, userService, authMiddleware, oauthClient.ClientApp, &routes.UserRouteOptions{ 795 + UserBlockRepo: userBlockRepo, 796 + }) 782 797 log.Println("User XRPC endpoints registered") 783 - log.Println(" - GET /xrpc/social.coves.actor.getprofile (public)") 798 + log.Println(" - GET /xrpc/social.coves.actor.getprofile (public, OptionalAuth for viewer.blocking)") 784 799 log.Println(" - POST /xrpc/social.coves.actor.signup (public)") 785 800 log.Println(" - POST /xrpc/social.coves.actor.deleteAccount (requires OAuth)") 786 801 log.Println(" - POST /xrpc/social.coves.actor.updateProfile (requires OAuth)") ··· 793 808 794 809 routes.RegisterVoteRoutes(r, voteService, authMiddleware) 795 810 log.Println("Vote XRPC endpoints registered with OAuth authentication") 811 + 812 + routes.RegisterUserBlockRoutes(r, userBlockService, authMiddleware) 813 + log.Println("User block XRPC endpoints registered with OAuth authentication") 814 + log.Println(" - POST /xrpc/social.coves.actor.blockUser") 815 + log.Println(" - POST /xrpc/social.coves.actor.unblockUser") 816 + log.Println(" - GET /xrpc/social.coves.actor.getBlockedUsers") 796 817 797 818 // Register comment write routes (create, update, delete) 798 819 routes.RegisterCommentRoutes(r, commentService, authMiddleware)
+4
internal/api/handlers/communityFeed/get_community.go
··· 7 7 "strconv" 8 8 9 9 "Coves/internal/api/handlers/common" 10 + "Coves/internal/api/middleware" 10 11 "Coves/internal/core/blueskypost" 11 12 "Coves/internal/core/communityFeeds" 12 13 "Coves/internal/core/posts" ··· 77 78 // parseRequest parses query parameters into GetCommunityFeedRequest 78 79 func (h *GetCommunityHandler) parseRequest(r *http.Request) (communityFeeds.GetCommunityFeedRequest, error) { 79 80 req := communityFeeds.GetCommunityFeedRequest{} 81 + 82 + // Extract viewer DID from OptionalAuth context for block filtering 83 + req.ViewerDID = middleware.GetUserDID(r) 80 84 81 85 // Required: community 82 86 req.Community = r.URL.Query().Get("community")
+4
internal/api/handlers/discover/get_discover.go
··· 7 7 "strconv" 8 8 9 9 "Coves/internal/api/handlers/common" 10 + "Coves/internal/api/middleware" 10 11 "Coves/internal/core/blueskypost" 11 12 "Coves/internal/core/discover" 12 13 "Coves/internal/core/posts" ··· 73 74 // parseRequest parses query parameters into GetDiscoverRequest 74 75 func (h *GetDiscoverHandler) parseRequest(r *http.Request) discover.GetDiscoverRequest { 75 76 req := discover.GetDiscoverRequest{} 77 + 78 + // Extract viewer DID from OptionalAuth context for block filtering 79 + req.ViewerDID = middleware.GetUserDID(r) 76 80 77 81 // Optional: sort (default: hot) 78 82 req.Sort = r.URL.Query().Get("sort")
+200
internal/api/handlers/userblock/block.go
··· 1 + package userblock 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "strconv" 10 + "time" 11 + 12 + "Coves/internal/api/middleware" 13 + "Coves/internal/core/userblocks" 14 + ) 15 + 16 + // BlockHandler handles user-to-user blocking operations 17 + type BlockHandler struct { 18 + service userblocks.Service 19 + } 20 + 21 + // NewBlockHandler creates a new user block handler 22 + func NewBlockHandler(service userblocks.Service) *BlockHandler { 23 + if service == nil { 24 + panic("userblock: NewBlockHandler requires a non-nil service") 25 + } 26 + return &BlockHandler{ 27 + service: service, 28 + } 29 + } 30 + 31 + // blockRequest is the input for block/unblock operations. 32 + type blockRequest struct { 33 + Subject string `json:"subject"` 34 + } 35 + 36 + // blockResponse is the output for a successful block operation. 37 + type blockResponse struct { 38 + Block blockRecord `json:"block"` 39 + } 40 + 41 + type blockRecord struct { 42 + RecordURI string `json:"recordUri"` 43 + RecordCID string `json:"recordCid"` 44 + } 45 + 46 + // unblockResponse is the output for a successful unblock operation. 47 + type unblockResponse struct { 48 + Success bool `json:"success"` 49 + } 50 + 51 + // blockedUsersResponse is the output for the get-blocked-users endpoint. 52 + type blockedUsersResponse struct { 53 + Blocks []blockedUserEntry `json:"blocks"` 54 + } 55 + 56 + type blockedUserEntry struct { 57 + BlockedDID string `json:"blockedDid"` 58 + RecordURI string `json:"recordUri"` 59 + RecordCID string `json:"recordCid"` 60 + BlockedAt time.Time `json:"blockedAt"` 61 + } 62 + 63 + // HandleBlock blocks a user 64 + // POST /xrpc/social.coves.actor.blockUser 65 + // 66 + // Request body: { "subject": "did-or-handle" } 67 + // The subject can be a DID (did:plc:xxx) or a handle. 68 + func (h *BlockHandler) HandleBlock(w http.ResponseWriter, r *http.Request) { 69 + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) 70 + 71 + var req blockRequest 72 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 73 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 74 + return 75 + } 76 + 77 + if req.Subject == "" { 78 + writeError(w, http.StatusBadRequest, "InvalidRequest", "subject is required") 79 + return 80 + } 81 + 82 + session := middleware.GetOAuthSession(r) 83 + if session == nil { 84 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 85 + return 86 + } 87 + 88 + result, err := h.service.BlockUser(r.Context(), session, req.Subject) 89 + if err != nil { 90 + handleServiceError(w, err) 91 + return 92 + } 93 + 94 + writeJSON(w, http.StatusOK, blockResponse{ 95 + Block: blockRecord{ 96 + RecordURI: result.RecordURI, 97 + RecordCID: result.RecordCID, 98 + }, 99 + }) 100 + } 101 + 102 + // HandleUnblock unblocks a user 103 + // POST /xrpc/social.coves.actor.unblockUser 104 + // 105 + // Request body: { "subject": "did-or-handle" } 106 + func (h *BlockHandler) HandleUnblock(w http.ResponseWriter, r *http.Request) { 107 + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) 108 + 109 + var req blockRequest 110 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 111 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 112 + return 113 + } 114 + 115 + if req.Subject == "" { 116 + writeError(w, http.StatusBadRequest, "InvalidRequest", "subject is required") 117 + return 118 + } 119 + 120 + session := middleware.GetOAuthSession(r) 121 + if session == nil { 122 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 123 + return 124 + } 125 + 126 + err := h.service.UnblockUser(r.Context(), session, req.Subject) 127 + if err != nil { 128 + handleServiceError(w, err) 129 + return 130 + } 131 + 132 + writeJSON(w, http.StatusOK, unblockResponse{Success: true}) 133 + } 134 + 135 + // HandleGetBlocked returns the list of users blocked by the authenticated user 136 + // GET /xrpc/social.coves.actor.getBlockedUsers?limit=50&offset=0 137 + func (h *BlockHandler) HandleGetBlocked(w http.ResponseWriter, r *http.Request) { 138 + session := middleware.GetOAuthSession(r) 139 + if session == nil { 140 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 141 + return 142 + } 143 + 144 + limit := 50 145 + offset := 0 146 + 147 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 148 + parsed, err := strconv.Atoi(limitStr) 149 + if err != nil { 150 + writeError(w, http.StatusBadRequest, "InvalidRequest", fmt.Sprintf("invalid limit parameter: %q", limitStr)) 151 + return 152 + } 153 + limit = parsed 154 + } 155 + 156 + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { 157 + parsed, err := strconv.Atoi(offsetStr) 158 + if err != nil { 159 + writeError(w, http.StatusBadRequest, "InvalidRequest", fmt.Sprintf("invalid offset parameter: %q", offsetStr)) 160 + return 161 + } 162 + offset = parsed 163 + } 164 + 165 + userDID := session.AccountDID.String() 166 + 167 + blocks, err := h.service.GetBlockedUsers(r.Context(), userDID, limit, offset) 168 + if err != nil { 169 + handleServiceError(w, err) 170 + return 171 + } 172 + 173 + entries := make([]blockedUserEntry, 0, len(blocks)) 174 + for _, b := range blocks { 175 + entries = append(entries, blockedUserEntry{ 176 + BlockedDID: b.BlockedDID, 177 + RecordURI: b.RecordURI, 178 + RecordCID: b.RecordCID, 179 + BlockedAt: b.BlockedAt, 180 + }) 181 + } 182 + 183 + writeJSON(w, http.StatusOK, blockedUsersResponse{Blocks: entries}) 184 + } 185 + 186 + // writeJSON encodes the response to a buffer first, then writes headers and body. 187 + // This avoids sending a 200 status with a broken/empty body if encoding fails. 188 + func writeJSON(w http.ResponseWriter, status int, v any) { 189 + var buf bytes.Buffer 190 + if err := json.NewEncoder(&buf).Encode(v); err != nil { 191 + slog.Error("Failed to encode response", "error", err) 192 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 193 + return 194 + } 195 + w.Header().Set("Content-Type", "application/json") 196 + w.WriteHeader(status) 197 + if _, err := w.Write(buf.Bytes()); err != nil { 198 + slog.Error("Failed to write response", "error", err) 199 + } 200 + }
+56
internal/api/handlers/userblock/errors.go
··· 1 + package userblock 2 + 3 + import ( 4 + "Coves/internal/atproto/pds" 5 + "Coves/internal/core/userblocks" 6 + "encoding/json" 7 + "errors" 8 + "log/slog" 9 + "net/http" 10 + ) 11 + 12 + // XRPCError represents an XRPC error response 13 + type XRPCError struct { 14 + Error string `json:"error"` 15 + Message string `json:"message"` 16 + } 17 + 18 + // writeError writes an XRPC error response 19 + func writeError(w http.ResponseWriter, status int, errCode, message string) { 20 + w.Header().Set("Content-Type", "application/json") 21 + w.WriteHeader(status) 22 + if err := json.NewEncoder(w).Encode(XRPCError{ 23 + Error: errCode, 24 + Message: message, 25 + }); err != nil { 26 + slog.Error("Failed to encode error response", "error", err) 27 + } 28 + } 29 + 30 + // handleServiceError converts user block service errors to appropriate HTTP responses 31 + func handleServiceError(w http.ResponseWriter, err error) { 32 + switch { 33 + case errors.Is(err, userblocks.ErrBlockNotFound): 34 + writeError(w, http.StatusNotFound, "NotFound", err.Error()) 35 + case errors.Is(err, userblocks.ErrBlockAlreadyExists): 36 + writeError(w, http.StatusConflict, "AlreadyExists", err.Error()) 37 + case errors.Is(err, userblocks.ErrCannotBlockSelf): 38 + writeError(w, http.StatusBadRequest, "InvalidRequest", "cannot block yourself") 39 + // PDS-specific errors 40 + case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden): 41 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required or session expired") 42 + case errors.Is(err, pds.ErrBadRequest): 43 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request to PDS") 44 + case errors.Is(err, pds.ErrNotFound): 45 + writeError(w, http.StatusNotFound, "NotFound", "Record not found on PDS") 46 + case errors.Is(err, pds.ErrConflict): 47 + writeError(w, http.StatusConflict, "Conflict", "Record was modified by another operation") 48 + case errors.Is(err, pds.ErrRateLimited): 49 + writeError(w, http.StatusTooManyRequests, "RateLimitExceeded", "Too many requests, please try again later") 50 + case errors.Is(err, pds.ErrPayloadTooLarge): 51 + writeError(w, http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Request payload exceeds size limit") 52 + default: 53 + slog.Error("XRPC user block handler error", "error", err) 54 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 55 + } 56 + }
+35 -3
internal/api/routes/user.go
··· 3 3 import ( 4 4 "Coves/internal/api/handlers/user" 5 5 "Coves/internal/api/middleware" 6 + "Coves/internal/core/userblocks" 6 7 "Coves/internal/core/users" 7 8 "encoding/json" 8 9 "errors" ··· 16 17 17 18 // UserHandler handles user-related XRPC endpoints 18 19 type UserHandler struct { 19 - userService users.UserService 20 + userService users.UserService 21 + userBlockRepo userblocks.Repository // Optional: for hydrating viewer.blocking on profiles 20 22 } 21 23 22 24 // NewUserHandler creates a new user handler ··· 24 26 return &UserHandler{ 25 27 userService: userService, 26 28 } 29 + } 30 + 31 + // SetUserBlockRepo sets the user block repository for profile viewer state hydration. 32 + // When set, GetProfile will include viewer.blocking in the response for authenticated viewers. 33 + func (h *UserHandler) SetUserBlockRepo(repo userblocks.Repository) { 34 + h.userBlockRepo = repo 27 35 } 28 36 29 37 // UserRouteOptions contains optional configuration for user routes. ··· 33 41 // If nil, uses OAuth with DPoP (production behavior). 34 42 // Set this in E2E tests to use password-based authentication. 35 43 PDSClientFactory user.PDSClientFactory 44 + 45 + // UserBlockRepo provides access to user block data for profile viewer state hydration. 46 + // When set, GetProfile includes viewer.blocking in the response for authenticated viewers. 47 + UserBlockRepo userblocks.Repository 36 48 } 37 49 38 50 // RegisterUserRoutes registers user-related XRPC endpoints on the router ··· 45 57 // Use opts to inject test dependencies like custom PDS client factories. 46 58 func RegisterUserRoutesWithOptions(r chi.Router, service users.UserService, authMiddleware *middleware.OAuthAuthMiddleware, oauthClient *oauth.ClientApp, opts *UserRouteOptions) { 47 59 h := NewUserHandler(service) 60 + 61 + // Wire optional dependencies from options 62 + if opts != nil && opts.UserBlockRepo != nil { 63 + h.SetUserBlockRepo(opts.UserBlockRepo) 64 + } 48 65 49 66 // /api/me - returns the authenticated user's own profile (cookie or Bearer) 50 67 meHandler := user.NewMeHandler(service) 51 68 r.With(authMiddleware.RequireAuth).Get("/api/me", meHandler.HandleMe) 52 69 53 - // social.coves.actor.getprofile - query endpoint (public) 54 - r.Get("/xrpc/social.coves.actor.getprofile", h.GetProfile) 70 + // social.coves.actor.getprofile - query endpoint (public, OptionalAuth for viewer state) 71 + r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getprofile", h.GetProfile) 55 72 56 73 // social.coves.actor.signup - procedure endpoint (public) 57 74 r.Post("/xrpc/social.coves.actor.signup", h.Signup) ··· 113 130 log.Printf("Failed to get profile for %s: %v", did, err) 114 131 writeXRPCError(w, "InternalError", "failed to get profile", http.StatusInternalServerError) 115 132 return 133 + } 134 + 135 + // Hydrate viewer state (blocking) when authenticated 136 + viewerDID := middleware.GetUserDID(r) 137 + if viewerDID != "" && viewerDID != did && h.userBlockRepo != nil { 138 + block, blockErr := h.userBlockRepo.GetBlock(ctx, viewerDID, did) 139 + if blockErr == nil && block != nil { 140 + profile.Viewer = &users.ProfileViewerState{ 141 + Blocking: &block.RecordURI, 142 + } 143 + } else if blockErr != nil && !userblocks.IsNotFound(blockErr) { 144 + // Log unexpected DB errors (connection timeout, pool exhaustion, etc.) 145 + // but don't fail the profile request — viewer.blocking is best-effort 146 + log.Printf("WARNING: failed to check block state for viewer %s on profile %s: %v", viewerDID, did, blockErr) 147 + } 116 148 } 117 149 118 150 // Marshal to bytes first to avoid partial writes on encoding errors
+26
internal/api/routes/userblock.go
··· 1 + package routes 2 + 3 + import ( 4 + "Coves/internal/api/handlers/userblock" 5 + "Coves/internal/api/middleware" 6 + "Coves/internal/core/userblocks" 7 + 8 + "github.com/go-chi/chi/v5" 9 + ) 10 + 11 + // RegisterUserBlockRoutes registers user-to-user blocking XRPC endpoints on the router 12 + // Implements social.coves.actor.blockUser, unblockUser, and getBlockedUsers 13 + func RegisterUserBlockRoutes(r chi.Router, service userblocks.Service, authMiddleware *middleware.OAuthAuthMiddleware) { 14 + handler := userblock.NewBlockHandler(service) 15 + 16 + // Procedure endpoints (POST) - require authentication 17 + // social.coves.actor.blockUser - block a user 18 + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.actor.blockUser", handler.HandleBlock) 19 + 20 + // social.coves.actor.unblockUser - unblock a user 21 + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.actor.unblockUser", handler.HandleUnblock) 22 + 23 + // Query endpoints (GET) - require authentication (viewing own blocks) 24 + // social.coves.actor.getBlockedUsers - list blocked users 25 + r.With(authMiddleware.RequireAuth).Get("/xrpc/social.coves.actor.getBlockedUsers", handler.HandleGetBlocked) 26 + }
+166 -7
internal/atproto/jetstream/user_consumer.go
··· 2 2 3 3 import ( 4 4 "Coves/internal/atproto/identity" 5 + "Coves/internal/atproto/utils" 6 + "Coves/internal/core/userblocks" 5 7 "Coves/internal/core/users" 6 8 "context" 7 9 "encoding/json" ··· 9 11 "fmt" 10 12 "log" 11 13 "log/slog" 14 + "strings" 12 15 "sync" 13 16 "time" 14 17 ··· 19 22 // NOTE: This constant is intentionally duplicated in internal/api/handlers/user/update_profile.go 20 23 // to avoid circular dependencies between packages. Keep both definitions in sync. 21 24 const CovesProfileCollection = "social.coves.actor.profile" 25 + 26 + // CovesActorBlockCollection is the atProto collection for user-to-user blocks. 27 + // Records live in the blocker's repository at at://blocker_did/social.coves.actor.block/{tid} 28 + const CovesActorBlockCollection = "social.coves.actor.block" 22 29 23 30 // SessionHandleUpdater is an interface for updating OAuth session handles 24 31 // when identity changes occur. This keeps active sessions in sync with ··· 66 73 type UserEventConsumer struct { 67 74 userService users.UserService 68 75 identityResolver identity.Resolver 69 - sessionHandleUpdater SessionHandleUpdater // Optional: updates OAuth sessions on handle change 76 + sessionHandleUpdater SessionHandleUpdater // Optional: updates OAuth sessions on handle change 77 + userBlockRepo userblocks.Repository // Optional: indexes user-to-user blocks 70 78 wsURL string 71 79 pdsFilter string // Optional: only index users from specific PDS 72 80 } ··· 79 87 func WithSessionHandleUpdater(updater SessionHandleUpdater) ConsumerOption { 80 88 return func(c *UserEventConsumer) { 81 89 c.sessionHandleUpdater = updater 90 + } 91 + } 92 + 93 + // WithUserBlockRepo sets the user block repository for indexing user-to-user blocks 94 + // from the Jetstream firehose. If not set, block events will be ignored. 95 + func WithUserBlockRepo(repo userblocks.Repository) ConsumerOption { 96 + return func(c *UserEventConsumer) { 97 + c.userBlockRepo = repo 82 98 } 83 99 } 84 100 ··· 217 233 } 218 234 } 219 235 220 - // HandleIdentityEventPublic is a public wrapper for testing 236 + // HandleEvent processes a Jetstream event for user-related records. 237 + // This is the public entry point used by tests and external callers. 238 + func (c *UserEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error { 239 + switch event.Kind { 240 + case "identity": 241 + return c.handleIdentityEvent(ctx, event) 242 + case "account": 243 + return c.handleAccountEvent(ctx, event) 244 + case "commit": 245 + return c.handleCommitEvent(ctx, event) 246 + default: 247 + return nil 248 + } 249 + } 250 + 251 + // Deprecated: HandleIdentityEventPublic is superseded by HandleEvent which routes 252 + // all event kinds. Use HandleEvent for new code; this remains for existing tests. 221 253 func (c *UserEventConsumer) HandleIdentityEventPublic(ctx context.Context, event *JetstreamEvent) error { 222 254 return c.handleIdentityEvent(ctx, event) 223 255 } ··· 316 348 return nil 317 349 } 318 350 319 - // handleCommitEvent processes commit events for user profile updates 320 - // Only handles social.coves.actor.profile collection for users already in our database. 321 - // This syncs profile data (displayName, bio, avatar, banner) from Coves profiles. 351 + // handleCommitEvent processes commit events for user-related collections. 352 + // Routes to appropriate handler based on collection: 353 + // - social.coves.actor.profile: Profile updates for users in our database 354 + // - social.coves.actor.block: User-to-user block create/delete events 322 355 func (c *UserEventConsumer) handleCommitEvent(ctx context.Context, event *JetstreamEvent) error { 323 356 if event.Commit == nil { 324 357 slog.Warn("received nil commit in handleCommitEvent (malformed event)", slog.String("did", event.Did)) 325 358 return nil 326 359 } 327 360 328 - // Only handle social.coves.actor.profile collection 329 - if event.Commit.Collection != CovesProfileCollection { 361 + switch event.Commit.Collection { 362 + case CovesProfileCollection: 363 + return c.handleProfileCommit(ctx, event) 364 + case CovesActorBlockCollection: 365 + return c.handleUserBlock(ctx, event.Did, event.Commit) 366 + default: 367 + return nil 368 + } 369 + } 370 + 371 + // handleProfileCommit processes profile commit events for users already in our database. 372 + // This syncs profile data (displayName, bio, avatar, banner) from Coves profiles. 373 + func (c *UserEventConsumer) handleProfileCommit(ctx context.Context, event *JetstreamEvent) error { 374 + // Profile handling requires userService 375 + if c.userService == nil { 330 376 return nil 331 377 } 332 378 ··· 414 460 log.Printf("Cleared profile for user %s", did) 415 461 return nil 416 462 } 463 + 464 + // handleUserBlock processes user-to-user block create/delete events. 465 + // CREATE operation = user blocked another user 466 + // DELETE operation = user unblocked another user 467 + func (c *UserEventConsumer) handleUserBlock(ctx context.Context, userDID string, commit *CommitEvent) error { 468 + if c.userBlockRepo == nil { 469 + slog.Warn("user block event ignored: userBlockRepo not configured (WithUserBlockRepo not called)", 470 + slog.String("user_did", userDID), 471 + slog.String("operation", commit.Operation)) 472 + return nil 473 + } 474 + 475 + switch commit.Operation { 476 + case "create": 477 + return c.createUserBlock(ctx, userDID, commit) 478 + case "delete": 479 + return c.deleteUserBlock(ctx, userDID, commit) 480 + default: 481 + // Update operations shouldn't happen on blocks, but ignore gracefully 482 + log.Printf("Ignoring unexpected operation on user block: %s (userDID=%s, rkey=%s)", 483 + commit.Operation, userDID, commit.RKey) 484 + return nil 485 + } 486 + } 487 + 488 + // createUserBlock indexes a new user-to-user block from the firehose. 489 + func (c *UserEventConsumer) createUserBlock(ctx context.Context, userDID string, commit *CommitEvent) error { 490 + if commit.Record == nil { 491 + return fmt.Errorf("user block create event missing record data") 492 + } 493 + 494 + // Validate userDID format (untrusted firehose data) 495 + if !strings.HasPrefix(userDID, "did:") { 496 + return fmt.Errorf("invalid blocker DID format from firehose: %s", userDID) 497 + } 498 + 499 + // Extract blocked user DID from record's subject field 500 + blockedDID, ok := commit.Record["subject"].(string) 501 + if !ok { 502 + return fmt.Errorf("user block record missing subject field") 503 + } 504 + 505 + // Validate blockedDID format (untrusted firehose data) 506 + if !strings.HasPrefix(blockedDID, "did:") { 507 + return fmt.Errorf("invalid blocked DID format from firehose: %s", blockedDID) 508 + } 509 + 510 + // Validate rkey is non-empty before building AT-URI 511 + if commit.RKey == "" { 512 + return fmt.Errorf("user block create event missing rkey") 513 + } 514 + 515 + // Build AT-URI for the block record (lives in the blocker's repository) 516 + uri := fmt.Sprintf("at://%s/social.coves.actor.block/%s", userDID, commit.RKey) 517 + 518 + // Parse createdAt from record to preserve chronological ordering during replays 519 + block := &userblocks.UserBlock{ 520 + BlockerDID: userDID, 521 + BlockedDID: blockedDID, 522 + BlockedAt: utils.ParseCreatedAt(commit.Record), 523 + RecordURI: uri, 524 + RecordCID: commit.CID, 525 + } 526 + 527 + // Index the block (idempotent via ON CONFLICT DO UPDATE) 528 + _, err := c.userBlockRepo.BlockUser(ctx, block) 529 + if err != nil { 530 + if userblocks.IsConflict(err) { 531 + log.Printf("User block already indexed: %s -> %s", userDID, blockedDID) 532 + return nil 533 + } 534 + return fmt.Errorf("failed to index user block: %w", err) 535 + } 536 + 537 + log.Printf("Indexed user block: %s -> %s", userDID, blockedDID) 538 + return nil 539 + } 540 + 541 + // deleteUserBlock removes a user-to-user block from the index. 542 + // DELETE operations don't include record data, so we look up the block by its URI. 543 + func (c *UserEventConsumer) deleteUserBlock(ctx context.Context, userDID string, commit *CommitEvent) error { 544 + // Validate rkey is non-empty before building AT-URI 545 + if commit.RKey == "" { 546 + return fmt.Errorf("user block delete event missing rkey") 547 + } 548 + 549 + // Build AT-URI from the rkey 550 + uri := fmt.Sprintf("at://%s/social.coves.actor.block/%s", userDID, commit.RKey) 551 + 552 + // Look up the block to get the blocked DID 553 + block, err := c.userBlockRepo.GetBlockByURI(ctx, uri) 554 + if err != nil { 555 + if userblocks.IsNotFound(err) { 556 + // Already deleted - this is fine (idempotency) 557 + log.Printf("User block already deleted: %s", uri) 558 + return nil 559 + } 560 + return fmt.Errorf("failed to find user block for deletion: %w", err) 561 + } 562 + 563 + // Remove the block from the index 564 + err = c.userBlockRepo.UnblockUser(ctx, userDID, block.BlockedDID) 565 + if err != nil { 566 + if userblocks.IsNotFound(err) { 567 + log.Printf("User block already removed: %s -> %s", userDID, block.BlockedDID) 568 + return nil 569 + } 570 + return fmt.Errorf("failed to remove user block: %w", err) 571 + } 572 + 573 + log.Printf("Removed user block: %s -> %s", userDID, block.BlockedDID) 574 + return nil 575 + }
+27
internal/atproto/lexicon/social/coves/actor/block.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.actor.block", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record declaring a block relationship against another user. Blocks are public and one-directional: only the blocker's view is affected.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject"], 12 + "properties": { 13 + "subject": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "DID of the user being blocked" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "When the block was created" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+17
internal/core/comments/comment_service.go
··· 163 163 164 164 // 3. Fetch top-level comments with pagination 165 165 // Uses repository's hot rank sorting and cursor-based pagination 166 + var viewerDIDStr string 167 + if req.ViewerDID != nil { 168 + viewerDIDStr = *req.ViewerDID 169 + } 166 170 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank( 167 171 ctx, 168 172 req.PostURI, ··· 170 174 req.Timeframe, 171 175 req.Limit, 172 176 req.Cursor, 177 + viewerDIDStr, 173 178 ) 174 179 if err != nil { 175 180 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err) ··· 284 289 285 290 // Batch load all replies for this level in a single query 286 291 if len(parentsWithReplies) > 0 { 292 + var batchViewerDID string 293 + if viewerDID != nil { 294 + batchViewerDID = *viewerDID 295 + } 287 296 repliesByParent, err := s.commentRepo.ListByParentsBatch( 288 297 ctx, 289 298 parentsWithReplies, 290 299 sort, 291 300 DefaultRepliesPerParent, 301 + batchViewerDID, 292 302 ) 303 + 304 + if err != nil { 305 + slog.Error("failed to batch load replies (nested replies will be missing)", 306 + "error", err, 307 + "parent_count", len(parentsWithReplies), 308 + "sort", sort) 309 + } 293 310 294 311 // Process replies if batch query succeeded 295 312 if err == nil {
+2
internal/core/comments/comment_service_test.go
··· 111 111 timeframe string, 112 112 limit int, 113 113 cursor *string, 114 + viewerDID string, 114 115 ) ([]*Comment, *string, error) { 115 116 if m.listByParentWithHotRankFunc != nil { 116 117 return m.listByParentWithHotRankFunc(ctx, parentURI, sort, timeframe, limit, cursor) ··· 140 141 parentURIs []string, 141 142 sort string, 142 143 limitPerParent int, 144 + viewerDID string, 143 145 ) (map[string][]*Comment, error) { 144 146 if m.listByParentsBatchFunc != nil { 145 147 return m.listByParentsBatchFunc(ctx, parentURIs, sort, limitPerParent)
+4
internal/core/comments/interfaces.go
··· 61 61 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 62 62 // Supports hot, top, and new sorting with cursor-based pagination 63 63 // Returns comments with author info hydrated and next page cursor 64 + // viewerDID is optional — when non-empty, comments from blocked users are filtered out 64 65 ListByParentWithHotRank( 65 66 ctx context.Context, 66 67 parentURI string, ··· 68 69 timeframe string, // "hour", "day", "week", "month", "year", "all" (for "top" only) 69 70 limit int, 70 71 cursor *string, 72 + viewerDID string, 71 73 ) ([]*Comment, *string, error) 72 74 73 75 // GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query ··· 84 86 // Returns map[parentURI][]*Comment grouped by parent 85 87 // Used to prevent N+1 queries when loading nested replies 86 88 // Limits results per parent to avoid memory exhaustion 89 + // viewerDID is optional — when non-empty, comments from blocked users are filtered out 87 90 ListByParentsBatch( 88 91 ctx context.Context, 89 92 parentURIs []string, 90 93 sort string, 91 94 limitPerParent int, 95 + viewerDID string, 92 96 ) (map[string][]*Comment, error) 93 97 } 94 98
+1
internal/core/communityFeeds/types.go
··· 11 11 type GetCommunityFeedRequest struct { 12 12 Cursor *string `json:"cursor,omitempty"` 13 13 Community string `json:"community"` 14 + ViewerDID string `json:"-"` // Optional: authenticated viewer's DID for block filtering 14 15 Sort string `json:"sort"` 15 16 Timeframe string `json:"timeframe"` 16 17 Limit int `json:"limit"`
+1
internal/core/discover/types.go
··· 20 20 // Matches social.coves.feed.getDiscover lexicon input 21 21 type GetDiscoverRequest struct { 22 22 Cursor *string `json:"cursor,omitempty"` 23 + ViewerDID string `json:"-"` // Optional: authenticated viewer's DID for block filtering 23 24 Sort string `json:"sort"` 24 25 Timeframe string `json:"timeframe"` 25 26 Limit int `json:"limit"`
+26
internal/core/userblocks/errors.go
··· 1 + package userblocks 2 + 3 + import "errors" 4 + 5 + // Sentinel errors for user block operations 6 + var ( 7 + // ErrBlockNotFound is returned when a block lookup finds no matching record 8 + ErrBlockNotFound = errors.New("user block not found") 9 + 10 + // ErrBlockAlreadyExists is returned when attempting to create a duplicate block 11 + ErrBlockAlreadyExists = errors.New("user already blocked") 12 + 13 + // ErrCannotBlockSelf is returned when a user attempts to block themselves 14 + ErrCannotBlockSelf = errors.New("cannot block yourself") 15 + ) 16 + 17 + // IsNotFound returns true if the error is ErrBlockNotFound 18 + func IsNotFound(err error) bool { 19 + return errors.Is(err, ErrBlockNotFound) 20 + } 21 + 22 + // IsConflict returns true if the error is ErrBlockAlreadyExists 23 + func IsConflict(err error) bool { 24 + return errors.Is(err, ErrBlockAlreadyExists) 25 + } 26 +
+69
internal/core/userblocks/interfaces.go
··· 1 + package userblocks 2 + 3 + import ( 4 + "Coves/internal/atproto/pds" 5 + "context" 6 + 7 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 8 + ) 9 + 10 + // PDSClientFactory creates PDS clients from session data. 11 + // This is primarily used for test injection via NewServiceWithPDSFactory to allow 12 + // password-based auth in integration tests instead of OAuth DPoP. 13 + type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) 14 + 15 + // Repository defines the interface for user block data persistence. 16 + // This is the AppView's indexed view of blocks from the firehose. 17 + type Repository interface { 18 + // BlockUser creates a new block record (idempotent via ON CONFLICT DO UPDATE) 19 + BlockUser(ctx context.Context, block *UserBlock) (*UserBlock, error) 20 + 21 + // UnblockUser removes a block record. Returns ErrBlockNotFound if not exists. 22 + UnblockUser(ctx context.Context, blockerDID, blockedDID string) error 23 + 24 + // GetBlock retrieves a block by blocker + blocked DID pair. 25 + // Returns ErrBlockNotFound if not exists. 26 + GetBlock(ctx context.Context, blockerDID, blockedDID string) (*UserBlock, error) 27 + 28 + // GetBlockByURI retrieves a block by its AT-URI. 29 + // Used by Jetstream consumer for DELETE operations (which don't include record data). 30 + // Returns ErrBlockNotFound if not exists. 31 + GetBlockByURI(ctx context.Context, recordURI string) (*UserBlock, error) 32 + 33 + // ListBlockedUsers retrieves all users blocked by the given blocker, paginated. 34 + // Results are ordered by blocked_at DESC. 35 + ListBlockedUsers(ctx context.Context, blockerDID string, limit, offset int) ([]*UserBlock, error) 36 + 37 + // IsBlocked checks if blockerDID has blocked blockedDID (fast EXISTS check). 38 + IsBlocked(ctx context.Context, blockerDID, blockedDID string) (bool, error) 39 + 40 + // AreBlocked checks which of the given DIDs are blocked by blockerDID. 41 + // Returns a map of blockedDID → true for each DID that is blocked. 42 + // Used for batch hydration of viewer state on lists of posts/comments. 43 + AreBlocked(ctx context.Context, blockerDID string, blockedDIDs []string) (map[string]bool, error) 44 + } 45 + 46 + // Service defines the interface for user block business logic. 47 + // Coordinates between Repository and external services (PDS, identity, etc.) 48 + // Uses write-forward pattern: Service → PDS → Firehose → Consumer → Repository 49 + type Service interface { 50 + // BlockUser creates a block against another user. 51 + // Uses OAuth session for DPoP-authenticated PDS write-forward. 52 + // The identifier can be a DID or handle. 53 + // Returns a BlockResult with the record URI and CID from PDS. 54 + // The block is indexed asynchronously via the firehose consumer. 55 + // Returns ErrCannotBlockSelf if the user attempts to block themselves. 56 + // Returns ErrBlockAlreadyExists if the block already exists on PDS. 57 + BlockUser(ctx context.Context, session *oauth.ClientSessionData, identifier string) (*BlockResult, error) 58 + 59 + // UnblockUser removes a block against another user. 60 + // Uses OAuth session for DPoP-authenticated PDS delete. 61 + // The identifier can be a DID or handle. 62 + UnblockUser(ctx context.Context, session *oauth.ClientSessionData, identifier string) error 63 + 64 + // GetBlockedUsers retrieves all users blocked by the given user, paginated. 65 + GetBlockedUsers(ctx context.Context, blockerDID string, limit, offset int) ([]*UserBlock, error) 66 + 67 + // IsBlocked checks if blockerDID has blocked blockedDID. 68 + IsBlocked(ctx context.Context, blockerDID, blockedDID string) (bool, error) 69 + }
+269
internal/core/userblocks/service.go
··· 1 + package userblocks 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "strings" 9 + "time" 10 + 11 + oauthclient "Coves/internal/atproto/oauth" 12 + "Coves/internal/atproto/pds" 13 + "Coves/internal/atproto/utils" 14 + 15 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + ) 18 + 19 + const ( 20 + // blockCollection is the AT Protocol collection for user block records 21 + blockCollection = "social.coves.actor.block" 22 + ) 23 + 24 + // HandleResolver resolves AT Protocol handles to DIDs. 25 + // This is a minimal interface satisfied by users.UserService. 26 + type HandleResolver interface { 27 + ResolveHandleToDID(ctx context.Context, handle string) (string, error) 28 + } 29 + 30 + type userBlockService struct { 31 + repo Repository 32 + handleResolver HandleResolver 33 + oauthClient *oauthclient.OAuthClient 34 + oauthStore oauth.ClientAuthStore 35 + logger *slog.Logger 36 + pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth. 37 + } 38 + 39 + // NewService creates a new user block service with OAuth client for production use. 40 + func NewService( 41 + repo Repository, 42 + handleResolver HandleResolver, 43 + oauthClient *oauthclient.OAuthClient, 44 + oauthStore oauth.ClientAuthStore, 45 + logger *slog.Logger, 46 + ) Service { 47 + if logger == nil { 48 + logger = slog.Default() 49 + } 50 + return &userBlockService{ 51 + repo: repo, 52 + handleResolver: handleResolver, 53 + oauthClient: oauthClient, 54 + oauthStore: oauthStore, 55 + logger: logger, 56 + } 57 + } 58 + 59 + // NewServiceWithPDSFactory creates a user block service with a custom PDS client factory. 60 + // This is primarily for testing with password-based authentication. 61 + func NewServiceWithPDSFactory( 62 + repo Repository, 63 + handleResolver HandleResolver, 64 + factory PDSClientFactory, 65 + ) Service { 66 + return &userBlockService{ 67 + repo: repo, 68 + handleResolver: handleResolver, 69 + pdsClientFactory: factory, 70 + logger: slog.Default(), 71 + } 72 + } 73 + 74 + // BlockUser creates a block against another user via write-forward to PDS. 75 + // The identifier can be a DID (starts with "did:") or a handle. 76 + // Returns a BlockResult with the record URI and CID from PDS. 77 + // The block will be indexed asynchronously via the firehose consumer. 78 + func (s *userBlockService) BlockUser(ctx context.Context, session *oauth.ClientSessionData, identifier string) (*BlockResult, error) { 79 + if session == nil { 80 + return nil, fmt.Errorf("session is required") 81 + } 82 + 83 + blockerDID := session.AccountDID.String() 84 + 85 + // Validate and normalize identifier 86 + identifier = strings.TrimSpace(identifier) 87 + if identifier == "" { 88 + return nil, fmt.Errorf("block: identifier is required") 89 + } 90 + 91 + // Resolve identifier to DID 92 + blockedDID, err := s.resolveIdentifier(ctx, identifier) 93 + if err != nil { 94 + return nil, fmt.Errorf("block: %w", err) 95 + } 96 + 97 + // Prevent self-blocking 98 + if blockerDID == blockedDID { 99 + s.logger.Warn("self-block attempt", 100 + "blockerDID", blockerDID) 101 + return nil, ErrCannotBlockSelf 102 + } 103 + 104 + // Get PDS client for this session 105 + pdsClient, err := s.getPDSClient(ctx, session) 106 + if err != nil { 107 + return nil, err 108 + } 109 + 110 + // Generate TID for record key 111 + tid := syntax.NewTIDNow(0) 112 + 113 + // Build block record following atProto conventions 114 + blockRecord := map[string]interface{}{ 115 + "$type": blockCollection, 116 + "subject": blockedDID, 117 + "createdAt": time.Now().Format(time.RFC3339), 118 + } 119 + 120 + // Write-forward: create block record in user's repo 121 + recordURI, recordCID, err := pdsClient.CreateRecord(ctx, blockCollection, tid.String(), blockRecord) 122 + if err != nil { 123 + // Check for auth errors 124 + if pds.IsAuthError(err) { 125 + s.logger.Warn("block auth failure", 126 + "blockerDID", blockerDID, 127 + "blockedDID", blockedDID, 128 + "error", err) 129 + return nil, fmt.Errorf("unauthorized: %w", err) 130 + } 131 + 132 + // Check for duplicate/conflict error from PDS 133 + if pds.IsConflictError(err) { 134 + existingBlock, getErr := s.repo.GetBlock(ctx, blockerDID, blockedDID) 135 + if getErr == nil { 136 + return &BlockResult{ 137 + RecordURI: existingBlock.RecordURI, 138 + RecordCID: existingBlock.RecordCID, 139 + }, nil 140 + } 141 + if errors.Is(getErr, ErrBlockNotFound) { 142 + return nil, ErrBlockAlreadyExists 143 + } 144 + return nil, fmt.Errorf("PDS reported duplicate block but failed to fetch from index: %w", getErr) 145 + } 146 + return nil, fmt.Errorf("failed to create block on PDS: %w", err) 147 + } 148 + 149 + s.logger.Info("block created", 150 + "blockerDID", blockerDID, 151 + "blockedDID", blockedDID, 152 + "recordURI", recordURI) 153 + 154 + return &BlockResult{ 155 + RecordURI: recordURI, 156 + RecordCID: recordCID, 157 + }, nil 158 + } 159 + 160 + // UnblockUser removes a block against another user via PDS delete. 161 + func (s *userBlockService) UnblockUser(ctx context.Context, session *oauth.ClientSessionData, identifier string) error { 162 + if session == nil { 163 + return fmt.Errorf("session is required") 164 + } 165 + 166 + blockerDID := session.AccountDID.String() 167 + 168 + // Validate and normalize identifier 169 + identifier = strings.TrimSpace(identifier) 170 + if identifier == "" { 171 + return fmt.Errorf("unblock: identifier is required") 172 + } 173 + 174 + // Resolve identifier to DID 175 + blockedDID, err := s.resolveIdentifier(ctx, identifier) 176 + if err != nil { 177 + return fmt.Errorf("unblock: %w", err) 178 + } 179 + 180 + // Get the block from AppView to find the record key 181 + block, err := s.repo.GetBlock(ctx, blockerDID, blockedDID) 182 + if err != nil { 183 + return err 184 + } 185 + 186 + // Extract rkey from record URI (at://did/collection/rkey) 187 + rkey := utils.ExtractRKeyFromURI(block.RecordURI) 188 + if rkey == "" { 189 + return fmt.Errorf("invalid block record URI") 190 + } 191 + 192 + // Get PDS client for this session 193 + pdsClient, err := s.getPDSClient(ctx, session) 194 + if err != nil { 195 + return fmt.Errorf("failed to create PDS client: %w", err) 196 + } 197 + 198 + // Write-forward: delete record from PDS 199 + if err := pdsClient.DeleteRecord(ctx, blockCollection, rkey); err != nil { 200 + if pds.IsAuthError(err) { 201 + s.logger.Warn("unblock auth failure", 202 + "blockerDID", blockerDID, 203 + "blockedDID", blockedDID, 204 + "error", err) 205 + return fmt.Errorf("unauthorized: %w", err) 206 + } 207 + return fmt.Errorf("failed to delete block on PDS: %w", err) 208 + } 209 + 210 + s.logger.Info("block deleted", 211 + "blockerDID", blockerDID, 212 + "blockedDID", blockedDID) 213 + 214 + return nil 215 + } 216 + 217 + // GetBlockedUsers retrieves all users blocked by the given user, paginated. 218 + func (s *userBlockService) GetBlockedUsers(ctx context.Context, blockerDID string, limit, offset int) ([]*UserBlock, error) { 219 + if limit <= 0 { 220 + limit = 50 221 + } 222 + if limit > 100 { 223 + limit = 100 224 + } 225 + if offset < 0 { 226 + offset = 0 227 + } 228 + 229 + return s.repo.ListBlockedUsers(ctx, blockerDID, limit, offset) 230 + } 231 + 232 + // IsBlocked checks if blockerDID has blocked blockedDID. 233 + func (s *userBlockService) IsBlocked(ctx context.Context, blockerDID, blockedDID string) (bool, error) { 234 + return s.repo.IsBlocked(ctx, blockerDID, blockedDID) 235 + } 236 + 237 + // resolveIdentifier resolves an identifier to a DID. 238 + // If the identifier starts with "did:", it is validated as a proper DID. 239 + // Otherwise, it is treated as a handle and resolved via the handle resolver. 240 + func (s *userBlockService) resolveIdentifier(ctx context.Context, identifier string) (string, error) { 241 + if strings.HasPrefix(identifier, "did:") { 242 + // Validate DID using the syntax package 243 + if _, err := syntax.ParseDID(identifier); err != nil { 244 + return "", fmt.Errorf("invalid DID %q: %w", identifier, err) 245 + } 246 + return identifier, nil 247 + } 248 + return s.handleResolver.ResolveHandleToDID(ctx, identifier) 249 + } 250 + 251 + // getPDSClient creates a PDS client from an OAuth session. 252 + // If a custom factory was provided (for testing), uses that. 253 + // Otherwise, uses OAuth with DPoP authentication. 254 + func (s *userBlockService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 255 + if s.pdsClientFactory != nil { 256 + return s.pdsClientFactory(ctx, session) 257 + } 258 + 259 + if s.oauthClient == nil || s.oauthClient.ClientApp == nil { 260 + return nil, fmt.Errorf("OAuth client not configured") 261 + } 262 + 263 + client, err := pds.NewFromOAuthSession(ctx, s.oauthClient.ClientApp, session) 264 + if err != nil { 265 + return nil, fmt.Errorf("failed to create PDS client: %w", err) 266 + } 267 + 268 + return client, nil 269 + }
+897
internal/core/userblocks/service_test.go
··· 1 + package userblocks 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "Coves/internal/atproto/pds" 13 + "Coves/internal/core/blobs" 14 + ) 15 + 16 + // --- Mock Repository --- 17 + 18 + type mockRepo struct { 19 + blockUserFn func(ctx context.Context, block *UserBlock) (*UserBlock, error) 20 + unblockUserFn func(ctx context.Context, blockerDID, blockedDID string) error 21 + getBlockFn func(ctx context.Context, blockerDID, blockedDID string) (*UserBlock, error) 22 + getBlockByURIFn func(ctx context.Context, recordURI string) (*UserBlock, error) 23 + listBlockedFn func(ctx context.Context, blockerDID string, limit, offset int) ([]*UserBlock, error) 24 + isBlockedFn func(ctx context.Context, blockerDID, blockedDID string) (bool, error) 25 + areBlockedFn func(ctx context.Context, blockerDID string, blockedDIDs []string) (map[string]bool, error) 26 + } 27 + 28 + func (m *mockRepo) BlockUser(ctx context.Context, block *UserBlock) (*UserBlock, error) { 29 + if m.blockUserFn != nil { 30 + return m.blockUserFn(ctx, block) 31 + } 32 + return block, nil 33 + } 34 + 35 + func (m *mockRepo) UnblockUser(ctx context.Context, blockerDID, blockedDID string) error { 36 + if m.unblockUserFn != nil { 37 + return m.unblockUserFn(ctx, blockerDID, blockedDID) 38 + } 39 + return nil 40 + } 41 + 42 + func (m *mockRepo) GetBlock(ctx context.Context, blockerDID, blockedDID string) (*UserBlock, error) { 43 + if m.getBlockFn != nil { 44 + return m.getBlockFn(ctx, blockerDID, blockedDID) 45 + } 46 + return nil, ErrBlockNotFound 47 + } 48 + 49 + func (m *mockRepo) GetBlockByURI(ctx context.Context, recordURI string) (*UserBlock, error) { 50 + if m.getBlockByURIFn != nil { 51 + return m.getBlockByURIFn(ctx, recordURI) 52 + } 53 + return nil, ErrBlockNotFound 54 + } 55 + 56 + func (m *mockRepo) ListBlockedUsers(ctx context.Context, blockerDID string, limit, offset int) ([]*UserBlock, error) { 57 + if m.listBlockedFn != nil { 58 + return m.listBlockedFn(ctx, blockerDID, limit, offset) 59 + } 60 + return nil, nil 61 + } 62 + 63 + func (m *mockRepo) IsBlocked(ctx context.Context, blockerDID, blockedDID string) (bool, error) { 64 + if m.isBlockedFn != nil { 65 + return m.isBlockedFn(ctx, blockerDID, blockedDID) 66 + } 67 + return false, nil 68 + } 69 + 70 + func (m *mockRepo) AreBlocked(ctx context.Context, blockerDID string, blockedDIDs []string) (map[string]bool, error) { 71 + if m.areBlockedFn != nil { 72 + return m.areBlockedFn(ctx, blockerDID, blockedDIDs) 73 + } 74 + return make(map[string]bool), nil 75 + } 76 + 77 + // --- Mock Handle Resolver --- 78 + 79 + type mockHandleResolver struct { 80 + resolveHandleToDIDFn func(ctx context.Context, handle string) (string, error) 81 + } 82 + 83 + func (m *mockHandleResolver) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 84 + if m.resolveHandleToDIDFn != nil { 85 + return m.resolveHandleToDIDFn(ctx, handle) 86 + } 87 + return "", errors.New("not implemented") 88 + } 89 + 90 + // --- Mock PDS Client --- 91 + 92 + type mockPDSClient struct { 93 + createRecordFn func(ctx context.Context, collection, rkey string, record any) (string, string, error) 94 + deleteRecordFn func(ctx context.Context, collection, rkey string) error 95 + } 96 + 97 + func (m *mockPDSClient) CreateRecord(ctx context.Context, collection, rkey string, record any) (string, string, error) { 98 + if m.createRecordFn != nil { 99 + return m.createRecordFn(ctx, collection, rkey, record) 100 + } 101 + return "at://did:plc:test/social.coves.actor.block/" + rkey, "bafyreicid", nil 102 + } 103 + 104 + func (m *mockPDSClient) DeleteRecord(ctx context.Context, collection, rkey string) error { 105 + if m.deleteRecordFn != nil { 106 + return m.deleteRecordFn(ctx, collection, rkey) 107 + } 108 + return nil 109 + } 110 + 111 + func (m *mockPDSClient) GetRecord(ctx context.Context, collection, rkey string) (*pds.RecordResponse, error) { 112 + return nil, nil 113 + } 114 + 115 + func (m *mockPDSClient) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*pds.ListRecordsResponse, error) { 116 + return nil, nil 117 + } 118 + 119 + func (m *mockPDSClient) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) { 120 + return "", "", nil 121 + } 122 + 123 + func (m *mockPDSClient) UploadBlob(ctx context.Context, data []byte, mimeType string) (*blobs.BlobRef, error) { 124 + return nil, nil 125 + } 126 + 127 + func (m *mockPDSClient) DID() string { 128 + return "did:plc:mock" 129 + } 130 + 131 + func (m *mockPDSClient) HostURL() string { 132 + return "http://localhost:3001" 133 + } 134 + 135 + // --- Helper to create a test session --- 136 + 137 + func testSession(did string) *oauth.ClientSessionData { 138 + parsedDID, _ := syntax.ParseDID(did) 139 + return &oauth.ClientSessionData{ 140 + AccountDID: parsedDID, 141 + AccessToken: "test-token", 142 + HostURL: "http://localhost:3001", 143 + } 144 + } 145 + 146 + // --- Tests --- 147 + 148 + func TestBlockUser_PreventsSelfBlock(t *testing.T) { 149 + repo := &mockRepo{} 150 + userSvc := &mockHandleResolver{} 151 + pdsClient := &mockPDSClient{} 152 + 153 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 154 + return pdsClient, nil 155 + } 156 + 157 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 158 + 159 + selfDID := "did:plc:selfuser123" 160 + session := testSession(selfDID) 161 + 162 + // Blocking yourself should return ErrCannotBlockSelf 163 + _, err := svc.BlockUser(context.Background(), session, selfDID) 164 + if err == nil { 165 + t.Fatal("expected error when blocking self, got nil") 166 + } 167 + if !errors.Is(err, ErrCannotBlockSelf) { 168 + t.Fatalf("expected ErrCannotBlockSelf, got: %v", err) 169 + } 170 + } 171 + 172 + func TestBlockUser_ResolvesHandleToDID(t *testing.T) { 173 + resolvedDID := "did:plc:bobresolved" 174 + var createCalledWithCollection string 175 + var createCalledWithSubject string 176 + 177 + repo := &mockRepo{} 178 + userSvc := &mockHandleResolver{ 179 + resolveHandleToDIDFn: func(ctx context.Context, handle string) (string, error) { 180 + if handle != "bob.bsky.social" { 181 + t.Fatalf("expected handle bob.bsky.social, got %s", handle) 182 + } 183 + return resolvedDID, nil 184 + }, 185 + } 186 + pdsClient := &mockPDSClient{ 187 + createRecordFn: func(ctx context.Context, collection, rkey string, record interface{}) (string, string, error) { 188 + createCalledWithCollection = collection 189 + // Extract subject from record map 190 + if rec, ok := record.(map[string]interface{}); ok { 191 + if sub, ok := rec["subject"].(string); ok { 192 + createCalledWithSubject = sub 193 + } 194 + } 195 + uri := "at://did:plc:alice123/social.coves.actor.block/" + rkey 196 + return uri, "bafyreicid123", nil 197 + }, 198 + } 199 + 200 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 201 + return pdsClient, nil 202 + } 203 + 204 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 205 + 206 + session := testSession("did:plc:alice123") 207 + 208 + result, err := svc.BlockUser(context.Background(), session, "bob.bsky.social") 209 + if err != nil { 210 + t.Fatalf("unexpected error: %v", err) 211 + } 212 + 213 + if createCalledWithSubject != resolvedDID { 214 + t.Fatalf("expected PDS record subject %s, got %s", resolvedDID, createCalledWithSubject) 215 + } 216 + if createCalledWithCollection != "social.coves.actor.block" { 217 + t.Fatalf("expected collection social.coves.actor.block, got %s", createCalledWithCollection) 218 + } 219 + if result.RecordURI == "" { 220 + t.Fatal("expected non-empty record URI") 221 + } 222 + if result.RecordCID == "" { 223 + t.Fatal("expected non-empty record CID") 224 + } 225 + } 226 + 227 + func TestBlockUser_AcceptsDIDDirectly(t *testing.T) { 228 + targetDID := "did:plc:target456" 229 + resolveHandleCalled := false 230 + 231 + repo := &mockRepo{} 232 + userSvc := &mockHandleResolver{ 233 + resolveHandleToDIDFn: func(ctx context.Context, handle string) (string, error) { 234 + resolveHandleCalled = true 235 + return "", errors.New("should not be called") 236 + }, 237 + } 238 + pdsClient := &mockPDSClient{} 239 + 240 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 241 + return pdsClient, nil 242 + } 243 + 244 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 245 + 246 + session := testSession("did:plc:alice123") 247 + 248 + result, err := svc.BlockUser(context.Background(), session, targetDID) 249 + if err != nil { 250 + t.Fatalf("unexpected error: %v", err) 251 + } 252 + 253 + if resolveHandleCalled { 254 + t.Fatal("ResolveHandleToDID should not be called for DID identifiers") 255 + } 256 + if result.RecordURI == "" { 257 + t.Fatal("expected non-empty record URI") 258 + } 259 + } 260 + 261 + func TestBlockUser_NilSession(t *testing.T) { 262 + repo := &mockRepo{} 263 + userSvc := &mockHandleResolver{} 264 + 265 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 266 + return &mockPDSClient{}, nil 267 + } 268 + 269 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 270 + 271 + _, err := svc.BlockUser(context.Background(), nil, "did:plc:target") 272 + if err == nil { 273 + t.Fatal("expected error for nil session, got nil") 274 + } 275 + } 276 + 277 + func TestGetBlockedUsers_DefaultPagination(t *testing.T) { 278 + tests := []struct { 279 + name string 280 + inputLimit int 281 + expectedLimit int 282 + }{ 283 + {"zero limit defaults to 50", 0, 50}, 284 + {"negative limit defaults to 50", -1, 50}, 285 + {"over 100 capped to 100", 200, 100}, 286 + {"valid limit passes through", 25, 25}, 287 + {"exactly 100 passes through", 100, 100}, 288 + } 289 + 290 + for _, tt := range tests { 291 + t.Run(tt.name, func(t *testing.T) { 292 + var capturedLimit int 293 + 294 + repo := &mockRepo{ 295 + listBlockedFn: func(ctx context.Context, blockerDID string, limit, offset int) ([]*UserBlock, error) { 296 + capturedLimit = limit 297 + return nil, nil 298 + }, 299 + } 300 + userSvc := &mockHandleResolver{} 301 + 302 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 303 + return &mockPDSClient{}, nil 304 + } 305 + 306 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 307 + 308 + _, err := svc.GetBlockedUsers(context.Background(), "did:plc:user123", tt.inputLimit, 0) 309 + if err != nil { 310 + t.Fatalf("unexpected error: %v", err) 311 + } 312 + 313 + if capturedLimit != tt.expectedLimit { 314 + t.Fatalf("expected limit %d, got %d", tt.expectedLimit, capturedLimit) 315 + } 316 + }) 317 + } 318 + } 319 + 320 + func TestIsBlocked_DelegatesToRepo(t *testing.T) { 321 + blockerDID := "did:plc:alice" 322 + blockedDID := "did:plc:bob" 323 + isBlockedCalled := false 324 + 325 + repo := &mockRepo{ 326 + isBlockedFn: func(ctx context.Context, blocker, blocked string) (bool, error) { 327 + isBlockedCalled = true 328 + if blocker != blockerDID { 329 + t.Fatalf("expected blocker %s, got %s", blockerDID, blocker) 330 + } 331 + if blocked != blockedDID { 332 + t.Fatalf("expected blocked %s, got %s", blockedDID, blocked) 333 + } 334 + return true, nil 335 + }, 336 + } 337 + userSvc := &mockHandleResolver{} 338 + 339 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 340 + return &mockPDSClient{}, nil 341 + } 342 + 343 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 344 + 345 + blocked, err := svc.IsBlocked(context.Background(), blockerDID, blockedDID) 346 + if err != nil { 347 + t.Fatalf("unexpected error: %v", err) 348 + } 349 + 350 + if !isBlockedCalled { 351 + t.Fatal("expected repo.IsBlocked to be called") 352 + } 353 + if !blocked { 354 + t.Fatal("expected blocked=true") 355 + } 356 + } 357 + 358 + func TestUnblockUser_ExtractsRKeyAndDeletesFromPDS(t *testing.T) { 359 + blockerDID := "did:plc:alice" 360 + blockedDID := "did:plc:bob" 361 + expectedRKey := "3lawvb5hii22f" 362 + recordURI := "at://" + blockerDID + "/social.coves.actor.block/" + expectedRKey 363 + 364 + var deletedCollection, deletedRKey string 365 + 366 + repo := &mockRepo{ 367 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 368 + return &UserBlock{ 369 + BlockerDID: blocker, 370 + BlockedDID: blocked, 371 + RecordURI: recordURI, 372 + RecordCID: "bafyreicid", 373 + }, nil 374 + }, 375 + } 376 + userSvc := &mockHandleResolver{} 377 + pdsClient := &mockPDSClient{ 378 + deleteRecordFn: func(ctx context.Context, collection, rkey string) error { 379 + deletedCollection = collection 380 + deletedRKey = rkey 381 + return nil 382 + }, 383 + } 384 + 385 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 386 + return pdsClient, nil 387 + } 388 + 389 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 390 + 391 + session := testSession(blockerDID) 392 + 393 + err := svc.UnblockUser(context.Background(), session, blockedDID) 394 + if err != nil { 395 + t.Fatalf("unexpected error: %v", err) 396 + } 397 + 398 + if deletedCollection != "social.coves.actor.block" { 399 + t.Fatalf("expected collection social.coves.actor.block, got %s", deletedCollection) 400 + } 401 + if deletedRKey != expectedRKey { 402 + t.Fatalf("expected rkey %s, got %s", expectedRKey, deletedRKey) 403 + } 404 + } 405 + 406 + func TestBlockUser_EmptyIdentifier(t *testing.T) { 407 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 408 + return &mockPDSClient{}, nil 409 + }) 410 + 411 + session := testSession("did:plc:alice123") 412 + 413 + _, err := svc.BlockUser(context.Background(), session, "") 414 + if err == nil { 415 + t.Fatal("expected error for empty identifier, got nil") 416 + } 417 + if !strings.Contains(err.Error(), "identifier is required") { 418 + t.Fatalf("expected 'identifier is required' error, got: %v", err) 419 + } 420 + } 421 + 422 + func TestBlockUser_WhitespaceIdentifier(t *testing.T) { 423 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 424 + return &mockPDSClient{}, nil 425 + }) 426 + 427 + session := testSession("did:plc:alice123") 428 + 429 + _, err := svc.BlockUser(context.Background(), session, " ") 430 + if err == nil { 431 + t.Fatal("expected error for whitespace identifier, got nil") 432 + } 433 + if !strings.Contains(err.Error(), "identifier is required") { 434 + t.Fatalf("expected 'identifier is required' error, got: %v", err) 435 + } 436 + } 437 + 438 + func TestUnblockUser_EmptyIdentifier(t *testing.T) { 439 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 440 + return &mockPDSClient{}, nil 441 + }) 442 + 443 + session := testSession("did:plc:alice123") 444 + 445 + err := svc.UnblockUser(context.Background(), session, "") 446 + if err == nil { 447 + t.Fatal("expected error for empty identifier, got nil") 448 + } 449 + if !strings.Contains(err.Error(), "identifier is required") { 450 + t.Fatalf("expected 'identifier is required' error, got: %v", err) 451 + } 452 + } 453 + 454 + func TestBlockUser_InvalidDIDFormat(t *testing.T) { 455 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 456 + return &mockPDSClient{}, nil 457 + }) 458 + 459 + session := testSession("did:plc:alice123") 460 + 461 + // "did:" alone should fail DID validation 462 + _, err := svc.BlockUser(context.Background(), session, "did:") 463 + if err == nil { 464 + t.Fatal("expected error for bare 'did:' identifier, got nil") 465 + } 466 + 467 + // "did:garbage" should fail DID validation 468 + _, err = svc.BlockUser(context.Background(), session, "did:garbage") 469 + if err == nil { 470 + t.Fatal("expected error for malformed DID, got nil") 471 + } 472 + } 473 + 474 + func TestGetBlockedUsers_NegativeOffsetClamped(t *testing.T) { 475 + var capturedOffset int 476 + 477 + repo := &mockRepo{ 478 + listBlockedFn: func(ctx context.Context, blockerDID string, limit, offset int) ([]*UserBlock, error) { 479 + capturedOffset = offset 480 + return nil, nil 481 + }, 482 + } 483 + 484 + svc := NewServiceWithPDSFactory(repo, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 485 + return &mockPDSClient{}, nil 486 + }) 487 + 488 + _, err := svc.GetBlockedUsers(context.Background(), "did:plc:user123", 50, -5) 489 + if err != nil { 490 + t.Fatalf("unexpected error: %v", err) 491 + } 492 + 493 + if capturedOffset != 0 { 494 + t.Fatalf("expected offset clamped to 0, got %d", capturedOffset) 495 + } 496 + } 497 + 498 + func TestUnblockUser_BlockNotFound(t *testing.T) { 499 + repo := &mockRepo{ 500 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 501 + return nil, ErrBlockNotFound 502 + }, 503 + } 504 + userSvc := &mockHandleResolver{} 505 + 506 + factory := func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 507 + return &mockPDSClient{}, nil 508 + } 509 + 510 + svc := NewServiceWithPDSFactory(repo, userSvc, factory) 511 + 512 + session := testSession("did:plc:alice") 513 + 514 + err := svc.UnblockUser(context.Background(), session, "did:plc:bob") 515 + if err == nil { 516 + t.Fatal("expected error for non-existent block") 517 + } 518 + if !errors.Is(err, ErrBlockNotFound) { 519 + t.Fatalf("expected ErrBlockNotFound, got: %v", err) 520 + } 521 + } 522 + 523 + // --- Critical: PDS conflict/idempotency tests (service.go lines 133-145) --- 524 + 525 + func TestBlockUser_PDSConflict_RepoHasBlock(t *testing.T) { 526 + // When PDS returns conflict and the repo already has the block, 527 + // service should return the existing block result (no error). 528 + existingURI := "at://did:plc:alice/social.coves.actor.block/existing123" 529 + existingCID := "bafyexisting" 530 + 531 + repo := &mockRepo{ 532 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 533 + return &UserBlock{ 534 + BlockerDID: blocker, 535 + BlockedDID: blocked, 536 + RecordURI: existingURI, 537 + RecordCID: existingCID, 538 + }, nil 539 + }, 540 + } 541 + pdsClient := &mockPDSClient{ 542 + createRecordFn: func(ctx context.Context, collection, rkey string, record any) (string, string, error) { 543 + return "", "", pds.ErrConflict 544 + }, 545 + } 546 + 547 + svc := NewServiceWithPDSFactory(repo, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 548 + return pdsClient, nil 549 + }) 550 + 551 + session := testSession("did:plc:alice") 552 + result, err := svc.BlockUser(context.Background(), session, "did:plc:bob") 553 + if err != nil { 554 + t.Fatalf("expected no error for idempotent block, got: %v", err) 555 + } 556 + if result.RecordURI != existingURI { 557 + t.Errorf("expected RecordURI=%s, got %s", existingURI, result.RecordURI) 558 + } 559 + if result.RecordCID != existingCID { 560 + t.Errorf("expected RecordCID=%s, got %s", existingCID, result.RecordCID) 561 + } 562 + } 563 + 564 + func TestBlockUser_PDSConflict_RepoNotFound(t *testing.T) { 565 + // When PDS returns conflict but the repo doesn't have the block, 566 + // service should return ErrBlockAlreadyExists. 567 + repo := &mockRepo{ 568 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 569 + return nil, ErrBlockNotFound 570 + }, 571 + } 572 + pdsClient := &mockPDSClient{ 573 + createRecordFn: func(ctx context.Context, collection, rkey string, record any) (string, string, error) { 574 + return "", "", pds.ErrConflict 575 + }, 576 + } 577 + 578 + svc := NewServiceWithPDSFactory(repo, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 579 + return pdsClient, nil 580 + }) 581 + 582 + session := testSession("did:plc:alice") 583 + _, err := svc.BlockUser(context.Background(), session, "did:plc:bob") 584 + if err == nil { 585 + t.Fatal("expected error when PDS conflict and repo has no block") 586 + } 587 + if !errors.Is(err, ErrBlockAlreadyExists) { 588 + t.Fatalf("expected ErrBlockAlreadyExists, got: %v", err) 589 + } 590 + } 591 + 592 + func TestBlockUser_PDSConflict_RepoError(t *testing.T) { 593 + // When PDS returns conflict and the repo returns a non-NotFound error, 594 + // service should wrap and return that error. 595 + repoErr := errors.New("database connection lost") 596 + repo := &mockRepo{ 597 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 598 + return nil, repoErr 599 + }, 600 + } 601 + pdsClient := &mockPDSClient{ 602 + createRecordFn: func(ctx context.Context, collection, rkey string, record any) (string, string, error) { 603 + return "", "", pds.ErrConflict 604 + }, 605 + } 606 + 607 + svc := NewServiceWithPDSFactory(repo, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 608 + return pdsClient, nil 609 + }) 610 + 611 + session := testSession("did:plc:alice") 612 + _, err := svc.BlockUser(context.Background(), session, "did:plc:bob") 613 + if err == nil { 614 + t.Fatal("expected error when repo fails during conflict handling") 615 + } 616 + if !errors.Is(err, repoErr) { 617 + t.Fatalf("expected wrapped repo error, got: %v", err) 618 + } 619 + if !strings.Contains(err.Error(), "PDS reported duplicate block") { 620 + t.Fatalf("expected error message to mention PDS duplicate, got: %v", err) 621 + } 622 + } 623 + 624 + // --- Important: Auth error tests --- 625 + 626 + func TestBlockUser_PDSAuthError(t *testing.T) { 627 + pdsClient := &mockPDSClient{ 628 + createRecordFn: func(ctx context.Context, collection, rkey string, record any) (string, string, error) { 629 + return "", "", pds.ErrUnauthorized 630 + }, 631 + } 632 + 633 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 634 + return pdsClient, nil 635 + }) 636 + 637 + session := testSession("did:plc:alice") 638 + _, err := svc.BlockUser(context.Background(), session, "did:plc:bob") 639 + if err == nil { 640 + t.Fatal("expected error for auth failure") 641 + } 642 + if !strings.Contains(err.Error(), "unauthorized") { 643 + t.Fatalf("expected unauthorized error, got: %v", err) 644 + } 645 + if !errors.Is(err, pds.ErrUnauthorized) { 646 + t.Fatalf("expected wrapped ErrUnauthorized, got: %v", err) 647 + } 648 + } 649 + 650 + func TestUnblockUser_PDSAuthError(t *testing.T) { 651 + repo := &mockRepo{ 652 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 653 + return &UserBlock{ 654 + BlockerDID: blocker, 655 + BlockedDID: blocked, 656 + RecordURI: "at://did:plc:alice/social.coves.actor.block/rkey123", 657 + RecordCID: "bafycid", 658 + }, nil 659 + }, 660 + } 661 + pdsClient := &mockPDSClient{ 662 + deleteRecordFn: func(ctx context.Context, collection, rkey string) error { 663 + return pds.ErrUnauthorized 664 + }, 665 + } 666 + 667 + svc := NewServiceWithPDSFactory(repo, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 668 + return pdsClient, nil 669 + }) 670 + 671 + session := testSession("did:plc:alice") 672 + err := svc.UnblockUser(context.Background(), session, "did:plc:bob") 673 + if err == nil { 674 + t.Fatal("expected error for auth failure on unblock") 675 + } 676 + if !strings.Contains(err.Error(), "unauthorized") { 677 + t.Fatalf("expected unauthorized error, got: %v", err) 678 + } 679 + if !errors.Is(err, pds.ErrUnauthorized) { 680 + t.Fatalf("expected wrapped ErrUnauthorized, got: %v", err) 681 + } 682 + } 683 + 684 + // --- Important: UnblockUser nil session --- 685 + 686 + func TestUnblockUser_NilSession(t *testing.T) { 687 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 688 + return &mockPDSClient{}, nil 689 + }) 690 + 691 + err := svc.UnblockUser(context.Background(), nil, "did:plc:target") 692 + if err == nil { 693 + t.Fatal("expected error for nil session, got nil") 694 + } 695 + if !strings.Contains(err.Error(), "session is required") { 696 + t.Fatalf("expected 'session is required' error, got: %v", err) 697 + } 698 + } 699 + 700 + // --- Important: UnblockUser invalid record URI (empty rkey) --- 701 + 702 + func TestUnblockUser_InvalidRecordURI(t *testing.T) { 703 + // When the stored block has a malformed URI that yields an empty rkey, 704 + // service should return an error instead of sending an empty rkey to PDS. 705 + repo := &mockRepo{ 706 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 707 + return &UserBlock{ 708 + BlockerDID: blocker, 709 + BlockedDID: blocked, 710 + RecordURI: "at://bad", // too few segments → ExtractRKeyFromURI returns "" 711 + RecordCID: "bafycid", 712 + }, nil 713 + }, 714 + } 715 + 716 + svc := NewServiceWithPDSFactory(repo, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 717 + return &mockPDSClient{}, nil 718 + }) 719 + 720 + session := testSession("did:plc:alice") 721 + err := svc.UnblockUser(context.Background(), session, "did:plc:bob") 722 + if err == nil { 723 + t.Fatal("expected error for invalid record URI") 724 + } 725 + if !strings.Contains(err.Error(), "invalid block record URI") { 726 + t.Fatalf("expected 'invalid block record URI' error, got: %v", err) 727 + } 728 + } 729 + 730 + // --- Moderate: Handle resolution failure --- 731 + 732 + func TestBlockUser_HandleResolutionFailure(t *testing.T) { 733 + resolver := &mockHandleResolver{ 734 + resolveHandleToDIDFn: func(ctx context.Context, handle string) (string, error) { 735 + return "", errors.New("handle not found") 736 + }, 737 + } 738 + 739 + svc := NewServiceWithPDSFactory(&mockRepo{}, resolver, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 740 + return &mockPDSClient{}, nil 741 + }) 742 + 743 + session := testSession("did:plc:alice") 744 + _, err := svc.BlockUser(context.Background(), session, "nonexistent.bsky.social") 745 + if err == nil { 746 + t.Fatal("expected error for handle resolution failure") 747 + } 748 + if !strings.Contains(err.Error(), "handle not found") { 749 + t.Fatalf("expected handle resolution error, got: %v", err) 750 + } 751 + } 752 + 753 + // --- Moderate: PDS client factory error --- 754 + 755 + func TestBlockUser_PDSClientFactoryError(t *testing.T) { 756 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 757 + return nil, errors.New("failed to create PDS client") 758 + }) 759 + 760 + session := testSession("did:plc:alice") 761 + _, err := svc.BlockUser(context.Background(), session, "did:plc:bob") 762 + if err == nil { 763 + t.Fatal("expected error for PDS client factory failure") 764 + } 765 + if !strings.Contains(err.Error(), "failed to create PDS client") { 766 + t.Fatalf("expected PDS client factory error, got: %v", err) 767 + } 768 + } 769 + 770 + // --- Moderate: Generic PDS errors (non-auth, non-conflict) --- 771 + 772 + func TestBlockUser_GenericPDSError(t *testing.T) { 773 + pdsClient := &mockPDSClient{ 774 + createRecordFn: func(ctx context.Context, collection, rkey string, record any) (string, string, error) { 775 + return "", "", pds.ErrRateLimited 776 + }, 777 + } 778 + 779 + svc := NewServiceWithPDSFactory(&mockRepo{}, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 780 + return pdsClient, nil 781 + }) 782 + 783 + session := testSession("did:plc:alice") 784 + _, err := svc.BlockUser(context.Background(), session, "did:plc:bob") 785 + if err == nil { 786 + t.Fatal("expected error for generic PDS failure") 787 + } 788 + if !strings.Contains(err.Error(), "failed to create block on PDS") { 789 + t.Fatalf("expected 'failed to create block on PDS' error, got: %v", err) 790 + } 791 + if !errors.Is(err, pds.ErrRateLimited) { 792 + t.Fatalf("expected wrapped ErrRateLimited, got: %v", err) 793 + } 794 + } 795 + 796 + func TestUnblockUser_GenericPDSError(t *testing.T) { 797 + repo := &mockRepo{ 798 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 799 + return &UserBlock{ 800 + BlockerDID: blocker, 801 + BlockedDID: blocked, 802 + RecordURI: "at://did:plc:alice/social.coves.actor.block/rkey123", 803 + RecordCID: "bafycid", 804 + }, nil 805 + }, 806 + } 807 + pdsClient := &mockPDSClient{ 808 + deleteRecordFn: func(ctx context.Context, collection, rkey string) error { 809 + return pds.ErrRateLimited 810 + }, 811 + } 812 + 813 + svc := NewServiceWithPDSFactory(repo, &mockHandleResolver{}, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 814 + return pdsClient, nil 815 + }) 816 + 817 + session := testSession("did:plc:alice") 818 + err := svc.UnblockUser(context.Background(), session, "did:plc:bob") 819 + if err == nil { 820 + t.Fatal("expected error for generic PDS failure on unblock") 821 + } 822 + if !strings.Contains(err.Error(), "failed to delete block on PDS") { 823 + t.Fatalf("expected 'failed to delete block on PDS' error, got: %v", err) 824 + } 825 + if !errors.Is(err, pds.ErrRateLimited) { 826 + t.Fatalf("expected wrapped ErrRateLimited, got: %v", err) 827 + } 828 + } 829 + 830 + // --- Moderate: UnblockUser handle resolution --- 831 + 832 + func TestUnblockUser_ResolvesHandleToDID(t *testing.T) { 833 + resolvedDID := "did:plc:bobresolved" 834 + var deletedRKey string 835 + 836 + resolver := &mockHandleResolver{ 837 + resolveHandleToDIDFn: func(ctx context.Context, handle string) (string, error) { 838 + if handle != "bob.bsky.social" { 839 + t.Fatalf("expected handle bob.bsky.social, got %s", handle) 840 + } 841 + return resolvedDID, nil 842 + }, 843 + } 844 + repo := &mockRepo{ 845 + getBlockFn: func(ctx context.Context, blocker, blocked string) (*UserBlock, error) { 846 + if blocked != resolvedDID { 847 + t.Fatalf("expected resolved DID %s in repo lookup, got %s", resolvedDID, blocked) 848 + } 849 + return &UserBlock{ 850 + BlockerDID: blocker, 851 + BlockedDID: blocked, 852 + RecordURI: "at://did:plc:alice/social.coves.actor.block/rkey456", 853 + RecordCID: "bafycid456", 854 + }, nil 855 + }, 856 + } 857 + pdsClient := &mockPDSClient{ 858 + deleteRecordFn: func(ctx context.Context, collection, rkey string) error { 859 + deletedRKey = rkey 860 + return nil 861 + }, 862 + } 863 + 864 + svc := NewServiceWithPDSFactory(repo, resolver, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 865 + return pdsClient, nil 866 + }) 867 + 868 + session := testSession("did:plc:alice") 869 + err := svc.UnblockUser(context.Background(), session, "bob.bsky.social") 870 + if err != nil { 871 + t.Fatalf("unexpected error: %v", err) 872 + } 873 + if deletedRKey != "rkey456" { 874 + t.Fatalf("expected rkey=rkey456, got %s", deletedRKey) 875 + } 876 + } 877 + 878 + func TestUnblockUser_HandleResolutionFailure(t *testing.T) { 879 + resolver := &mockHandleResolver{ 880 + resolveHandleToDIDFn: func(ctx context.Context, handle string) (string, error) { 881 + return "", errors.New("handle not found") 882 + }, 883 + } 884 + 885 + svc := NewServiceWithPDSFactory(&mockRepo{}, resolver, func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 886 + return &mockPDSClient{}, nil 887 + }) 888 + 889 + session := testSession("did:plc:alice") 890 + err := svc.UnblockUser(context.Background(), session, "nonexistent.bsky.social") 891 + if err == nil { 892 + t.Fatal("expected error for handle resolution failure on unblock") 893 + } 894 + if !strings.Contains(err.Error(), "handle not found") { 895 + t.Fatalf("expected handle resolution error, got: %v", err) 896 + } 897 + }
+26
internal/core/userblocks/userblock.go
··· 1 + package userblocks 2 + 3 + import "time" 4 + 5 + // UserBlock represents a one-directional block relationship between two users. 6 + // The block record lives in the blocker's PDS repository at: 7 + // at://blocker_did/social.coves.actor.block/{tid} 8 + // 9 + // One-directional: Only the blocker's feeds/comments are filtered. 10 + // The blocked user is unaffected and unaware. 11 + type UserBlock struct { 12 + ID int `json:"id" db:"id"` 13 + BlockerDID string `json:"blockerDid" db:"blocker_did"` 14 + BlockedDID string `json:"blockedDid" db:"blocked_did"` 15 + BlockedAt time.Time `json:"blockedAt" db:"blocked_at"` 16 + RecordURI string `json:"recordUri" db:"record_uri"` 17 + RecordCID string `json:"recordCid" db:"record_cid"` 18 + } 19 + 20 + // BlockResult is returned by BlockUser after a successful PDS write-forward. 21 + // Contains only the record URI and CID from PDS. The full UserBlock will be 22 + // indexed asynchronously via the firehose consumer. 23 + type BlockResult struct { 24 + RecordURI string `json:"recordUri"` 25 + RecordCID string `json:"recordCid"` 26 + }
+4 -3
internal/core/users/interfaces.go
··· 64 64 // 3. community_subscriptions (explicit DELETE) 65 65 // 4. community_memberships (explicit DELETE) 66 66 // 5. community_blocks (explicit DELETE) 67 - // 6. comments (explicit DELETE) 68 - // 7. votes (explicit DELETE - FK removed in migration 014) 69 - // 8. users (FK CASCADE deletes posts) 67 + // 6. user_blocks (explicit DELETE - both directions) 68 + // 7. comments (explicit DELETE) 69 + // 8. votes (explicit DELETE - FK removed in migration 014) 70 + // 9. users (FK CASCADE deletes posts) 70 71 // 71 72 // Returns ErrUserNotFound if the user does not exist. 72 73 // Returns InvalidDIDError if the DID format is invalid.
+11 -3
internal/core/users/user.go
··· 63 63 DisplayName string `json:"displayName,omitempty"` 64 64 // Bio is the user's biography/description. Maps to JSON "description" for atProto lexicon compatibility. 65 65 Bio string `json:"description,omitempty"` 66 - Avatar string `json:"avatar,omitempty"` // URL, not CID 67 - Banner string `json:"banner,omitempty"` // URL, not CID 68 - // Viewer (requires user-to-user blocking infrastructure) 66 + Avatar string `json:"avatar,omitempty"` // URL, not CID 67 + Banner string `json:"banner,omitempty"` // URL, not CID 68 + Viewer *ProfileViewerState `json:"viewer,omitempty"` 69 + } 70 + 71 + // ProfileViewerState contains the authenticated viewer's relationship to this profile. 72 + // Populated when the request is authenticated; nil for unauthenticated requests. 73 + type ProfileViewerState struct { 74 + // Blocking is the AT-URI of the block record if the viewer has blocked this user. 75 + // nil if the viewer has not blocked this user. 76 + Blocking *string `json:"blocking,omitempty"` 69 77 }
+24
internal/db/migrations/029_create_user_blocks_table.sql
··· 1 + -- +goose Up 2 + CREATE TABLE user_blocks ( 3 + id SERIAL PRIMARY KEY, 4 + blocker_did TEXT NOT NULL CHECK (blocker_did ~ '^did:(plc|web):[a-zA-Z0-9._:%-]+$'), 5 + blocked_did TEXT NOT NULL CHECK (blocked_did ~ '^did:(plc|web):[a-zA-Z0-9._:%-]+$'), 6 + blocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 + 8 + -- AT-Proto metadata (block record lives in blocker's repo) 9 + -- These are required for atProto record verification and federation 10 + record_uri TEXT NOT NULL, -- atProto record identifier (at://blocker_did/social.coves.actor.block/rkey) 11 + record_cid TEXT NOT NULL, -- Content address (critical for verification) 12 + 13 + UNIQUE(blocker_did, blocked_did) 14 + ); 15 + 16 + -- Indexes for efficient queries 17 + -- Note: UNIQUE constraint on (blocker_did, blocked_did) already covers blocker_did as leading column 18 + CREATE INDEX idx_user_blocks_blocked ON user_blocks(blocked_did); 19 + CREATE UNIQUE INDEX idx_user_blocks_record_uri ON user_blocks(record_uri); -- For GetBlockByURI (Jetstream DELETE operations) 20 + 21 + -- +goose Down 22 + DROP INDEX IF EXISTS idx_user_blocks_record_uri; 23 + DROP INDEX IF EXISTS idx_user_blocks_blocked; 24 + DROP TABLE IF EXISTS user_blocks;
+29 -3
internal/db/postgres/comment_repo.go
··· 572 572 timeframe string, 573 573 limit int, 574 574 cursor *string, 575 + viewerDID string, 575 576 ) ([]*comments.Comment, *string, error) { 576 577 // Build ORDER BY clause and time filter based on sort type 577 578 orderBy, timeFilter := r.buildCommentSortClause(sort, timeframe) ··· 616 617 FROM comments c` 617 618 } 618 619 620 + // Build optional viewer block filter (only when authenticated viewer is present) 621 + var viewerFilter string 622 + var viewerArgs []interface{} 623 + if viewerDID != "" { 624 + viewerParamIdx := 3 + len(cursorValues) 625 + viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = c.commenter_did)", viewerParamIdx) 626 + viewerArgs = append(viewerArgs, viewerDID) 627 + } 628 + 619 629 // Build complete query with JOINs and filters 620 630 // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 621 631 // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) ··· 623 633 %s 624 634 LEFT JOIN users u ON c.commenter_did = u.did 625 635 WHERE c.parent_uri = $1 636 + %s 626 637 %s 627 638 %s 628 639 ORDER BY %s 629 640 LIMIT $2 630 - `, selectClause, timeFilter, cursorFilter, orderBy) 641 + `, selectClause, timeFilter, cursorFilter, viewerFilter, orderBy) 631 642 632 643 // Prepare query arguments 633 644 args := []interface{}{parentURI, limit + 1} // +1 to detect next page 634 645 args = append(args, cursorValues...) 646 + args = append(args, viewerArgs...) 635 647 636 648 // Execute query 637 649 rows, err := r.db.QueryContext(ctx, query, args...) ··· 964 976 parentURIs []string, 965 977 sort string, 966 978 limitPerParent int, 979 + viewerDID string, 967 980 ) (map[string][]*comments.Comment, error) { 968 981 if len(parentURIs) == 0 { 969 982 return make(map[string][]*comments.Comment), nil ··· 1019 1032 windowOrderBy = `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) DESC, c.score DESC, c.created_at DESC` 1020 1033 } 1021 1034 1035 + // Build optional viewer block filter (only when authenticated viewer is present) 1036 + // Parameter index computed dynamically: $1=parentURIs, $2=limitPerParent, $3+=viewer 1037 + var viewerFilter string 1038 + var viewerArgs []interface{} 1039 + if viewerDID != "" { 1040 + viewerParamIdx := 3 // after $1=parentURIs and $2=limitPerParent 1041 + viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = c.commenter_did)", viewerParamIdx) 1042 + viewerArgs = append(viewerArgs, viewerDID) 1043 + } 1044 + 1022 1045 // Use window function to limit results per parent 1023 1046 // This is more efficient than LIMIT in a subquery per parent 1024 1047 // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) ··· 1034 1057 FROM comments c 1035 1058 LEFT JOIN users u ON c.commenter_did = u.did 1036 1059 WHERE c.parent_uri = ANY($1) 1060 + %s 1037 1061 ) 1038 1062 SELECT 1039 1063 id, uri, cid, rkey, commenter_did, ··· 1045 1069 FROM ranked_comments 1046 1070 WHERE rn <= $2 1047 1071 ORDER BY parent_uri, rn 1048 - `, selectClause, windowOrderBy) 1072 + `, selectClause, windowOrderBy, viewerFilter) 1049 1073 1050 - rows, err := r.db.QueryContext(ctx, query, pq.Array(parentURIs), limitPerParent) 1074 + queryArgs := []interface{}{pq.Array(parentURIs), limitPerParent} 1075 + queryArgs = append(queryArgs, viewerArgs...) 1076 + rows, err := r.db.QueryContext(ctx, query, queryArgs...) 1051 1077 if err != nil { 1052 1078 return nil, fmt.Errorf("failed to batch query comments by parents: %w", err) 1053 1079 }
+12 -1
internal/db/postgres/discover_repo.go
··· 73 73 FROM posts p` 74 74 } 75 75 76 + // Build optional viewer block filter (only when authenticated viewer is present) 77 + var viewerFilter string 78 + var viewerArgs []interface{} 79 + if req.ViewerDID != "" { 80 + viewerParamIdx := 2 + len(cursorValues) 81 + viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = p.author_did)", viewerParamIdx) 82 + viewerArgs = append(viewerArgs, req.ViewerDID) 83 + } 84 + 76 85 // No subscription filter - show ALL posts from ALL communities 77 86 query := fmt.Sprintf(` 78 87 %s 79 88 INNER JOIN users u ON p.author_did = u.did 80 89 INNER JOIN communities c ON p.community_did = c.did 81 90 WHERE p.deleted_at IS NULL 91 + %s 82 92 %s 83 93 %s 84 94 ORDER BY %s 85 95 LIMIT $1 86 - `, selectClause, timeFilter, cursorFilter, orderBy) 96 + `, selectClause, timeFilter, cursorFilter, viewerFilter, orderBy) 87 97 88 98 // Prepare query arguments 89 99 args := []interface{}{req.Limit + 1} // +1 to check for next page 90 100 args = append(args, cursorValues...) 101 + args = append(args, viewerArgs...) 91 102 92 103 // Execute query 93 104 rows, err := r.db.QueryContext(ctx, query, args...)
+12 -1
internal/db/postgres/feed_repo.go
··· 78 78 FROM posts p` 79 79 } 80 80 81 + // Build optional viewer block filter (only when authenticated viewer is present) 82 + var viewerFilter string 83 + var viewerArgs []interface{} 84 + if req.ViewerDID != "" { 85 + viewerParamIdx := 3 + len(cursorValues) 86 + viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = p.author_did)", viewerParamIdx) 87 + viewerArgs = append(viewerArgs, req.ViewerDID) 88 + } 89 + 81 90 query := fmt.Sprintf(` 82 91 %s 83 92 INNER JOIN users u ON p.author_did = u.did 84 93 INNER JOIN communities c ON p.community_did = c.did 85 94 WHERE p.community_did = $1 86 95 AND p.deleted_at IS NULL 96 + %s 87 97 %s 88 98 %s 89 99 ORDER BY %s 90 100 LIMIT $2 91 - `, selectClause, timeFilter, cursorFilter, orderBy) 101 + `, selectClause, timeFilter, cursorFilter, viewerFilter, orderBy) 92 102 93 103 // Prepare query arguments 94 104 args := []interface{}{req.Community, req.Limit + 1} // +1 to check for next page 95 105 args = append(args, cursorValues...) 106 + args = append(args, viewerArgs...) 96 107 97 108 // Execute query 98 109 rows, err := r.db.QueryContext(ctx, query, args...)
+2
internal/db/postgres/timeline_repo.go
··· 84 84 INNER JOIN community_subscriptions cs ON p.community_did = cs.community_did 85 85 WHERE cs.user_did = $1 86 86 AND p.deleted_at IS NULL 87 + -- Intentional $1 reuse: the viewer's DID (cs.user_did) is also the blocker for block filtering 88 + AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $1 AND blocked_did = p.author_did) 87 89 %s 88 90 %s 89 91 ORDER BY %s
+8 -3
internal/db/postgres/user_repo.go
··· 278 278 return fmt.Errorf("failed to delete community_blocks for did=%s: %w", did, err) 279 279 } 280 280 281 - // 6. Delete comments (explicit DELETE) 281 + // 6. Delete user blocks (both directions - blocks they created AND blocks against them) 282 + if _, err := tx.ExecContext(ctx, `DELETE FROM user_blocks WHERE blocker_did = $1 OR blocked_did = $1`, did); err != nil { 283 + return fmt.Errorf("failed to delete user_blocks for did=%s: %w", did, err) 284 + } 285 + 286 + // 7. Delete comments (explicit DELETE) 282 287 if _, err := tx.ExecContext(ctx, `DELETE FROM comments WHERE commenter_did = $1`, did); err != nil { 283 288 return fmt.Errorf("failed to delete comments for did=%s: %w", did, err) 284 289 } 285 290 286 - // 7. Delete votes (explicit DELETE - FK constraint removed in migration 014) 291 + // 8. Delete votes (explicit DELETE - FK constraint removed in migration 014) 287 292 if _, err := tx.ExecContext(ctx, `DELETE FROM votes WHERE voter_did = $1`, did); err != nil { 288 293 return fmt.Errorf("failed to delete votes for did=%s: %w", did, err) 289 294 } 290 295 291 - // 8. Delete user (FK CASCADE deletes posts) 296 + // 9. Delete user (FK CASCADE deletes posts) 292 297 result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE did = $1`, did) 293 298 if err != nil { 294 299 return fmt.Errorf("failed to delete user did=%s: %w", did, err)
+224
internal/db/postgres/userblock_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "Coves/internal/core/userblocks" 5 + "context" 6 + "database/sql" 7 + "errors" 8 + "fmt" 9 + "log" 10 + 11 + "github.com/lib/pq" 12 + ) 13 + 14 + // postgresUserBlockRepo implements userblocks.Repository using PostgreSQL 15 + type postgresUserBlockRepo struct { 16 + db *sql.DB 17 + } 18 + 19 + // NewUserBlockRepository creates a new PostgreSQL-backed user block repository 20 + func NewUserBlockRepository(db *sql.DB) userblocks.Repository { 21 + return &postgresUserBlockRepo{db: db} 22 + } 23 + 24 + // BlockUser creates a new block record (idempotent via ON CONFLICT DO UPDATE) 25 + func (r *postgresUserBlockRepo) BlockUser(ctx context.Context, block *userblocks.UserBlock) (*userblocks.UserBlock, error) { 26 + query := ` 27 + INSERT INTO user_blocks (blocker_did, blocked_did, blocked_at, record_uri, record_cid) 28 + VALUES ($1, $2, $3, $4, $5) 29 + ON CONFLICT (blocker_did, blocked_did) DO UPDATE SET 30 + record_uri = EXCLUDED.record_uri, 31 + record_cid = EXCLUDED.record_cid, 32 + blocked_at = EXCLUDED.blocked_at 33 + RETURNING id, blocked_at` 34 + 35 + err := r.db.QueryRowContext(ctx, query, 36 + block.BlockerDID, 37 + block.BlockedDID, 38 + block.BlockedAt, 39 + block.RecordURI, 40 + block.RecordCID, 41 + ).Scan(&block.ID, &block.BlockedAt) 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to create user block: %w", err) 44 + } 45 + 46 + return block, nil 47 + } 48 + 49 + // UnblockUser removes a block record. Returns ErrBlockNotFound if not exists. 50 + func (r *postgresUserBlockRepo) UnblockUser(ctx context.Context, blockerDID, blockedDID string) error { 51 + query := `DELETE FROM user_blocks WHERE blocker_did = $1 AND blocked_did = $2` 52 + 53 + result, err := r.db.ExecContext(ctx, query, blockerDID, blockedDID) 54 + if err != nil { 55 + return fmt.Errorf("failed to unblock user: %w", err) 56 + } 57 + 58 + rowsAffected, err := result.RowsAffected() 59 + if err != nil { 60 + return fmt.Errorf("failed to check unblock result: %w", err) 61 + } 62 + 63 + if rowsAffected == 0 { 64 + return userblocks.ErrBlockNotFound 65 + } 66 + 67 + return nil 68 + } 69 + 70 + // GetBlock retrieves a block by blocker + blocked DID pair. 71 + func (r *postgresUserBlockRepo) GetBlock(ctx context.Context, blockerDID, blockedDID string) (*userblocks.UserBlock, error) { 72 + query := ` 73 + SELECT id, blocker_did, blocked_did, blocked_at, record_uri, record_cid 74 + FROM user_blocks 75 + WHERE blocker_did = $1 AND blocked_did = $2` 76 + 77 + var block userblocks.UserBlock 78 + 79 + err := r.db.QueryRowContext(ctx, query, blockerDID, blockedDID).Scan( 80 + &block.ID, 81 + &block.BlockerDID, 82 + &block.BlockedDID, 83 + &block.BlockedAt, 84 + &block.RecordURI, 85 + &block.RecordCID, 86 + ) 87 + if err != nil { 88 + if errors.Is(err, sql.ErrNoRows) { 89 + return nil, userblocks.ErrBlockNotFound 90 + } 91 + return nil, fmt.Errorf("failed to get user block: %w", err) 92 + } 93 + 94 + return &block, nil 95 + } 96 + 97 + // GetBlockByURI retrieves a block by its AT-URI (for Jetstream DELETE operations). 98 + func (r *postgresUserBlockRepo) GetBlockByURI(ctx context.Context, recordURI string) (*userblocks.UserBlock, error) { 99 + query := ` 100 + SELECT id, blocker_did, blocked_did, blocked_at, record_uri, record_cid 101 + FROM user_blocks 102 + WHERE record_uri = $1` 103 + 104 + var block userblocks.UserBlock 105 + 106 + err := r.db.QueryRowContext(ctx, query, recordURI).Scan( 107 + &block.ID, 108 + &block.BlockerDID, 109 + &block.BlockedDID, 110 + &block.BlockedAt, 111 + &block.RecordURI, 112 + &block.RecordCID, 113 + ) 114 + if err != nil { 115 + if errors.Is(err, sql.ErrNoRows) { 116 + return nil, userblocks.ErrBlockNotFound 117 + } 118 + return nil, fmt.Errorf("failed to get user block by URI: %w", err) 119 + } 120 + 121 + return &block, nil 122 + } 123 + 124 + // ListBlockedUsers retrieves all users blocked by the given blocker, paginated. 125 + // Results are ordered by blocked_at DESC. 126 + func (r *postgresUserBlockRepo) ListBlockedUsers(ctx context.Context, blockerDID string, limit, offset int) ([]*userblocks.UserBlock, error) { 127 + query := ` 128 + SELECT id, blocker_did, blocked_did, blocked_at, record_uri, record_cid 129 + FROM user_blocks 130 + WHERE blocker_did = $1 131 + ORDER BY blocked_at DESC 132 + LIMIT $2 OFFSET $3` 133 + 134 + rows, err := r.db.QueryContext(ctx, query, blockerDID, limit, offset) 135 + if err != nil { 136 + return nil, fmt.Errorf("failed to list blocked users: %w", err) 137 + } 138 + defer func() { 139 + if closeErr := rows.Close(); closeErr != nil { 140 + log.Printf("Failed to close rows: %v", closeErr) 141 + } 142 + }() 143 + 144 + var blocks []*userblocks.UserBlock 145 + for rows.Next() { 146 + block := &userblocks.UserBlock{} 147 + 148 + err = rows.Scan( 149 + &block.ID, 150 + &block.BlockerDID, 151 + &block.BlockedDID, 152 + &block.BlockedAt, 153 + &block.RecordURI, 154 + &block.RecordCID, 155 + ) 156 + if err != nil { 157 + return nil, fmt.Errorf("failed to scan user block: %w", err) 158 + } 159 + 160 + blocks = append(blocks, block) 161 + } 162 + 163 + if err = rows.Err(); err != nil { 164 + return nil, fmt.Errorf("error iterating user blocks: %w", err) 165 + } 166 + 167 + return blocks, nil 168 + } 169 + 170 + // IsBlocked checks if blockerDID has blocked blockedDID (fast EXISTS check). 171 + func (r *postgresUserBlockRepo) IsBlocked(ctx context.Context, blockerDID, blockedDID string) (bool, error) { 172 + query := ` 173 + SELECT EXISTS( 174 + SELECT 1 FROM user_blocks 175 + WHERE blocker_did = $1 AND blocked_did = $2 176 + )` 177 + 178 + var exists bool 179 + err := r.db.QueryRowContext(ctx, query, blockerDID, blockedDID).Scan(&exists) 180 + if err != nil { 181 + return false, fmt.Errorf("failed to check if user is blocked: %w", err) 182 + } 183 + 184 + return exists, nil 185 + } 186 + 187 + // AreBlocked checks which of the given DIDs are blocked by blockerDID. 188 + // Returns a map of blockedDID -> true for each DID that is blocked. 189 + func (r *postgresUserBlockRepo) AreBlocked(ctx context.Context, blockerDID string, blockedDIDs []string) (map[string]bool, error) { 190 + result := make(map[string]bool) 191 + 192 + if len(blockedDIDs) == 0 { 193 + return result, nil 194 + } 195 + 196 + query := ` 197 + SELECT blocked_did 198 + FROM user_blocks 199 + WHERE blocker_did = $1 AND blocked_did = ANY($2)` 200 + 201 + rows, err := r.db.QueryContext(ctx, query, blockerDID, pq.Array(blockedDIDs)) 202 + if err != nil { 203 + return nil, fmt.Errorf("failed to batch check blocked users: %w", err) 204 + } 205 + defer func() { 206 + if closeErr := rows.Close(); closeErr != nil { 207 + log.Printf("Failed to close rows: %v", closeErr) 208 + } 209 + }() 210 + 211 + for rows.Next() { 212 + var blockedDID string 213 + if err = rows.Scan(&blockedDID); err != nil { 214 + return nil, fmt.Errorf("failed to scan blocked DID: %w", err) 215 + } 216 + result[blockedDID] = true 217 + } 218 + 219 + if err = rows.Err(); err != nil { 220 + return nil, fmt.Errorf("error iterating blocked DIDs: %w", err) 221 + } 222 + 223 + return result, nil 224 + }
+39 -55
tests/integration/helpers.go
··· 5 5 "Coves/internal/atproto/oauth" 6 6 "Coves/internal/atproto/pds" 7 7 "Coves/internal/core/communities" 8 + "Coves/internal/core/userblocks" 8 9 "Coves/internal/core/users" 9 10 "Coves/internal/core/votes" 10 11 "bytes" ··· 16 17 "net/http" 17 18 "os" 18 19 "strings" 20 + "sync/atomic" 19 21 "testing" 20 22 "time" 21 23 ··· 70 72 return user 71 73 } 72 74 73 - // contains checks if string s contains substring substr 74 - // Helper for error message assertions 75 + // contains checks if string s contains substring substr. 76 + // Convenience wrapper used across multiple integration test files. 75 77 func contains(s, substr string) bool { 76 78 return strings.Contains(s, substr) 77 79 } ··· 119 121 return sessionResp.AccessJwt, sessionResp.DID, nil 120 122 } 121 123 122 - // tidCounter is used to ensure unique TIDs even when generateTID is called rapidly 123 - var tidCounter uint64 124 + // tidCounter is used to ensure unique TIDs even when generateTID is called rapidly. 125 + var tidCounter atomic.Uint64 124 126 125 - // testIDCounter is used to ensure unique test identifiers across all tests 126 - var testIDCounter uint64 127 + // testIDCounter is used to ensure unique test identifiers across all tests. 128 + var testIDCounter atomic.Uint64 127 129 128 - // generateTID generates a simple timestamp-based identifier for testing 129 - // In production, PDS generates proper TIDs 130 - // Uses an atomic counter to ensure uniqueness even when called in rapid succession 130 + // generateTID generates a simple timestamp-based identifier for testing. 131 + // In production, PDS generates proper TIDs. 131 132 func generateTID() string { 132 - tidCounter++ 133 - return fmt.Sprintf("3k%d%d", time.Now().UnixNano()/1000, tidCounter) 133 + n := tidCounter.Add(1) 134 + return fmt.Sprintf("3k%d%d", time.Now().UnixNano()/1000, n) 134 135 } 135 136 136 - // uniqueTestID generates a unique identifier for test resources (handles, emails, etc.) 137 - // Uses Unix timestamp (seconds) + atomic counter to ensure uniqueness across test runs 138 - // Keeps IDs short enough to fit within PDS handle limits (max 20 chars for label) 139 - // Returns a ~14 char string (10-digit timestamp + up to 4-digit counter) 137 + // uniqueTestID generates a unique identifier for test resources (handles, emails, etc.). 138 + // Uses Unix timestamp (seconds) + atomic counter to ensure uniqueness across test runs. 139 + // Keeps IDs short enough to fit within PDS handle limits (max 20 chars for label). 140 140 func uniqueTestID() string { 141 - testIDCounter++ 142 - return fmt.Sprintf("%d%d", time.Now().Unix(), testIDCounter) 141 + n := testIDCounter.Add(1) 142 + return fmt.Sprintf("%d%d", time.Now().Unix(), n) 143 143 } 144 144 145 145 // createPDSAccount creates a new account on PDS and returns access token + DID ··· 446 446 return token 447 447 } 448 448 449 - // PasswordAuthPDSClientFactory creates a PDSClientFactory that uses password-based Bearer auth. 450 - // This is for E2E tests that use createSession instead of OAuth. 451 - // The factory extracts the access token and host URL from the session data. 452 - func PasswordAuthPDSClientFactory() votes.PDSClientFactory { 453 - return func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 454 - if session.AccessToken == "" { 455 - return nil, fmt.Errorf("session has no access token") 456 - } 457 - if session.HostURL == "" { 458 - return nil, fmt.Errorf("session has no host URL") 459 - } 460 - 461 - return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 449 + // passwordAuthPDSClient is the shared implementation for all test PDS client factories. 450 + // Creates PDS clients using password-based Bearer auth from the session's access token. 451 + func passwordAuthPDSClient(_ context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 452 + if session.AccessToken == "" { 453 + return nil, fmt.Errorf("session has no access token") 462 454 } 455 + if session.HostURL == "" { 456 + return nil, fmt.Errorf("session has no host URL") 457 + } 458 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 463 459 } 464 460 465 - // CommunityPasswordAuthPDSClientFactory creates a PDSClientFactory for communities that uses password-based Bearer auth. 466 - // This is for E2E tests that use createSession instead of OAuth. 467 - // The factory extracts the access token and host URL from the session data. 461 + // PasswordAuthPDSClientFactory creates a PDSClientFactory for votes E2E tests. 462 + func PasswordAuthPDSClientFactory() votes.PDSClientFactory { 463 + return passwordAuthPDSClient 464 + } 465 + 466 + // CommunityPasswordAuthPDSClientFactory creates a PDSClientFactory for community E2E tests. 468 467 func CommunityPasswordAuthPDSClientFactory() communities.PDSClientFactory { 469 - return func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 470 - if session.AccessToken == "" { 471 - return nil, fmt.Errorf("session has no access token") 472 - } 473 - if session.HostURL == "" { 474 - return nil, fmt.Errorf("session has no host URL") 475 - } 468 + return passwordAuthPDSClient 469 + } 476 470 477 - return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 478 - } 471 + // UserBlockPasswordAuthPDSClientFactory creates a PDSClientFactory for user block E2E tests. 472 + func UserBlockPasswordAuthPDSClientFactory() userblocks.PDSClientFactory { 473 + return passwordAuthPDSClient 479 474 } 480 475 481 - // UserProfilePasswordAuthPDSClientFactory creates a PDSClientFactory for user profile updates 482 - // that uses password-based Bearer auth. This is for E2E tests that use createSession instead of OAuth. 483 - // The factory extracts the access token and host URL from the session data. 476 + // UserProfilePasswordAuthPDSClientFactory creates a PDSClientFactory for user profile E2E tests. 484 477 func UserProfilePasswordAuthPDSClientFactory() func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 485 - return func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 486 - if session.AccessToken == "" { 487 - return nil, fmt.Errorf("session has no access token") 488 - } 489 - if session.HostURL == "" { 490 - return nil, fmt.Errorf("session has no host URL") 491 - } 492 - 493 - return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 494 - } 478 + return passwordAuthPDSClient 495 479 }
+484
tests/integration/userblock_e2e_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/api/routes" 5 + "Coves/internal/atproto/jetstream" 6 + "Coves/internal/atproto/utils" 7 + "Coves/internal/core/userblocks" 8 + "Coves/internal/db/postgres" 9 + "bytes" 10 + "context" 11 + "encoding/json" 12 + "fmt" 13 + "io" 14 + "net/http" 15 + "net/http/httptest" 16 + "testing" 17 + "time" 18 + 19 + "github.com/go-chi/chi/v5" 20 + _ "github.com/lib/pq" 21 + ) 22 + 23 + // TestUserBlockE2E_BlockAndUnblock tests the full user block lifecycle with a real PDS. 24 + // Flow: Client -> XRPC -> PDS Write -> Verify on PDS -> Jetstream -> Consumer -> AppView 25 + // Then: Client -> XRPC Unblock -> PDS Delete -> Jetstream -> Consumer -> AppView removal 26 + func TestUserBlockE2E_BlockAndUnblock(t *testing.T) { 27 + if testing.Short() { 28 + t.Skip("Skipping E2E test in short mode") 29 + } 30 + 31 + db := setupTestDB(t) 32 + defer func() { _ = db.Close() }() 33 + 34 + ctx := context.Background() 35 + pdsURL := getTestPDSURL() 36 + 37 + // Health check PDS 38 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 39 + if err != nil { 40 + t.Skipf("PDS not running at %s: %v", pdsURL, err) 41 + } 42 + func() { 43 + if closeErr := healthResp.Body.Close(); closeErr != nil { 44 + t.Logf("Failed to close health response: %v", closeErr) 45 + } 46 + }() 47 + 48 + // Clean up user_blocks at the end to avoid polluting other tests 49 + defer func() { 50 + _, _ = db.Exec("DELETE FROM user_blocks") 51 + }() 52 + 53 + // Setup repository 54 + blockRepo := postgres.NewUserBlockRepository(db) 55 + 56 + // Create User A (blocker) on real PDS 57 + userAHandle := fmt.Sprintf("blka%d.local.coves.dev", time.Now().UnixNano()%1000000) 58 + userAEmail := fmt.Sprintf("blockerA-%d@test.local", time.Now().Unix()) 59 + userAPassword := "test-password-123" 60 + 61 + t.Logf("Creating User A (blocker) on PDS: %s", userAHandle) 62 + pdsAccessTokenA, userADID, err := createPDSAccount(pdsURL, userAHandle, userAEmail, userAPassword) 63 + if err != nil { 64 + t.Fatalf("Failed to create User A on PDS: %v", err) 65 + } 66 + t.Logf("User A created: DID=%s", userADID) 67 + 68 + // Create User B (target) on real PDS 69 + userBHandle := fmt.Sprintf("blkb%d.local.coves.dev", time.Now().UnixNano()%1000000) 70 + userBEmail := fmt.Sprintf("blockerB-%d@test.local", time.Now().Unix()) 71 + userBPassword := "test-password-123" 72 + 73 + t.Logf("Creating User B (target) on PDS: %s", userBHandle) 74 + _, userBDID, err := createPDSAccount(pdsURL, userBHandle, userBEmail, userBPassword) 75 + if err != nil { 76 + t.Fatalf("Failed to create User B on PDS: %v", err) 77 + } 78 + t.Logf("User B created: DID=%s", userBDID) 79 + 80 + // Index both users in AppView 81 + createTestUser(t, db, userAHandle, userADID) 82 + createTestUser(t, db, userBHandle, userBDID) 83 + 84 + // Setup OAuth middleware with User A's real PDS access token 85 + e2eAuth := NewE2EOAuthMiddleware() 86 + tokenA := e2eAuth.AddUserWithPDSToken(userADID, pdsAccessTokenA, pdsURL) 87 + 88 + // Setup service with password-based PDS client factory 89 + service := userblocks.NewServiceWithPDSFactory(blockRepo, nil, UserBlockPasswordAuthPDSClientFactory()) 90 + 91 + // Setup HTTP server with XRPC routes 92 + r := chi.NewRouter() 93 + routes.RegisterUserBlockRoutes(r, service, e2eAuth.OAuthAuthMiddleware) 94 + httpServer := httptest.NewServer(r) 95 + defer httpServer.Close() 96 + 97 + // Setup Jetstream consumer with block repo 98 + userConsumer := jetstream.NewUserEventConsumer(nil, nil, "", "", 99 + jetstream.WithUserBlockRepo(blockRepo)) 100 + 101 + // ==================================================================================== 102 + // BLOCK PHASE: User A blocks User B 103 + // ==================================================================================== 104 + t.Logf("\n--- BLOCK PHASE: User A blocks User B ---") 105 + 106 + blockReq := map[string]interface{}{ 107 + "subject": userBDID, 108 + } 109 + 110 + reqBody, marshalErr := json.Marshal(blockReq) 111 + if marshalErr != nil { 112 + t.Fatalf("Failed to marshal block request: %v", marshalErr) 113 + } 114 + 115 + req, err := http.NewRequest(http.MethodPost, 116 + httpServer.URL+"/xrpc/social.coves.actor.blockUser", 117 + bytes.NewBuffer(reqBody)) 118 + if err != nil { 119 + t.Fatalf("Failed to create block request: %v", err) 120 + } 121 + req.Header.Set("Content-Type", "application/json") 122 + req.Header.Set("Authorization", "Bearer "+tokenA) 123 + 124 + resp, err := http.DefaultClient.Do(req) 125 + if err != nil { 126 + t.Fatalf("Failed to POST block: %v", err) 127 + } 128 + defer func() { _ = resp.Body.Close() }() 129 + 130 + if resp.StatusCode != http.StatusOK { 131 + body, readErr := io.ReadAll(resp.Body) 132 + if readErr != nil { 133 + t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) 134 + } 135 + t.Logf("XRPC Block Failed") 136 + t.Logf(" Status: %d", resp.StatusCode) 137 + t.Logf(" Response: %s", string(body)) 138 + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 139 + } 140 + 141 + var blockResp struct { 142 + Block struct { 143 + RecordURI string `json:"recordUri"` 144 + RecordCID string `json:"recordCid"` 145 + } `json:"block"` 146 + } 147 + 148 + if decodeErr := json.NewDecoder(resp.Body).Decode(&blockResp); decodeErr != nil { 149 + t.Fatalf("Failed to decode block response: %v", decodeErr) 150 + } 151 + 152 + t.Logf("XRPC block response received:") 153 + t.Logf(" RecordURI: %s", blockResp.Block.RecordURI) 154 + t.Logf(" RecordCID: %s", blockResp.Block.RecordCID) 155 + 156 + if blockResp.Block.RecordURI == "" { 157 + t.Fatal("Block response missing recordUri") 158 + } 159 + if blockResp.Block.RecordCID == "" { 160 + t.Fatal("Block response missing recordCid") 161 + } 162 + 163 + // Verify block record was written to PDS 164 + t.Logf("\nVerifying block record on PDS...") 165 + rkey := utils.ExtractRKeyFromURI(blockResp.Block.RecordURI) 166 + collection := "social.coves.actor.block" 167 + 168 + pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 169 + pdsURL, userADID, collection, rkey)) 170 + if pdsErr != nil { 171 + t.Fatalf("Failed to fetch block record from PDS: %v", pdsErr) 172 + } 173 + defer func() { 174 + if closeErr := pdsResp.Body.Close(); closeErr != nil { 175 + t.Logf("Failed to close PDS response: %v", closeErr) 176 + } 177 + }() 178 + 179 + if pdsResp.StatusCode != http.StatusOK { 180 + body, _ := io.ReadAll(pdsResp.Body) 181 + t.Fatalf("Block record not found on PDS: status %d, body: %s", pdsResp.StatusCode, string(body)) 182 + } 183 + 184 + var pdsRecord struct { 185 + Value map[string]interface{} `json:"value"` 186 + CID string `json:"cid"` 187 + } 188 + if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil { 189 + t.Fatalf("Failed to decode PDS record: %v", decodeErr) 190 + } 191 + 192 + t.Logf("Block record found on PDS:") 193 + t.Logf(" CID: %s", pdsRecord.CID) 194 + t.Logf(" Subject: %v", pdsRecord.Value["subject"]) 195 + 196 + // Verify subject DID in PDS record 197 + if pdsRecord.Value["subject"] != userBDID { 198 + t.Errorf("Expected subject '%s', got %v", userBDID, pdsRecord.Value["subject"]) 199 + } 200 + 201 + // Simulate Jetstream CREATE event 202 + t.Logf("\nSimulating Jetstream CREATE event for block...") 203 + createEvent := jetstream.JetstreamEvent{ 204 + Did: userADID, 205 + TimeUS: time.Now().UnixMicro(), 206 + Kind: "commit", 207 + Commit: &jetstream.CommitEvent{ 208 + Rev: "test-block-rev-create", 209 + Operation: "create", 210 + Collection: "social.coves.actor.block", 211 + RKey: rkey, 212 + CID: pdsRecord.CID, 213 + Record: map[string]interface{}{ 214 + "$type": "social.coves.actor.block", 215 + "subject": userBDID, 216 + "createdAt": time.Now().Format(time.RFC3339), 217 + }, 218 + }, 219 + } 220 + 221 + if handleErr := userConsumer.HandleEvent(ctx, &createEvent); handleErr != nil { 222 + t.Fatalf("Failed to handle block create event: %v", handleErr) 223 + } 224 + 225 + // Verify block indexed in AppView via repo.GetBlock() 226 + t.Logf("\nVerifying block indexed in AppView...") 227 + indexedBlock, err := blockRepo.GetBlock(ctx, userADID, userBDID) 228 + if err != nil { 229 + t.Fatalf("Block not indexed in AppView: %v", err) 230 + } 231 + 232 + t.Logf("Block indexed in AppView:") 233 + t.Logf(" BlockerDID: %s", indexedBlock.BlockerDID) 234 + t.Logf(" BlockedDID: %s", indexedBlock.BlockedDID) 235 + t.Logf(" RecordURI: %s", indexedBlock.RecordURI) 236 + t.Logf(" RecordCID: %s", indexedBlock.RecordCID) 237 + 238 + if indexedBlock.BlockerDID != userADID { 239 + t.Errorf("Expected blocker_did %s, got %s", userADID, indexedBlock.BlockerDID) 240 + } 241 + if indexedBlock.BlockedDID != userBDID { 242 + t.Errorf("Expected blocked_did %s, got %s", userBDID, indexedBlock.BlockedDID) 243 + } 244 + 245 + // Verify via IsBlocked 246 + isBlocked, err := blockRepo.IsBlocked(ctx, userADID, userBDID) 247 + if err != nil { 248 + t.Fatalf("Failed to check IsBlocked: %v", err) 249 + } 250 + if !isBlocked { 251 + t.Error("Expected IsBlocked to return true, got false") 252 + } 253 + 254 + t.Logf("BLOCK PHASE COMPLETE:") 255 + t.Logf(" Client -> XRPC -> PDS Write -> Jetstream -> Consumer -> AppView") 256 + t.Logf(" Block written to PDS") 257 + t.Logf(" Block indexed in AppView") 258 + t.Logf(" IsBlocked confirmed") 259 + 260 + // ==================================================================================== 261 + // UNBLOCK PHASE: User A unblocks User B 262 + // ==================================================================================== 263 + t.Logf("\n--- UNBLOCK PHASE: User A unblocks User B ---") 264 + 265 + unblockReq := map[string]interface{}{ 266 + "subject": userBDID, 267 + } 268 + 269 + unblockBody, marshalErr := json.Marshal(unblockReq) 270 + if marshalErr != nil { 271 + t.Fatalf("Failed to marshal unblock request: %v", marshalErr) 272 + } 273 + 274 + unblockHTTPReq, err := http.NewRequest(http.MethodPost, 275 + httpServer.URL+"/xrpc/social.coves.actor.unblockUser", 276 + bytes.NewBuffer(unblockBody)) 277 + if err != nil { 278 + t.Fatalf("Failed to create unblock request: %v", err) 279 + } 280 + unblockHTTPReq.Header.Set("Content-Type", "application/json") 281 + unblockHTTPReq.Header.Set("Authorization", "Bearer "+tokenA) 282 + 283 + unblockResp, err := http.DefaultClient.Do(unblockHTTPReq) 284 + if err != nil { 285 + t.Fatalf("Failed to POST unblock: %v", err) 286 + } 287 + defer func() { 288 + if closeErr := unblockResp.Body.Close(); closeErr != nil { 289 + t.Logf("Failed to close unblock response: %v", closeErr) 290 + } 291 + }() 292 + 293 + if unblockResp.StatusCode != http.StatusOK { 294 + body, _ := io.ReadAll(unblockResp.Body) 295 + t.Fatalf("Unblock failed: status %d, body: %s", unblockResp.StatusCode, string(body)) 296 + } 297 + 298 + t.Logf("Unblock XRPC request succeeded") 299 + 300 + // Verify record deleted from PDS (expect error/404) 301 + t.Logf("\nVerifying block record deleted from PDS...") 302 + pdsDeleteCheck, pdsDeleteErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 303 + pdsURL, userADID, collection, rkey)) 304 + if pdsDeleteErr != nil { 305 + t.Fatalf("Failed to check PDS record after unblock: %v", pdsDeleteErr) 306 + } 307 + defer func() { 308 + if closeErr := pdsDeleteCheck.Body.Close(); closeErr != nil { 309 + t.Logf("Failed to close PDS delete check response: %v", closeErr) 310 + } 311 + }() 312 + 313 + switch pdsDeleteCheck.StatusCode { 314 + case http.StatusOK: 315 + t.Error("Expected block record to be deleted from PDS, but it still exists") 316 + case http.StatusBadRequest, http.StatusNotFound: 317 + // Expected: PDS returns 400 (RecordNotFound) or 404 for deleted records 318 + t.Logf("Block record confirmed deleted from PDS (status: %d)", pdsDeleteCheck.StatusCode) 319 + default: 320 + body, _ := io.ReadAll(pdsDeleteCheck.Body) 321 + t.Errorf("Unexpected status when checking deleted record: %d, body: %s", pdsDeleteCheck.StatusCode, string(body)) 322 + } 323 + 324 + // Simulate Jetstream DELETE event 325 + t.Logf("\nSimulating Jetstream DELETE event for unblock...") 326 + deleteEvent := jetstream.JetstreamEvent{ 327 + Did: userADID, 328 + TimeUS: time.Now().UnixMicro(), 329 + Kind: "commit", 330 + Commit: &jetstream.CommitEvent{ 331 + Rev: "test-block-rev-delete", 332 + Operation: "delete", 333 + Collection: "social.coves.actor.block", 334 + RKey: rkey, 335 + }, 336 + } 337 + 338 + if handleErr := userConsumer.HandleEvent(ctx, &deleteEvent); handleErr != nil { 339 + t.Fatalf("Failed to handle block delete event: %v", handleErr) 340 + } 341 + 342 + // Verify block removed from AppView 343 + t.Logf("\nVerifying block removed from AppView...") 344 + _, err = blockRepo.GetBlock(ctx, userADID, userBDID) 345 + if err == nil { 346 + t.Error("Expected block to be deleted from AppView, but it still exists") 347 + } 348 + 349 + // Verify via IsBlocked 350 + isBlockedAfter, err := blockRepo.IsBlocked(ctx, userADID, userBDID) 351 + if err != nil { 352 + t.Fatalf("Failed to check IsBlocked after unblock: %v", err) 353 + } 354 + if isBlockedAfter { 355 + t.Error("Expected IsBlocked to return false after unblock, got true") 356 + } 357 + 358 + t.Logf("UNBLOCK PHASE COMPLETE:") 359 + t.Logf(" Unblock request sent via XRPC") 360 + t.Logf(" Block record deleted from PDS") 361 + t.Logf(" Block removed from AppView") 362 + t.Logf(" IsBlocked confirmed false") 363 + 364 + t.Logf("\nTRUE E2E BLOCK/UNBLOCK FLOW COMPLETE:") 365 + t.Logf(" Client -> XRPC -> PDS Write -> Jetstream -> Consumer -> AppView") 366 + t.Logf(" Client -> XRPC Unblock -> PDS Delete -> Jetstream -> Consumer -> AppView removal") 367 + } 368 + 369 + // TestUserBlockE2E_SelfBlockPrevented tests that a user cannot block themselves. 370 + // This validates the self-block guard in the service layer with a real PDS. 371 + func TestUserBlockE2E_SelfBlockPrevented(t *testing.T) { 372 + if testing.Short() { 373 + t.Skip("Skipping E2E test in short mode") 374 + } 375 + 376 + db := setupTestDB(t) 377 + defer func() { _ = db.Close() }() 378 + 379 + pdsURL := getTestPDSURL() 380 + 381 + // Health check PDS 382 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 383 + if err != nil { 384 + t.Skipf("PDS not running at %s: %v", pdsURL, err) 385 + } 386 + func() { 387 + if closeErr := healthResp.Body.Close(); closeErr != nil { 388 + t.Logf("Failed to close health response: %v", closeErr) 389 + } 390 + }() 391 + 392 + // Clean up user_blocks at the end 393 + defer func() { 394 + _, _ = db.Exec("DELETE FROM user_blocks") 395 + }() 396 + 397 + // Setup repository 398 + blockRepo := postgres.NewUserBlockRepository(db) 399 + 400 + // Create User A on real PDS 401 + userAHandle := fmt.Sprintf("self%d.local.coves.dev", time.Now().UnixNano()%1000000) 402 + userAEmail := fmt.Sprintf("selfblock-%d@test.local", time.Now().Unix()) 403 + userAPassword := "test-password-123" 404 + 405 + t.Logf("Creating User A on PDS: %s", userAHandle) 406 + pdsAccessTokenA, userADID, err := createPDSAccount(pdsURL, userAHandle, userAEmail, userAPassword) 407 + if err != nil { 408 + t.Fatalf("Failed to create User A on PDS: %v", err) 409 + } 410 + t.Logf("User A created: DID=%s", userADID) 411 + 412 + // Index user in AppView 413 + createTestUser(t, db, userAHandle, userADID) 414 + 415 + // Setup OAuth middleware with User A's real PDS access token 416 + e2eAuth := NewE2EOAuthMiddleware() 417 + tokenA := e2eAuth.AddUserWithPDSToken(userADID, pdsAccessTokenA, pdsURL) 418 + 419 + // Setup service with password-based PDS client factory 420 + service := userblocks.NewServiceWithPDSFactory(blockRepo, nil, UserBlockPasswordAuthPDSClientFactory()) 421 + 422 + // Setup HTTP server 423 + r := chi.NewRouter() 424 + routes.RegisterUserBlockRoutes(r, service, e2eAuth.OAuthAuthMiddleware) 425 + httpServer := httptest.NewServer(r) 426 + defer httpServer.Close() 427 + 428 + // Attempt to self-block 429 + t.Logf("\nAttempting to block self (should fail)...") 430 + 431 + selfBlockReq := map[string]interface{}{ 432 + "subject": userADID, 433 + } 434 + 435 + reqBody, marshalErr := json.Marshal(selfBlockReq) 436 + if marshalErr != nil { 437 + t.Fatalf("Failed to marshal self-block request: %v", marshalErr) 438 + } 439 + 440 + req, err := http.NewRequest(http.MethodPost, 441 + httpServer.URL+"/xrpc/social.coves.actor.blockUser", 442 + bytes.NewBuffer(reqBody)) 443 + if err != nil { 444 + t.Fatalf("Failed to create self-block request: %v", err) 445 + } 446 + req.Header.Set("Content-Type", "application/json") 447 + req.Header.Set("Authorization", "Bearer "+tokenA) 448 + 449 + resp, err := http.DefaultClient.Do(req) 450 + if err != nil { 451 + t.Fatalf("Failed to POST self-block: %v", err) 452 + } 453 + defer func() { _ = resp.Body.Close() }() 454 + 455 + // Should return 400 Bad Request 456 + if resp.StatusCode != http.StatusBadRequest { 457 + body, _ := io.ReadAll(resp.Body) 458 + t.Fatalf("Expected 400 for self-block, got %d: %s", resp.StatusCode, string(body)) 459 + } 460 + 461 + // Parse error response 462 + var errResp struct { 463 + Error string `json:"error"` 464 + Message string `json:"message"` 465 + } 466 + if decodeErr := json.NewDecoder(resp.Body).Decode(&errResp); decodeErr != nil { 467 + t.Fatalf("Failed to decode error response: %v", decodeErr) 468 + } 469 + 470 + // Verify error message mentions self-blocking 471 + if !contains(errResp.Message, "cannot block yourself") { 472 + t.Errorf("Expected error about self-blocking, got: %s", errResp.Message) 473 + } 474 + 475 + t.Logf("Self-block correctly prevented:") 476 + t.Logf(" Status: %d", resp.StatusCode) 477 + t.Logf(" Error: %s", errResp.Error) 478 + t.Logf(" Message: %s", errResp.Message) 479 + 480 + t.Logf("\nSELF-BLOCK PREVENTION TEST COMPLETE:") 481 + t.Logf(" User attempted to block themselves") 482 + t.Logf(" Server returned 400 with 'cannot block yourself'") 483 + t.Logf(" No record written to PDS") 484 + }
+696
tests/integration/userblock_enforcement_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/core/comments" 5 + "Coves/internal/core/communityFeeds" 6 + "Coves/internal/core/discover" 7 + "Coves/internal/core/timeline" 8 + "Coves/internal/core/userblocks" 9 + "context" 10 + "database/sql" 11 + "fmt" 12 + "testing" 13 + "time" 14 + 15 + postgresRepo "Coves/internal/db/postgres" 16 + ) 17 + 18 + // TestUserBlock_CommunityFeedFiltering verifies that user block enforcement filters 19 + // blocked users' posts from community feeds when a viewer is authenticated, 20 + // but still shows them to unauthenticated viewers. 21 + func TestUserBlock_CommunityFeedFiltering(t *testing.T) { 22 + if testing.Short() { 23 + t.Skip("Skipping integration test in short mode") 24 + } 25 + 26 + ctx := context.Background() 27 + db := setupTestDB(t) 28 + 29 + testID := time.Now().UnixNano() 30 + 31 + // Unique DIDs for this test to avoid collisions 32 + blockerDID := fmt.Sprintf("did:plc:blocker-feed-%d", testID) 33 + posterDID := fmt.Sprintf("did:plc:poster-feed-%d", testID) 34 + // Third-party user whose posts should always be visible (positive assertion) 35 + thirdPartyDID := fmt.Sprintf("did:plc:thirdparty-feed-%d", testID) 36 + communityName := fmt.Sprintf("blockfeed-%d", testID) 37 + ownerHandle := fmt.Sprintf("blockfeed-owner-%d.test", testID) 38 + 39 + // Cleanup test data after the test 40 + defer func() { 41 + cleanupUserBlockEnforcementTestData(t, db, blockerDID, posterDID, communityName, ownerHandle) 42 + // Also clean up third-party user 43 + _, _ = db.Exec("DELETE FROM users WHERE did = $1", thirdPartyDID) 44 + if err := db.Close(); err != nil { 45 + t.Logf("Failed to close database: %v", err) 46 + } 47 + }() 48 + 49 + // Setup: Create test users 50 + for _, u := range []struct{ did, handle string }{ 51 + {blockerDID, fmt.Sprintf("blocker-%d.bsky.social", testID)}, 52 + {posterDID, fmt.Sprintf("poster-%d.bsky.social", testID)}, 53 + {thirdPartyDID, fmt.Sprintf("thirdparty-%d.bsky.social", testID)}, 54 + } { 55 + _, err := db.ExecContext(ctx, ` 56 + INSERT INTO users (did, handle, pds_url, created_at) 57 + VALUES ($1, $2, $3, NOW()) 58 + ON CONFLICT (did) DO NOTHING 59 + `, u.did, u.handle, getTestPDSURL()) 60 + if err != nil { 61 + t.Fatalf("Failed to create user %s: %v", u.handle, err) 62 + } 63 + } 64 + 65 + // Setup: Create community 66 + communityDID, err := createFeedTestCommunity(db, ctx, communityName, ownerHandle) 67 + if err != nil { 68 + t.Fatalf("Failed to create test community: %v", err) 69 + } 70 + 71 + // Setup: Create posts by poster and third-party 72 + post1URI := createTestPost(t, db, communityDID, posterDID, "Post by poster 1", 10, time.Now().Add(-3*time.Hour)) 73 + post2URI := createTestPost(t, db, communityDID, posterDID, "Post by poster 2", 20, time.Now().Add(-2*time.Hour)) 74 + thirdPartyPostURI := createTestPost(t, db, communityDID, thirdPartyDID, "Post by third party", 15, time.Now().Add(-1*time.Hour)) 75 + 76 + // Create the feed repo (testing at repo level, not handler level) 77 + feedRepo := postgresRepo.NewCommunityFeedRepository(db, "test-secret") 78 + 79 + // Step 1: Query community feed WITHOUT ViewerDID - should see all posts 80 + t.Run("unauthenticated sees all posts before block", func(t *testing.T) { 81 + req := communityFeeds.GetCommunityFeedRequest{ 82 + Community: communityDID, 83 + Sort: "new", 84 + Limit: 50, 85 + } 86 + 87 + posts, _, err := feedRepo.GetCommunityFeed(ctx, req) 88 + if err != nil { 89 + t.Fatalf("GetCommunityFeed failed: %v", err) 90 + } 91 + 92 + if !feedContainsPost(posts, post1URI) { 93 + t.Errorf("Expected to find post1 (%s) in unauthenticated feed", post1URI) 94 + } 95 + if !feedContainsPost(posts, post2URI) { 96 + t.Errorf("Expected to find post2 (%s) in unauthenticated feed", post2URI) 97 + } 98 + if !feedContainsPost(posts, thirdPartyPostURI) { 99 + t.Errorf("Expected to find third-party post (%s) in unauthenticated feed", thirdPartyPostURI) 100 + } 101 + }) 102 + 103 + // Step 2: Create a block (blocker blocks poster) 104 + _, err = db.ExecContext(ctx, ` 105 + INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) 106 + VALUES ($1, $2, $3, $4) 107 + `, blockerDID, posterDID, 108 + fmt.Sprintf("at://%s/social.coves.actor.block/block1", blockerDID), 109 + "bafyblocktest1") 110 + if err != nil { 111 + t.Fatalf("Failed to insert user block: %v", err) 112 + } 113 + 114 + // Step 3: Blocker should NOT see poster's posts, but SHOULD still see third-party posts 115 + t.Run("blocker does not see blocked user posts but sees others", func(t *testing.T) { 116 + req := communityFeeds.GetCommunityFeedRequest{ 117 + Community: communityDID, 118 + ViewerDID: blockerDID, 119 + Sort: "new", 120 + Limit: 50, 121 + } 122 + 123 + posts, _, err := feedRepo.GetCommunityFeed(ctx, req) 124 + if err != nil { 125 + t.Fatalf("GetCommunityFeed with ViewerDID failed: %v", err) 126 + } 127 + 128 + if feedContainsPost(posts, post1URI) { 129 + t.Errorf("Blocker should NOT see post1 (%s) from blocked user", post1URI) 130 + } 131 + if feedContainsPost(posts, post2URI) { 132 + t.Errorf("Blocker should NOT see post2 (%s) from blocked user", post2URI) 133 + } 134 + // Positive assertion: non-blocked third-party posts must still be visible 135 + if !feedContainsPost(posts, thirdPartyPostURI) { 136 + t.Errorf("Blocker should still see third-party post (%s)", thirdPartyPostURI) 137 + } 138 + }) 139 + 140 + // Step 4: Blocked user (poster) can still see blocker's content (one-directional block) 141 + t.Run("blocked user still sees blocker content (one-directional)", func(t *testing.T) { 142 + // Create a post by the blocker so the blocked user can potentially see it 143 + blockerPostURI := createTestPost(t, db, communityDID, blockerDID, "Post by blocker", 5, time.Now()) 144 + 145 + req := communityFeeds.GetCommunityFeedRequest{ 146 + Community: communityDID, 147 + ViewerDID: posterDID, 148 + Sort: "new", 149 + Limit: 50, 150 + } 151 + 152 + posts, _, err := feedRepo.GetCommunityFeed(ctx, req) 153 + if err != nil { 154 + t.Fatalf("GetCommunityFeed with blocked user as viewer failed: %v", err) 155 + } 156 + 157 + if !feedContainsPost(posts, blockerPostURI) { 158 + t.Errorf("Blocked user should still see blocker's post (%s) — block is one-directional", blockerPostURI) 159 + } 160 + }) 161 + 162 + // Step 5: Unauthenticated user still sees all posts after block 163 + t.Run("unauthenticated still sees all posts after block", func(t *testing.T) { 164 + req := communityFeeds.GetCommunityFeedRequest{ 165 + Community: communityDID, 166 + Sort: "new", 167 + Limit: 50, 168 + } 169 + 170 + posts, _, err := feedRepo.GetCommunityFeed(ctx, req) 171 + if err != nil { 172 + t.Fatalf("GetCommunityFeed without ViewerDID failed: %v", err) 173 + } 174 + 175 + if !feedContainsPost(posts, post1URI) { 176 + t.Errorf("Unauthenticated user should still see post1 (%s) after block", post1URI) 177 + } 178 + if !feedContainsPost(posts, post2URI) { 179 + t.Errorf("Unauthenticated user should still see post2 (%s) after block", post2URI) 180 + } 181 + }) 182 + } 183 + 184 + // TestUserBlock_DiscoverFeedFiltering verifies that user block enforcement filters 185 + // blocked users' posts from the discover feed when a viewer is authenticated, 186 + // but still shows them to unauthenticated viewers. 187 + func TestUserBlock_DiscoverFeedFiltering(t *testing.T) { 188 + if testing.Short() { 189 + t.Skip("Skipping integration test in short mode") 190 + } 191 + 192 + ctx := context.Background() 193 + db := setupTestDB(t) 194 + 195 + testID := time.Now().UnixNano() 196 + 197 + blockerDID := fmt.Sprintf("did:plc:blocker-discover-%d", testID) 198 + posterDID := fmt.Sprintf("did:plc:poster-discover-%d", testID) 199 + thirdPartyDID := fmt.Sprintf("did:plc:thirdparty-discover-%d", testID) 200 + communityName := fmt.Sprintf("blockdisc-%d", testID) 201 + ownerHandle := fmt.Sprintf("blockdisc-owner-%d.test", testID) 202 + 203 + defer func() { 204 + cleanupUserBlockEnforcementTestData(t, db, blockerDID, posterDID, communityName, ownerHandle) 205 + _, _ = db.Exec("DELETE FROM users WHERE did = $1", thirdPartyDID) 206 + if err := db.Close(); err != nil { 207 + t.Logf("Failed to close database: %v", err) 208 + } 209 + }() 210 + 211 + // Setup: Create test users 212 + for _, u := range []struct{ did, handle string }{ 213 + {blockerDID, fmt.Sprintf("blocker-%d.bsky.social", testID)}, 214 + {posterDID, fmt.Sprintf("poster-%d.bsky.social", testID)}, 215 + {thirdPartyDID, fmt.Sprintf("thirdparty-%d.bsky.social", testID)}, 216 + } { 217 + _, err := db.ExecContext(ctx, ` 218 + INSERT INTO users (did, handle, pds_url, created_at) 219 + VALUES ($1, $2, $3, NOW()) 220 + ON CONFLICT (did) DO NOTHING 221 + `, u.did, u.handle, getTestPDSURL()) 222 + if err != nil { 223 + t.Fatalf("Failed to create user %s: %v", u.handle, err) 224 + } 225 + } 226 + 227 + // Setup: Create community 228 + communityDID, err := createFeedTestCommunity(db, ctx, communityName, ownerHandle) 229 + if err != nil { 230 + t.Fatalf("Failed to create test community: %v", err) 231 + } 232 + 233 + // Setup: Create posts by poster and third-party 234 + post1URI := createTestPost(t, db, communityDID, posterDID, "Discover post 1", 15, time.Now().Add(-3*time.Hour)) 235 + post2URI := createTestPost(t, db, communityDID, posterDID, "Discover post 2", 25, time.Now().Add(-2*time.Hour)) 236 + thirdPartyPostURI := createTestPost(t, db, communityDID, thirdPartyDID, "Discover third party", 20, time.Now().Add(-1*time.Hour)) 237 + 238 + // Create the discover repo 239 + discoverRepo := postgresRepo.NewDiscoverRepository(db, "test-secret") 240 + 241 + // Step 1: Query discover feed WITHOUT ViewerDID - should see all posts 242 + t.Run("unauthenticated sees all posts before block", func(t *testing.T) { 243 + req := discover.GetDiscoverRequest{ 244 + Sort: "new", 245 + Limit: 50, 246 + } 247 + 248 + posts, _, err := discoverRepo.GetDiscover(ctx, req) 249 + if err != nil { 250 + t.Fatalf("GetDiscover failed: %v", err) 251 + } 252 + 253 + if !discoverFeedContainsPost(posts, post1URI) { 254 + t.Errorf("Expected to find post1 (%s) in unauthenticated discover feed", post1URI) 255 + } 256 + if !discoverFeedContainsPost(posts, post2URI) { 257 + t.Errorf("Expected to find post2 (%s) in unauthenticated discover feed", post2URI) 258 + } 259 + if !discoverFeedContainsPost(posts, thirdPartyPostURI) { 260 + t.Errorf("Expected to find third-party post (%s) in unauthenticated discover feed", thirdPartyPostURI) 261 + } 262 + }) 263 + 264 + // Step 2: Create a block (blocker blocks poster) 265 + _, err = db.ExecContext(ctx, ` 266 + INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) 267 + VALUES ($1, $2, $3, $4) 268 + `, blockerDID, posterDID, 269 + fmt.Sprintf("at://%s/social.coves.actor.block/block1", blockerDID), 270 + "bafyblockdiscover1") 271 + if err != nil { 272 + t.Fatalf("Failed to insert user block: %v", err) 273 + } 274 + 275 + // Step 3: Blocker should NOT see poster's posts, but SHOULD still see third-party posts 276 + t.Run("blocker does not see blocked user posts but sees others", func(t *testing.T) { 277 + req := discover.GetDiscoverRequest{ 278 + ViewerDID: blockerDID, 279 + Sort: "new", 280 + Limit: 50, 281 + } 282 + 283 + posts, _, err := discoverRepo.GetDiscover(ctx, req) 284 + if err != nil { 285 + t.Fatalf("GetDiscover with ViewerDID failed: %v", err) 286 + } 287 + 288 + if discoverFeedContainsPost(posts, post1URI) { 289 + t.Errorf("Blocker should NOT see post1 (%s) from blocked user in discover", post1URI) 290 + } 291 + if discoverFeedContainsPost(posts, post2URI) { 292 + t.Errorf("Blocker should NOT see post2 (%s) from blocked user in discover", post2URI) 293 + } 294 + // Positive assertion: non-blocked third-party posts must still be visible 295 + if !discoverFeedContainsPost(posts, thirdPartyPostURI) { 296 + t.Errorf("Blocker should still see third-party post (%s) in discover", thirdPartyPostURI) 297 + } 298 + }) 299 + 300 + // Step 4: Unauthenticated user still sees all posts after block 301 + t.Run("unauthenticated still sees all posts after block", func(t *testing.T) { 302 + req := discover.GetDiscoverRequest{ 303 + Sort: "new", 304 + Limit: 50, 305 + } 306 + 307 + posts, _, err := discoverRepo.GetDiscover(ctx, req) 308 + if err != nil { 309 + t.Fatalf("GetDiscover without ViewerDID failed: %v", err) 310 + } 311 + 312 + if !discoverFeedContainsPost(posts, post1URI) { 313 + t.Errorf("Unauthenticated user should still see post1 (%s) after block in discover", post1URI) 314 + } 315 + if !discoverFeedContainsPost(posts, post2URI) { 316 + t.Errorf("Unauthenticated user should still see post2 (%s) after block in discover", post2URI) 317 + } 318 + }) 319 + } 320 + 321 + // TestUserBlock_ProfileViewerState verifies that the user block repository correctly 322 + // returns block records, confirming that GetBlock returns a RecordURI when a block exists. 323 + func TestUserBlock_ProfileViewerState(t *testing.T) { 324 + if testing.Short() { 325 + t.Skip("Skipping integration test in short mode") 326 + } 327 + 328 + ctx := context.Background() 329 + db := setupTestDB(t) 330 + 331 + testID := time.Now().UnixNano() 332 + 333 + userA := fmt.Sprintf("did:plc:profile-viewer-a-%d", testID) 334 + userB := fmt.Sprintf("did:plc:profile-viewer-b-%d", testID) 335 + expectedRecordURI := fmt.Sprintf("at://%s/social.coves.actor.block/profileblock1", userA) 336 + 337 + defer func() { 338 + _, _ = db.Exec("DELETE FROM user_blocks WHERE blocker_did = $1", userA) 339 + if err := db.Close(); err != nil { 340 + t.Logf("Failed to close database: %v", err) 341 + } 342 + }() 343 + 344 + // Create the user block repo 345 + userBlockRepo := postgresRepo.NewUserBlockRepository(db) 346 + 347 + // Step 1: Verify no block exists initially 348 + t.Run("no block exists initially", func(t *testing.T) { 349 + _, err := userBlockRepo.GetBlock(ctx, userA, userB) 350 + if !userblocks.IsNotFound(err) { 351 + t.Errorf("Expected ErrBlockNotFound before creating block, got: %v", err) 352 + } 353 + }) 354 + 355 + // Step 2: Create a block (User A blocks User B) via raw SQL 356 + _, err := db.ExecContext(ctx, ` 357 + INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) 358 + VALUES ($1, $2, $3, $4) 359 + `, userA, userB, expectedRecordURI, "bafyprofileblock1") 360 + if err != nil { 361 + t.Fatalf("Failed to insert user block: %v", err) 362 + } 363 + 364 + // Step 3: Verify block exists via repo.GetBlock 365 + t.Run("GetBlock returns block with correct RecordURI", func(t *testing.T) { 366 + block, err := userBlockRepo.GetBlock(ctx, userA, userB) 367 + if err != nil { 368 + t.Fatalf("GetBlock failed: %v", err) 369 + } 370 + 371 + if block.BlockerDID != userA { 372 + t.Errorf("Expected BlockerDID=%s, got %s", userA, block.BlockerDID) 373 + } 374 + if block.BlockedDID != userB { 375 + t.Errorf("Expected BlockedDID=%s, got %s", userB, block.BlockedDID) 376 + } 377 + if block.RecordURI != expectedRecordURI { 378 + t.Errorf("Expected RecordURI=%s, got %s", expectedRecordURI, block.RecordURI) 379 + } 380 + if block.RecordURI == "" { 381 + t.Error("RecordURI should not be empty - needed for profile viewer.blocking hydration") 382 + } 383 + }) 384 + 385 + // Step 4: Verify the reverse direction does not have a block 386 + t.Run("reverse direction has no block", func(t *testing.T) { 387 + _, err := userBlockRepo.GetBlock(ctx, userB, userA) 388 + if !userblocks.IsNotFound(err) { 389 + t.Errorf("Expected ErrBlockNotFound for reverse direction (B->A), got: %v", err) 390 + } 391 + }) 392 + } 393 + 394 + // TestUserBlock_CommentFiltering verifies that comments from blocked users are 395 + // filtered out when querying with a viewerDID. 396 + func TestUserBlock_CommentFiltering(t *testing.T) { 397 + if testing.Short() { 398 + t.Skip("Skipping integration test in short mode") 399 + } 400 + 401 + ctx := context.Background() 402 + db := setupTestDB(t) 403 + 404 + testID := time.Now().UnixNano() 405 + 406 + blockerDID := fmt.Sprintf("did:plc:blocker-comment-%d", testID) 407 + commenterDID := fmt.Sprintf("did:plc:commenter-%d", testID) 408 + otherCommenterDID := fmt.Sprintf("did:plc:othercommenter-%d", testID) 409 + 410 + defer func() { 411 + _, _ = db.Exec("DELETE FROM comments WHERE commenter_did IN ($1, $2)", commenterDID, otherCommenterDID) 412 + _, _ = db.Exec("DELETE FROM user_blocks WHERE blocker_did = $1", blockerDID) 413 + _, _ = db.Exec("DELETE FROM users WHERE did IN ($1, $2, $3)", blockerDID, commenterDID, otherCommenterDID) 414 + if err := db.Close(); err != nil { 415 + t.Logf("Failed to close database: %v", err) 416 + } 417 + }() 418 + 419 + // Create users 420 + for _, u := range []struct{ did, handle string }{ 421 + {blockerDID, fmt.Sprintf("blocker-%d.bsky.social", testID)}, 422 + {commenterDID, fmt.Sprintf("commenter-%d.bsky.social", testID)}, 423 + {otherCommenterDID, fmt.Sprintf("other-%d.bsky.social", testID)}, 424 + } { 425 + _, err := db.ExecContext(ctx, ` 426 + INSERT INTO users (did, handle, pds_url, created_at) 427 + VALUES ($1, $2, $3, NOW()) 428 + ON CONFLICT (did) DO NOTHING 429 + `, u.did, u.handle, getTestPDSURL()) 430 + if err != nil { 431 + t.Fatalf("Failed to create user %s: %v", u.handle, err) 432 + } 433 + } 434 + 435 + // Create a parent post URI (the comment's parent) 436 + parentURI := fmt.Sprintf("at://did:plc:somepost/social.coves.community.post/parent%d", testID) 437 + 438 + // Insert comments from blocked commenter and non-blocked commenter 439 + commentRepo := postgresRepo.NewCommentRepository(db) 440 + 441 + blockedCommentURI := fmt.Sprintf("at://%s/social.coves.community.comment/c1%d", commenterDID, testID) 442 + if err := commentRepo.Create(ctx, &comments.Comment{ 443 + URI: blockedCommentURI, 444 + CID: "bafyblocked-comment", 445 + RKey: fmt.Sprintf("c1%d", testID), 446 + CommenterDID: commenterDID, 447 + RootURI: parentURI, 448 + RootCID: "bafyroot", 449 + ParentURI: parentURI, 450 + ParentCID: "bafyparent", 451 + Content: "Comment from blocked user", 452 + CreatedAt: time.Now().Add(-1 * time.Hour), 453 + }); err != nil { 454 + t.Fatalf("Failed to create blocked user comment: %v", err) 455 + } 456 + 457 + otherCommentURI := fmt.Sprintf("at://%s/social.coves.community.comment/c2%d", otherCommenterDID, testID) 458 + if err := commentRepo.Create(ctx, &comments.Comment{ 459 + URI: otherCommentURI, 460 + CID: "bafyother-comment", 461 + RKey: fmt.Sprintf("c2%d", testID), 462 + CommenterDID: otherCommenterDID, 463 + RootURI: parentURI, 464 + RootCID: "bafyroot", 465 + ParentURI: parentURI, 466 + ParentCID: "bafyparent", 467 + Content: "Comment from non-blocked user", 468 + CreatedAt: time.Now(), 469 + }); err != nil { 470 + t.Fatalf("Failed to create other user comment: %v", err) 471 + } 472 + 473 + // Verify both comments visible without block 474 + t.Run("without block both comments visible", func(t *testing.T) { 475 + result, _, err := commentRepo.ListByParentWithHotRank(ctx, parentURI, "new", "", 50, nil, blockerDID) 476 + if err != nil { 477 + t.Fatalf("ListByParentWithHotRank failed: %v", err) 478 + } 479 + if !commentListContains(result, blockedCommentURI) { 480 + t.Errorf("Expected to find blocked user's comment before block") 481 + } 482 + if !commentListContains(result, otherCommentURI) { 483 + t.Errorf("Expected to find other user's comment before block") 484 + } 485 + }) 486 + 487 + // Create block 488 + _, err := db.ExecContext(ctx, ` 489 + INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) 490 + VALUES ($1, $2, $3, $4) 491 + `, blockerDID, commenterDID, 492 + fmt.Sprintf("at://%s/social.coves.actor.block/cb%d", blockerDID, testID), 493 + "bafycommentblock") 494 + if err != nil { 495 + t.Fatalf("Failed to insert user block: %v", err) 496 + } 497 + 498 + // Verify blocked commenter's comment is filtered 499 + t.Run("blocker does not see blocked user comments", func(t *testing.T) { 500 + result, _, err := commentRepo.ListByParentWithHotRank(ctx, parentURI, "new", "", 50, nil, blockerDID) 501 + if err != nil { 502 + t.Fatalf("ListByParentWithHotRank failed: %v", err) 503 + } 504 + if commentListContains(result, blockedCommentURI) { 505 + t.Errorf("Blocker should NOT see blocked user's comment") 506 + } 507 + // Positive assertion: non-blocked comment must remain visible 508 + if !commentListContains(result, otherCommentURI) { 509 + t.Errorf("Blocker should still see non-blocked user's comment") 510 + } 511 + }) 512 + } 513 + 514 + // TestUserBlock_TimelineFeedFiltering verifies that user block enforcement filters 515 + // blocked users' posts from the authenticated user's timeline feed. 516 + func TestUserBlock_TimelineFeedFiltering(t *testing.T) { 517 + if testing.Short() { 518 + t.Skip("Skipping integration test in short mode") 519 + } 520 + 521 + ctx := context.Background() 522 + db := setupTestDB(t) 523 + 524 + testID := time.Now().UnixNano() 525 + 526 + viewerDID := fmt.Sprintf("did:plc:viewer-timeline-%d", testID) 527 + blockedAuthorDID := fmt.Sprintf("did:plc:blocked-timeline-%d", testID) 528 + otherAuthorDID := fmt.Sprintf("did:plc:other-timeline-%d", testID) 529 + communityName := fmt.Sprintf("blocktl-%d", testID) 530 + ownerHandle := fmt.Sprintf("blocktl-owner-%d.test", testID) 531 + 532 + defer func() { 533 + communityDID := fmt.Sprintf("did:plc:community-%s", communityName) 534 + _, _ = db.Exec("DELETE FROM user_blocks WHERE blocker_did = $1", viewerDID) 535 + _, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did = $1", viewerDID) 536 + _, _ = db.Exec("DELETE FROM posts WHERE community_did = $1", communityDID) 537 + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) 538 + ownerDID := fmt.Sprintf("did:plc:%s", ownerHandle) 539 + _, _ = db.Exec("DELETE FROM users WHERE did IN ($1, $2, $3, $4)", viewerDID, blockedAuthorDID, otherAuthorDID, ownerDID) 540 + if err := db.Close(); err != nil { 541 + t.Logf("Failed to close database: %v", err) 542 + } 543 + }() 544 + 545 + // Create users 546 + for _, u := range []struct{ did, handle string }{ 547 + {viewerDID, fmt.Sprintf("viewer-%d.bsky.social", testID)}, 548 + {blockedAuthorDID, fmt.Sprintf("blocked-%d.bsky.social", testID)}, 549 + {otherAuthorDID, fmt.Sprintf("other-%d.bsky.social", testID)}, 550 + } { 551 + _, err := db.ExecContext(ctx, ` 552 + INSERT INTO users (did, handle, pds_url, created_at) 553 + VALUES ($1, $2, $3, NOW()) 554 + ON CONFLICT (did) DO NOTHING 555 + `, u.did, u.handle, getTestPDSURL()) 556 + if err != nil { 557 + t.Fatalf("Failed to create user %s: %v", u.handle, err) 558 + } 559 + } 560 + 561 + // Create community 562 + communityDID, err := createFeedTestCommunity(db, ctx, communityName, ownerHandle) 563 + if err != nil { 564 + t.Fatalf("Failed to create test community: %v", err) 565 + } 566 + 567 + // Subscribe viewer to the community (required for timeline) 568 + _, err = db.ExecContext(ctx, ` 569 + INSERT INTO community_subscriptions (user_did, community_did, subscribed_at) 570 + VALUES ($1, $2, NOW()) 571 + ON CONFLICT DO NOTHING 572 + `, viewerDID, communityDID) 573 + if err != nil { 574 + t.Fatalf("Failed to create community subscription: %v", err) 575 + } 576 + 577 + // Create posts 578 + blockedPostURI := createTestPost(t, db, communityDID, blockedAuthorDID, "Post from blocked author", 10, time.Now().Add(-2*time.Hour)) 579 + otherPostURI := createTestPost(t, db, communityDID, otherAuthorDID, "Post from other author", 15, time.Now().Add(-1*time.Hour)) 580 + 581 + // Create timeline repo 582 + timelineRepo := postgresRepo.NewTimelineRepository(db, "test-secret") 583 + 584 + // Step 1: Verify both posts visible before block 585 + t.Run("both posts visible before block", func(t *testing.T) { 586 + req := timeline.GetTimelineRequest{ 587 + UserDID: viewerDID, 588 + Sort: "new", 589 + Limit: 50, 590 + } 591 + posts, _, err := timelineRepo.GetTimeline(ctx, req) 592 + if err != nil { 593 + t.Fatalf("GetTimeline failed: %v", err) 594 + } 595 + if !timelineContainsPost(posts, blockedPostURI) { 596 + t.Errorf("Expected to find blocked author's post before block") 597 + } 598 + if !timelineContainsPost(posts, otherPostURI) { 599 + t.Errorf("Expected to find other author's post before block") 600 + } 601 + }) 602 + 603 + // Step 2: Create block 604 + _, err = db.ExecContext(ctx, ` 605 + INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) 606 + VALUES ($1, $2, $3, $4) 607 + `, viewerDID, blockedAuthorDID, 608 + fmt.Sprintf("at://%s/social.coves.actor.block/tlblock%d", viewerDID, testID), 609 + "bafytimelineblock") 610 + if err != nil { 611 + t.Fatalf("Failed to insert user block: %v", err) 612 + } 613 + 614 + // Step 3: Verify blocked author's post is filtered from timeline 615 + t.Run("blocked author posts filtered from timeline", func(t *testing.T) { 616 + req := timeline.GetTimelineRequest{ 617 + UserDID: viewerDID, 618 + Sort: "new", 619 + Limit: 50, 620 + } 621 + posts, _, err := timelineRepo.GetTimeline(ctx, req) 622 + if err != nil { 623 + t.Fatalf("GetTimeline with block failed: %v", err) 624 + } 625 + if timelineContainsPost(posts, blockedPostURI) { 626 + t.Errorf("Blocked author's post should NOT appear in viewer's timeline") 627 + } 628 + // Positive assertion: non-blocked author's post must remain visible 629 + if !timelineContainsPost(posts, otherPostURI) { 630 + t.Errorf("Non-blocked author's post should still appear in viewer's timeline") 631 + } 632 + }) 633 + } 634 + 635 + // commentListContains checks if a list of comments contains one with the given URI 636 + func commentListContains(list []*comments.Comment, uri string) bool { 637 + for _, c := range list { 638 + if c.URI == uri { 639 + return true 640 + } 641 + } 642 + return false 643 + } 644 + 645 + // timelineContainsPost checks if a timeline feed result contains a post with the given URI 646 + func timelineContainsPost(posts []*timeline.FeedViewPost, uri string) bool { 647 + for _, p := range posts { 648 + if p.Post != nil && p.Post.URI == uri { 649 + return true 650 + } 651 + } 652 + return false 653 + } 654 + 655 + // feedContainsPost checks if a community feed result contains a post with the given URI 656 + func feedContainsPost(posts []*communityFeeds.FeedViewPost, uri string) bool { 657 + for _, p := range posts { 658 + if p.Post != nil && p.Post.URI == uri { 659 + return true 660 + } 661 + } 662 + return false 663 + } 664 + 665 + // discoverFeedContainsPost checks if a discover feed result contains a post with the given URI 666 + func discoverFeedContainsPost(posts []*discover.FeedViewPost, uri string) bool { 667 + for _, p := range posts { 668 + if p.Post != nil && p.Post.URI == uri { 669 + return true 670 + } 671 + } 672 + return false 673 + } 674 + 675 + // cleanupUserBlockEnforcementTestData removes test data created by block enforcement tests 676 + func cleanupUserBlockEnforcementTestData(t *testing.T, db *sql.DB, blockerDID, posterDID, communityName, ownerHandle string) { 677 + t.Helper() 678 + 679 + communityDID := fmt.Sprintf("did:plc:community-%s", communityName) 680 + // createFeedTestCommunity builds ownerDID as "did:plc:{ownerHandle}" — match that here 681 + ownerDID := fmt.Sprintf("did:plc:%s", ownerHandle) 682 + 683 + // Delete in dependency order: blocks, posts, community subscriptions, communities, users 684 + if _, err := db.Exec("DELETE FROM user_blocks WHERE blocker_did = $1 AND blocked_did = $2", blockerDID, posterDID); err != nil { 685 + t.Logf("Warning: Failed to clean up user blocks: %v", err) 686 + } 687 + if _, err := db.Exec("DELETE FROM posts WHERE community_did = $1", communityDID); err != nil { 688 + t.Logf("Warning: Failed to clean up posts: %v", err) 689 + } 690 + if _, err := db.Exec("DELETE FROM communities WHERE did = $1", communityDID); err != nil { 691 + t.Logf("Warning: Failed to clean up communities: %v", err) 692 + } 693 + if _, err := db.Exec("DELETE FROM users WHERE did IN ($1, $2, $3)", blockerDID, posterDID, ownerDID); err != nil { 694 + t.Logf("Warning: Failed to clean up users: %v", err) 695 + } 696 + }
+649
tests/integration/userblock_handler_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/api/routes" 5 + "Coves/internal/atproto/pds" 6 + "Coves/internal/core/blobs" 7 + "Coves/internal/core/userblocks" 8 + "Coves/internal/db/postgres" 9 + "bytes" 10 + "context" 11 + "encoding/json" 12 + "fmt" 13 + "io" 14 + "net/http" 15 + "net/http/httptest" 16 + "strings" 17 + "sync" 18 + "testing" 19 + "time" 20 + 21 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 22 + "github.com/go-chi/chi/v5" 23 + ) 24 + 25 + // mockPDSClient implements pds.Client for handler tests without a real PDS. 26 + // Tracks created and deleted records for verification in tests. 27 + type mockPDSClient struct { 28 + did string 29 + mu sync.Mutex 30 + createdRecords map[string]bool 31 + deletedRecords []string // collection/rkey pairs 32 + createErr error // if set, CreateRecord returns this error 33 + } 34 + 35 + func newMockPDSClient(did string) *mockPDSClient { 36 + return &mockPDSClient{ 37 + did: did, 38 + createdRecords: make(map[string]bool), 39 + } 40 + } 41 + 42 + func (m *mockPDSClient) CreateRecord(_ context.Context, collection string, rkey string, _ any) (string, string, error) { 43 + m.mu.Lock() 44 + defer m.mu.Unlock() 45 + if m.createErr != nil { 46 + return "", "", m.createErr 47 + } 48 + uri := fmt.Sprintf("at://%s/%s/%s", m.did, collection, rkey) 49 + m.createdRecords[uri] = true 50 + return uri, "bafymockrecordcid", nil 51 + } 52 + 53 + func (m *mockPDSClient) DeleteRecord(_ context.Context, collection string, rkey string) error { 54 + m.mu.Lock() 55 + m.deletedRecords = append(m.deletedRecords, collection+"/"+rkey) 56 + m.mu.Unlock() 57 + return nil 58 + } 59 + 60 + func (m *mockPDSClient) ListRecords(_ context.Context, _ string, _ int, _ string) (*pds.ListRecordsResponse, error) { 61 + return &pds.ListRecordsResponse{}, nil 62 + } 63 + 64 + func (m *mockPDSClient) GetRecord(_ context.Context, _ string, _ string) (*pds.RecordResponse, error) { 65 + return &pds.RecordResponse{}, nil 66 + } 67 + 68 + func (m *mockPDSClient) PutRecord(_ context.Context, _ string, _ string, _ any, _ string) (string, string, error) { 69 + return "", "", nil 70 + } 71 + 72 + func (m *mockPDSClient) UploadBlob(_ context.Context, _ []byte, _ string) (*blobs.BlobRef, error) { 73 + return nil, nil 74 + } 75 + 76 + func (m *mockPDSClient) DID() string { 77 + return m.did 78 + } 79 + 80 + func (m *mockPDSClient) HostURL() string { 81 + return "http://localhost:3001" 82 + } 83 + 84 + // DeleteCallCount returns the number of DeleteRecord calls made. 85 + func (m *mockPDSClient) DeleteCallCount() int { 86 + m.mu.Lock() 87 + defer m.mu.Unlock() 88 + return len(m.deletedRecords) 89 + } 90 + 91 + // mockPDSTracker manages per-session mock PDS clients so tests can inspect PDS interactions. 92 + type mockPDSTracker struct { 93 + mu sync.Mutex 94 + clients map[string]*mockPDSClient // DID -> client 95 + } 96 + 97 + func newMockPDSTracker() *mockPDSTracker { 98 + return &mockPDSTracker{clients: make(map[string]*mockPDSClient)} 99 + } 100 + 101 + // Factory returns a PDSClientFactory that creates per-session mock clients. 102 + func (t *mockPDSTracker) Factory() userblocks.PDSClientFactory { 103 + return func(_ context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 104 + did := session.AccountDID.String() 105 + t.mu.Lock() 106 + defer t.mu.Unlock() 107 + if c, ok := t.clients[did]; ok { 108 + return c, nil 109 + } 110 + c := newMockPDSClient(did) 111 + t.clients[did] = c 112 + return c, nil 113 + } 114 + } 115 + 116 + // ClientFor returns the mock PDS client for the given DID. 117 + func (t *mockPDSTracker) ClientFor(did string) *mockPDSClient { 118 + t.mu.Lock() 119 + defer t.mu.Unlock() 120 + return t.clients[did] 121 + } 122 + 123 + // userBlockTestEnv bundles all resources created by setupUserBlockTestServer. 124 + type userBlockTestEnv struct { 125 + Server *httptest.Server 126 + Auth *E2EOAuthMiddleware 127 + Repo userblocks.Repository 128 + PDSTracker *mockPDSTracker 129 + } 130 + 131 + // setupUserBlockTestServer creates a test HTTP server with userblock routes wired up. 132 + func setupUserBlockTestServer(t *testing.T) *userBlockTestEnv { 133 + t.Helper() 134 + 135 + db := setupTestDB(t) 136 + t.Cleanup(func() { 137 + // Clean up user_blocks before closing db 138 + _, _ = db.Exec("DELETE FROM user_blocks") 139 + if err := db.Close(); err != nil { 140 + t.Logf("Failed to close database: %v", err) 141 + } 142 + }) 143 + 144 + // Clean up user_blocks from any prior runs 145 + _, _ = db.Exec("DELETE FROM user_blocks") 146 + 147 + repo := postgres.NewUserBlockRepository(db) 148 + tracker := newMockPDSTracker() 149 + service := userblocks.NewServiceWithPDSFactory(repo, nil, tracker.Factory()) 150 + 151 + e2eAuth := NewE2EOAuthMiddleware() 152 + 153 + r := chi.NewRouter() 154 + routes.RegisterUserBlockRoutes(r, service, e2eAuth.OAuthAuthMiddleware) 155 + 156 + server := httptest.NewServer(r) 157 + t.Cleanup(func() { server.Close() }) 158 + 159 + return &userBlockTestEnv{ 160 + Server: server, 161 + Auth: e2eAuth, 162 + Repo: repo, 163 + PDSTracker: tracker, 164 + } 165 + } 166 + 167 + // postXRPC sends a POST request to an XRPC endpoint and returns the response. 168 + // Fails the test on marshaling or request creation errors. 169 + func postXRPC(t *testing.T, serverURL, path, token string, body any) *http.Response { 170 + t.Helper() 171 + 172 + reqBody, err := json.Marshal(body) 173 + if err != nil { 174 + t.Fatalf("Failed to marshal request body: %v", err) 175 + } 176 + 177 + req, err := http.NewRequest(http.MethodPost, serverURL+path, bytes.NewBuffer(reqBody)) 178 + if err != nil { 179 + t.Fatalf("Failed to create request: %v", err) 180 + } 181 + req.Header.Set("Content-Type", "application/json") 182 + if token != "" { 183 + req.Header.Set("Authorization", "Bearer "+token) 184 + } 185 + 186 + resp, err := http.DefaultClient.Do(req) 187 + if err != nil { 188 + t.Fatalf("Failed to execute request to %s: %v", path, err) 189 + } 190 + 191 + return resp 192 + } 193 + 194 + // getXRPC sends a GET request to an XRPC endpoint and returns the response. 195 + func getXRPC(t *testing.T, serverURL, path, token string) *http.Response { 196 + t.Helper() 197 + 198 + req, err := http.NewRequest(http.MethodGet, serverURL+path, nil) 199 + if err != nil { 200 + t.Fatalf("Failed to create request: %v", err) 201 + } 202 + if token != "" { 203 + req.Header.Set("Authorization", "Bearer "+token) 204 + } 205 + 206 + resp, err := http.DefaultClient.Do(req) 207 + if err != nil { 208 + t.Fatalf("Failed to execute request to %s: %v", path, err) 209 + } 210 + 211 + return resp 212 + } 213 + 214 + // TestUserBlockHandler_BlockUser tests the block user endpoint 215 + func TestUserBlockHandler_BlockUser(t *testing.T) { 216 + env := setupUserBlockTestServer(t) 217 + 218 + blockerDID := "did:plc:blocker123" 219 + targetDID := "did:plc:target456" 220 + token := env.Auth.AddUser(blockerDID) 221 + 222 + resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 223 + "subject": targetDID, 224 + }) 225 + defer func() { _ = resp.Body.Close() }() 226 + 227 + if resp.StatusCode != http.StatusOK { 228 + body, _ := io.ReadAll(resp.Body) 229 + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 230 + } 231 + 232 + var blockResp struct { 233 + Block struct { 234 + RecordURI string `json:"recordUri"` 235 + RecordCID string `json:"recordCid"` 236 + } `json:"block"` 237 + } 238 + 239 + if decodeErr := json.NewDecoder(resp.Body).Decode(&blockResp); decodeErr != nil { 240 + t.Fatalf("Failed to decode response: %v", decodeErr) 241 + } 242 + 243 + if blockResp.Block.RecordURI == "" { 244 + t.Error("Expected non-empty recordUri in response") 245 + } 246 + if blockResp.Block.RecordCID == "" { 247 + t.Error("Expected non-empty recordCid in response") 248 + } 249 + 250 + // Verify the record URI contains the blocker's actual DID (not a hardcoded mock DID) 251 + if !strings.Contains(blockResp.Block.RecordURI, blockerDID) { 252 + t.Errorf("Expected recordUri to contain blocker DID %q, got %q", blockerDID, blockResp.Block.RecordURI) 253 + } 254 + 255 + // Simulate Jetstream indexing by inserting directly into repo 256 + // (In a real E2E test, the Jetstream consumer would handle this) 257 + ctx := context.Background() 258 + _, repoErr := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 259 + BlockerDID: blockerDID, 260 + BlockedDID: targetDID, 261 + BlockedAt: time.Now(), 262 + RecordURI: blockResp.Block.RecordURI, 263 + RecordCID: blockResp.Block.RecordCID, 264 + }) 265 + if repoErr != nil { 266 + t.Fatalf("Failed to index block in repo: %v", repoErr) 267 + } 268 + 269 + // Verify block exists in AppView 270 + isBlocked, checkErr := env.Repo.IsBlocked(ctx, blockerDID, targetDID) 271 + if checkErr != nil { 272 + t.Fatalf("Failed to check block: %v", checkErr) 273 + } 274 + if !isBlocked { 275 + t.Error("Expected user to be blocked after block request") 276 + } 277 + } 278 + 279 + // TestUserBlockHandler_BlockUser_MissingSubject tests blocking with missing subject 280 + func TestUserBlockHandler_BlockUser_MissingSubject(t *testing.T) { 281 + env := setupUserBlockTestServer(t) 282 + 283 + blockerDID := "did:plc:blocker789" 284 + token := env.Auth.AddUser(blockerDID) 285 + 286 + resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{}) 287 + defer func() { _ = resp.Body.Close() }() 288 + 289 + if resp.StatusCode != http.StatusBadRequest { 290 + body, _ := io.ReadAll(resp.Body) 291 + t.Fatalf("Expected 400, got %d: %s", resp.StatusCode, string(body)) 292 + } 293 + 294 + var errResp struct { 295 + Error string `json:"error"` 296 + Message string `json:"message"` 297 + } 298 + if decodeErr := json.NewDecoder(resp.Body).Decode(&errResp); decodeErr != nil { 299 + t.Fatalf("Failed to decode error response: %v", decodeErr) 300 + } 301 + 302 + if errResp.Error != "InvalidRequest" { 303 + t.Errorf("Expected error code 'InvalidRequest', got %q", errResp.Error) 304 + } 305 + if !strings.Contains(errResp.Message, "subject") { 306 + t.Errorf("Expected error message to mention 'subject', got %q", errResp.Message) 307 + } 308 + } 309 + 310 + // TestUserBlockHandler_BlockUser_Unauthenticated tests blocking without auth 311 + func TestUserBlockHandler_BlockUser_Unauthenticated(t *testing.T) { 312 + env := setupUserBlockTestServer(t) 313 + 314 + // No token — unauthenticated request 315 + resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", "", map[string]string{ 316 + "subject": "did:plc:target", 317 + }) 318 + defer func() { _ = resp.Body.Close() }() 319 + 320 + if resp.StatusCode != http.StatusUnauthorized { 321 + body, _ := io.ReadAll(resp.Body) 322 + t.Fatalf("Expected 401, got %d: %s", resp.StatusCode, string(body)) 323 + } 324 + } 325 + 326 + // TestUserBlockHandler_BlockUser_SelfBlock tests that self-blocking returns 400 327 + func TestUserBlockHandler_BlockUser_SelfBlock(t *testing.T) { 328 + env := setupUserBlockTestServer(t) 329 + 330 + selfDID := "did:plc:selfblock" 331 + token := env.Auth.AddUser(selfDID) 332 + 333 + resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 334 + "subject": selfDID, 335 + }) 336 + defer func() { _ = resp.Body.Close() }() 337 + 338 + if resp.StatusCode != http.StatusBadRequest { 339 + body, _ := io.ReadAll(resp.Body) 340 + t.Fatalf("Expected 400, got %d: %s", resp.StatusCode, string(body)) 341 + } 342 + 343 + var errResp struct { 344 + Error string `json:"error"` 345 + Message string `json:"message"` 346 + } 347 + if decodeErr := json.NewDecoder(resp.Body).Decode(&errResp); decodeErr != nil { 348 + t.Fatalf("Failed to decode error response: %v", decodeErr) 349 + } 350 + 351 + if !strings.Contains(errResp.Message, "block yourself") { 352 + t.Errorf("Expected error message about self-blocking, got %q", errResp.Message) 353 + } 354 + } 355 + 356 + // TestUserBlockHandler_UnblockUser tests the unblock user endpoint 357 + func TestUserBlockHandler_UnblockUser(t *testing.T) { 358 + env := setupUserBlockTestServer(t) 359 + 360 + blockerDID := "did:plc:unblocker1" 361 + targetDID := "did:plc:unblockee1" 362 + token := env.Auth.AddUser(blockerDID) 363 + 364 + ctx := context.Background() 365 + 366 + // First, create a block via the API 367 + blockResp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 368 + "subject": targetDID, 369 + }) 370 + 371 + var blockResult struct { 372 + Block struct { 373 + RecordURI string `json:"recordUri"` 374 + RecordCID string `json:"recordCid"` 375 + } `json:"block"` 376 + } 377 + if decodeErr := json.NewDecoder(blockResp.Body).Decode(&blockResult); decodeErr != nil { 378 + t.Fatalf("Failed to decode block response: %v", decodeErr) 379 + } 380 + _ = blockResp.Body.Close() 381 + 382 + // Simulate Jetstream indexing: insert block record into AppView repo 383 + _, repoErr := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 384 + BlockerDID: blockerDID, 385 + BlockedDID: targetDID, 386 + BlockedAt: time.Now(), 387 + RecordURI: blockResult.Block.RecordURI, 388 + RecordCID: blockResult.Block.RecordCID, 389 + }) 390 + if repoErr != nil { 391 + t.Fatalf("Failed to index block: %v", repoErr) 392 + } 393 + 394 + // Verify block exists before unblock 395 + isBlocked, err := env.Repo.IsBlocked(ctx, blockerDID, targetDID) 396 + if err != nil { 397 + t.Fatalf("Failed to check block: %v", err) 398 + } 399 + if !isBlocked { 400 + t.Fatal("Expected block to exist before unblock") 401 + } 402 + 403 + // Now unblock 404 + unblockResp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.unblockUser", token, map[string]string{ 405 + "subject": targetDID, 406 + }) 407 + defer func() { _ = unblockResp.Body.Close() }() 408 + 409 + if unblockResp.StatusCode != http.StatusOK { 410 + body, _ := io.ReadAll(unblockResp.Body) 411 + t.Fatalf("Expected 200, got %d: %s", unblockResp.StatusCode, string(body)) 412 + } 413 + 414 + var unblockResult struct { 415 + Success bool `json:"success"` 416 + } 417 + if decodeErr := json.NewDecoder(unblockResp.Body).Decode(&unblockResult); decodeErr != nil { 418 + t.Fatalf("Failed to decode unblock response: %v", decodeErr) 419 + } 420 + 421 + if !unblockResult.Success { 422 + t.Error("Expected success: true in unblock response") 423 + } 424 + 425 + // Verify PDS DeleteRecord was actually called 426 + mockClient := env.PDSTracker.ClientFor(blockerDID) 427 + if mockClient == nil { 428 + t.Fatal("Expected mock PDS client to exist for blocker") 429 + } 430 + if mockClient.DeleteCallCount() == 0 { 431 + t.Error("Expected PDS DeleteRecord to be called during unblock, but it was never called") 432 + } 433 + 434 + // Simulate Jetstream processing the delete and verify block removal from AppView. 435 + // In production, the Jetstream consumer removes the block from the repo. 436 + // Here we manually remove it to verify the full flow. 437 + if removeErr := env.Repo.UnblockUser(ctx, blockerDID, targetDID); removeErr != nil { 438 + t.Fatalf("Failed to remove block from repo: %v", removeErr) 439 + } 440 + 441 + isBlockedAfter, checkErr := env.Repo.IsBlocked(ctx, blockerDID, targetDID) 442 + if checkErr != nil { 443 + t.Fatalf("Failed to check block after unblock: %v", checkErr) 444 + } 445 + if isBlockedAfter { 446 + t.Error("Expected block to be removed from AppView after unblock") 447 + } 448 + } 449 + 450 + // TestUserBlockHandler_GetBlockedUsers tests listing blocked users 451 + func TestUserBlockHandler_GetBlockedUsers(t *testing.T) { 452 + env := setupUserBlockTestServer(t) 453 + 454 + blockerDID := "did:plc:lister1" 455 + target1DID := "did:plc:listed1" 456 + target2DID := "did:plc:listed2" 457 + token := env.Auth.AddUser(blockerDID) 458 + 459 + ctx := context.Background() 460 + 461 + // Index two blocks directly in the repo (simulating Jetstream indexing) 462 + _, err := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 463 + BlockerDID: blockerDID, 464 + BlockedDID: target1DID, 465 + BlockedAt: time.Now().Add(-1 * time.Minute), 466 + RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/tid1", blockerDID), 467 + RecordCID: "bafyblock1", 468 + }) 469 + if err != nil { 470 + t.Fatalf("Failed to create block 1: %v", err) 471 + } 472 + 473 + _, err = env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 474 + BlockerDID: blockerDID, 475 + BlockedDID: target2DID, 476 + BlockedAt: time.Now(), 477 + RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/tid2", blockerDID), 478 + RecordCID: "bafyblock2", 479 + }) 480 + if err != nil { 481 + t.Fatalf("Failed to create block 2: %v", err) 482 + } 483 + 484 + resp := getXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.getBlockedUsers?limit=10", token) 485 + defer func() { _ = resp.Body.Close() }() 486 + 487 + if resp.StatusCode != http.StatusOK { 488 + body, _ := io.ReadAll(resp.Body) 489 + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) 490 + } 491 + 492 + var listResp struct { 493 + Blocks []struct { 494 + BlockedDID string `json:"blockedDid"` 495 + RecordURI string `json:"recordUri"` 496 + RecordCID string `json:"recordCid"` 497 + } `json:"blocks"` 498 + } 499 + if decodeErr := json.NewDecoder(resp.Body).Decode(&listResp); decodeErr != nil { 500 + t.Fatalf("Failed to decode list response: %v", decodeErr) 501 + } 502 + 503 + if len(listResp.Blocks) != 2 { 504 + t.Fatalf("Expected 2 blocks, got %d", len(listResp.Blocks)) 505 + } 506 + 507 + // Verify both target DIDs are present 508 + foundDIDs := make(map[string]bool) 509 + for _, b := range listResp.Blocks { 510 + foundDIDs[b.BlockedDID] = true 511 + if b.RecordURI == "" { 512 + t.Error("Expected non-empty recordUri") 513 + } 514 + if b.RecordCID == "" { 515 + t.Error("Expected non-empty recordCid") 516 + } 517 + } 518 + 519 + if !foundDIDs[target1DID] { 520 + t.Errorf("Expected %s in blocked list", target1DID) 521 + } 522 + if !foundDIDs[target2DID] { 523 + t.Errorf("Expected %s in blocked list", target2DID) 524 + } 525 + } 526 + 527 + // TestUserBlockHandler_GetBlockedUsers_Unauthenticated tests that getBlockedUsers requires auth 528 + func TestUserBlockHandler_GetBlockedUsers_Unauthenticated(t *testing.T) { 529 + env := setupUserBlockTestServer(t) 530 + 531 + // No token — unauthenticated request 532 + resp := getXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.getBlockedUsers", "") 533 + defer func() { _ = resp.Body.Close() }() 534 + 535 + if resp.StatusCode != http.StatusUnauthorized { 536 + body, _ := io.ReadAll(resp.Body) 537 + t.Fatalf("Expected 401, got %d: %s", resp.StatusCode, string(body)) 538 + } 539 + } 540 + 541 + // TestUserBlockHandler_BlockUser_DuplicateConflict tests that blocking a user who is 542 + // already blocked on PDS returns the existing block (via repo lookup) or 409 Conflict. 543 + func TestUserBlockHandler_BlockUser_DuplicateConflict(t *testing.T) { 544 + env := setupUserBlockTestServer(t) 545 + 546 + blockerDID := "did:plc:conflict-blocker" 547 + targetDID := "did:plc:conflict-target" 548 + token := env.Auth.AddUser(blockerDID) 549 + 550 + ctx := context.Background() 551 + 552 + // Pre-index an existing block in the AppView repo (as if Jetstream already processed it) 553 + existingURI := fmt.Sprintf("at://%s/social.coves.actor.block/existing1", blockerDID) 554 + existingCID := "bafyexistingcid" 555 + _, err := env.Repo.BlockUser(ctx, &userblocks.UserBlock{ 556 + BlockerDID: blockerDID, 557 + BlockedDID: targetDID, 558 + BlockedAt: time.Now(), 559 + RecordURI: existingURI, 560 + RecordCID: existingCID, 561 + }) 562 + if err != nil { 563 + t.Fatalf("Failed to pre-index block: %v", err) 564 + } 565 + 566 + // Configure mock PDS to return conflict error (simulating duplicate record on PDS) 567 + mockClient := env.PDSTracker.ClientFor(blockerDID) 568 + if mockClient == nil { 569 + // Trigger client creation by making any authenticated request first 570 + _ = postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 571 + "subject": "did:plc:dummy", 572 + }) 573 + mockClient = env.PDSTracker.ClientFor(blockerDID) 574 + } 575 + mockClient.mu.Lock() 576 + mockClient.createErr = fmt.Errorf("conflict: %w", pds.ErrConflict) 577 + mockClient.mu.Unlock() 578 + 579 + // Attempt to block the same user again — PDS returns conflict 580 + resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 581 + "subject": targetDID, 582 + }) 583 + defer func() { _ = resp.Body.Close() }() 584 + 585 + // The service should find the existing block in the repo and return it 586 + if resp.StatusCode != http.StatusOK { 587 + body, _ := io.ReadAll(resp.Body) 588 + t.Fatalf("Expected 200 (existing block returned), got %d: %s", resp.StatusCode, string(body)) 589 + } 590 + 591 + var blockResp struct { 592 + Block struct { 593 + RecordURI string `json:"recordUri"` 594 + RecordCID string `json:"recordCid"` 595 + } `json:"block"` 596 + } 597 + if decodeErr := json.NewDecoder(resp.Body).Decode(&blockResp); decodeErr != nil { 598 + t.Fatalf("Failed to decode response: %v", decodeErr) 599 + } 600 + 601 + if blockResp.Block.RecordURI != existingURI { 602 + t.Errorf("Expected existing RecordURI=%s, got %s", existingURI, blockResp.Block.RecordURI) 603 + } 604 + if blockResp.Block.RecordCID != existingCID { 605 + t.Errorf("Expected existing RecordCID=%s, got %s", existingCID, blockResp.Block.RecordCID) 606 + } 607 + } 608 + 609 + // TestUserBlockHandler_BlockUser_DuplicateConflict_NotIndexed tests the 409 path when 610 + // PDS returns conflict but the block hasn't been indexed in AppView yet. 611 + func TestUserBlockHandler_BlockUser_DuplicateConflict_NotIndexed(t *testing.T) { 612 + env := setupUserBlockTestServer(t) 613 + 614 + blockerDID := "did:plc:conflict-noindex-blocker" 615 + targetDID := "did:plc:conflict-noindex-target" 616 + token := env.Auth.AddUser(blockerDID) 617 + 618 + // Force mock PDS client creation, then set conflict error 619 + _ = postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 620 + "subject": "did:plc:warmup", 621 + }) 622 + mockClient := env.PDSTracker.ClientFor(blockerDID) 623 + mockClient.mu.Lock() 624 + mockClient.createErr = fmt.Errorf("conflict: %w", pds.ErrConflict) 625 + mockClient.mu.Unlock() 626 + 627 + // Block target — PDS says conflict, but repo has no block → should return 409 628 + resp := postXRPC(t, env.Server.URL, "/xrpc/social.coves.actor.blockUser", token, map[string]string{ 629 + "subject": targetDID, 630 + }) 631 + defer func() { _ = resp.Body.Close() }() 632 + 633 + if resp.StatusCode != http.StatusConflict { 634 + body, _ := io.ReadAll(resp.Body) 635 + t.Fatalf("Expected 409, got %d: %s", resp.StatusCode, string(body)) 636 + } 637 + 638 + var errResp struct { 639 + Error string `json:"error"` 640 + Message string `json:"message"` 641 + } 642 + if decodeErr := json.NewDecoder(resp.Body).Decode(&errResp); decodeErr != nil { 643 + t.Fatalf("Failed to decode error response: %v", decodeErr) 644 + } 645 + 646 + if errResp.Error != "AlreadyExists" { 647 + t.Errorf("Expected error code 'AlreadyExists', got %q", errResp.Error) 648 + } 649 + }
+268
tests/integration/userblock_indexing_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/atproto/jetstream" 5 + "Coves/internal/core/userblocks" 6 + "context" 7 + "fmt" 8 + "testing" 9 + "time" 10 + 11 + postgresRepo "Coves/internal/db/postgres" 12 + ) 13 + 14 + // TestUserBlockIndexing_CreateEvent tests that a Jetstream CREATE event for 15 + // social.coves.actor.block is properly indexed in the AppView. 16 + func TestUserBlockIndexing_CreateEvent(t *testing.T) { 17 + if testing.Short() { 18 + t.Skip("Skipping integration test in short mode") 19 + } 20 + 21 + ctx := context.Background() 22 + db := setupTestDB(t) 23 + defer cleanupUserBlockTestDB(t, db) 24 + 25 + repo := postgresRepo.NewUserBlockRepository(db) 26 + consumer := createUserBlockConsumer(t, repo) 27 + 28 + blockerDID := "did:plc:test-blocker-create" 29 + blockedDID := "did:plc:test-blocked-create" 30 + rkey := "test-block-create-1" 31 + 32 + // Simulate Jetstream CREATE event 33 + event := &jetstream.JetstreamEvent{ 34 + Did: blockerDID, 35 + Kind: "commit", 36 + TimeUS: time.Now().UnixMicro(), 37 + Commit: &jetstream.CommitEvent{ 38 + Rev: "test-rev-1", 39 + Operation: "create", 40 + Collection: "social.coves.actor.block", 41 + RKey: rkey, 42 + CID: "bafyuserblock123", 43 + Record: map[string]interface{}{ 44 + "$type": "social.coves.actor.block", 45 + "subject": blockedDID, 46 + "createdAt": time.Now().Format(time.RFC3339), 47 + }, 48 + }, 49 + } 50 + 51 + // Process event through the consumer 52 + err := consumer.HandleEvent(ctx, event) 53 + if err != nil { 54 + t.Fatalf("Failed to handle block CREATE event: %v", err) 55 + } 56 + 57 + // Verify block indexed in AppView via repo.GetBlock() 58 + block, err := repo.GetBlock(ctx, blockerDID, blockedDID) 59 + if err != nil { 60 + t.Fatalf("Failed to get block after indexing: %v", err) 61 + } 62 + 63 + if block.BlockerDID != blockerDID { 64 + t.Errorf("Expected blockerDID=%s, got %s", blockerDID, block.BlockerDID) 65 + } 66 + if block.BlockedDID != blockedDID { 67 + t.Errorf("Expected blockedDID=%s, got %s", blockedDID, block.BlockedDID) 68 + } 69 + 70 + expectedURI := fmt.Sprintf("at://%s/social.coves.actor.block/%s", blockerDID, rkey) 71 + if block.RecordURI != expectedURI { 72 + t.Errorf("Expected recordURI=%s, got %s", expectedURI, block.RecordURI) 73 + } 74 + if block.RecordCID != "bafyuserblock123" { 75 + t.Errorf("Expected recordCID=bafyuserblock123, got %s", block.RecordCID) 76 + } 77 + 78 + // Verify IsBlocked returns true 79 + isBlocked, err := repo.IsBlocked(ctx, blockerDID, blockedDID) 80 + if err != nil { 81 + t.Fatalf("IsBlocked failed: %v", err) 82 + } 83 + if !isBlocked { 84 + t.Error("Expected IsBlocked=true, got false") 85 + } 86 + } 87 + 88 + // TestUserBlockIndexing_DeleteEvent tests that a Jetstream DELETE event 89 + // properly removes a previously indexed block from the AppView. 90 + func TestUserBlockIndexing_DeleteEvent(t *testing.T) { 91 + if testing.Short() { 92 + t.Skip("Skipping integration test in short mode") 93 + } 94 + 95 + ctx := context.Background() 96 + db := setupTestDB(t) 97 + defer cleanupUserBlockTestDB(t, db) 98 + 99 + repo := postgresRepo.NewUserBlockRepository(db) 100 + consumer := createUserBlockConsumer(t, repo) 101 + 102 + blockerDID := "did:plc:test-blocker-delete" 103 + blockedDID := "did:plc:test-blocked-delete" 104 + rkey := "test-block-delete-1" 105 + uri := fmt.Sprintf("at://%s/social.coves.actor.block/%s", blockerDID, rkey) 106 + 107 + // Pre-index a block in AppView via repo.BlockUser() 108 + block := &userblocks.UserBlock{ 109 + BlockerDID: blockerDID, 110 + BlockedDID: blockedDID, 111 + BlockedAt: time.Now(), 112 + RecordURI: uri, 113 + RecordCID: "bafyuserblock456", 114 + } 115 + _, err := repo.BlockUser(ctx, block) 116 + if err != nil { 117 + t.Fatalf("Failed to pre-index block: %v", err) 118 + } 119 + 120 + // Verify block exists before delete 121 + isBlocked, err := repo.IsBlocked(ctx, blockerDID, blockedDID) 122 + if err != nil { 123 + t.Fatalf("IsBlocked failed: %v", err) 124 + } 125 + if !isBlocked { 126 + t.Fatal("Expected block to exist before DELETE event") 127 + } 128 + 129 + // Simulate Jetstream DELETE event (no record data, just rkey) 130 + event := &jetstream.JetstreamEvent{ 131 + Did: blockerDID, 132 + Kind: "commit", 133 + TimeUS: time.Now().UnixMicro(), 134 + Commit: &jetstream.CommitEvent{ 135 + Rev: "test-rev-2", 136 + Operation: "delete", 137 + Collection: "social.coves.actor.block", 138 + RKey: rkey, 139 + }, 140 + } 141 + 142 + // Process delete event 143 + err = consumer.HandleEvent(ctx, event) 144 + if err != nil { 145 + t.Fatalf("Failed to handle block DELETE event: %v", err) 146 + } 147 + 148 + // Verify block removed via repo.IsBlocked() == false 149 + isBlocked, err = repo.IsBlocked(ctx, blockerDID, blockedDID) 150 + if err != nil { 151 + t.Fatalf("IsBlocked failed after delete: %v", err) 152 + } 153 + if isBlocked { 154 + t.Error("Expected IsBlocked=false after DELETE event, got true") 155 + } 156 + 157 + // Also verify GetBlock returns ErrBlockNotFound 158 + _, err = repo.GetBlock(ctx, blockerDID, blockedDID) 159 + if !userblocks.IsNotFound(err) { 160 + t.Errorf("Expected ErrBlockNotFound after delete, got: %v", err) 161 + } 162 + } 163 + 164 + // TestUserBlockIndexing_Idempotent tests that processing the same CREATE event 165 + // twice results in only 1 block (idempotent via ON CONFLICT DO UPDATE). 166 + func TestUserBlockIndexing_Idempotent(t *testing.T) { 167 + if testing.Short() { 168 + t.Skip("Skipping integration test in short mode") 169 + } 170 + 171 + ctx := context.Background() 172 + db := setupTestDB(t) 173 + defer cleanupUserBlockTestDB(t, db) 174 + 175 + repo := postgresRepo.NewUserBlockRepository(db) 176 + consumer := createUserBlockConsumer(t, repo) 177 + 178 + blockerDID := "did:plc:test-blocker-idempotent" 179 + blockedDID := "did:plc:test-blocked-idempotent" 180 + rkey := "test-block-idempotent-1" 181 + 182 + event := &jetstream.JetstreamEvent{ 183 + Did: blockerDID, 184 + Kind: "commit", 185 + TimeUS: time.Now().UnixMicro(), 186 + Commit: &jetstream.CommitEvent{ 187 + Rev: "test-rev-3", 188 + Operation: "create", 189 + Collection: "social.coves.actor.block", 190 + RKey: rkey, 191 + CID: "bafyuserblock789", 192 + Record: map[string]interface{}{ 193 + "$type": "social.coves.actor.block", 194 + "subject": blockedDID, 195 + "createdAt": time.Now().Format(time.RFC3339), 196 + }, 197 + }, 198 + } 199 + 200 + // Process event twice 201 + err := consumer.HandleEvent(ctx, event) 202 + if err != nil { 203 + t.Fatalf("First block CREATE failed: %v", err) 204 + } 205 + 206 + err = consumer.HandleEvent(ctx, event) 207 + if err != nil { 208 + t.Fatalf("Second block CREATE (idempotent) failed: %v", err) 209 + } 210 + 211 + // Verify only 1 block exists via ListBlockedUsers 212 + blocks, err := repo.ListBlockedUsers(ctx, blockerDID, 10, 0) 213 + if err != nil { 214 + t.Fatalf("ListBlockedUsers failed: %v", err) 215 + } 216 + if len(blocks) != 1 { 217 + t.Errorf("Expected 1 block after idempotent create, got %d", len(blocks)) 218 + } 219 + } 220 + 221 + // TestUserBlockIndexing_DeleteNonExistent tests that a DELETE event for a 222 + // non-existent block does not error (graceful/idempotent). 223 + func TestUserBlockIndexing_DeleteNonExistent(t *testing.T) { 224 + if testing.Short() { 225 + t.Skip("Skipping integration test in short mode") 226 + } 227 + 228 + ctx := context.Background() 229 + db := setupTestDB(t) 230 + defer cleanupUserBlockTestDB(t, db) 231 + 232 + repo := postgresRepo.NewUserBlockRepository(db) 233 + consumer := createUserBlockConsumer(t, repo) 234 + 235 + blockerDID := "did:plc:test-blocker-nonexistent" 236 + rkey := "test-block-nonexistent" 237 + 238 + // Simulate DELETE event for block that doesn't exist 239 + event := &jetstream.JetstreamEvent{ 240 + Did: blockerDID, 241 + Kind: "commit", 242 + TimeUS: time.Now().UnixMicro(), 243 + Commit: &jetstream.CommitEvent{ 244 + Rev: "test-rev-99", 245 + Operation: "delete", 246 + Collection: "social.coves.actor.block", 247 + RKey: rkey, 248 + }, 249 + } 250 + 251 + // Should not error (idempotent) 252 + err := consumer.HandleEvent(ctx, event) 253 + if err != nil { 254 + t.Errorf("DELETE of non-existent block should be idempotent, got error: %v", err) 255 + } 256 + } 257 + 258 + // Helper functions for user block indexing tests 259 + 260 + // createUserBlockConsumer creates a UserEventConsumer configured with a user block 261 + // repo for testing. Uses nil for userService and identityResolver since block 262 + // handling doesn't need them. 263 + func createUserBlockConsumer(t *testing.T, repo userblocks.Repository) *jetstream.UserEventConsumer { 264 + t.Helper() 265 + return jetstream.NewUserEventConsumer(nil, nil, "", "", jetstream.WithUserBlockRepo(repo)) 266 + } 267 + 268 + // cleanupUserBlockTestDB is defined in userblock_repo_test.go
+588
tests/integration/userblock_repo_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/core/userblocks" 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "testing" 9 + "time" 10 + 11 + postgresRepo "Coves/internal/db/postgres" 12 + ) 13 + 14 + // TestUserBlockRepo_BlockUser tests creating user blocks 15 + func TestUserBlockRepo_BlockUser(t *testing.T) { 16 + if testing.Short() { 17 + t.Skip("Skipping integration test in short mode") 18 + } 19 + 20 + ctx := context.Background() 21 + db := setupTestDB(t) 22 + defer cleanupUserBlockTestDB(t, db) 23 + 24 + repo := postgresRepo.NewUserBlockRepository(db) 25 + 26 + t.Run("creates block successfully", func(t *testing.T) { 27 + // Save expected values independently — BlockUser mutates the input pointer, 28 + // so comparing created vs block fields would be self-referential. 29 + expectedBlockerDID := "did:plc:test-blocker-1" 30 + expectedBlockedDID := "did:plc:test-blocked-1" 31 + expectedRecordURI := "at://did:plc:test-blocker-1/social.coves.actor.block/abc123" 32 + expectedRecordCID := "bafyblock123" 33 + 34 + block := &userblocks.UserBlock{ 35 + BlockerDID: expectedBlockerDID, 36 + BlockedDID: expectedBlockedDID, 37 + BlockedAt: time.Now(), 38 + RecordURI: expectedRecordURI, 39 + RecordCID: expectedRecordCID, 40 + } 41 + 42 + created, err := repo.BlockUser(ctx, block) 43 + if err != nil { 44 + t.Fatalf("BlockUser failed: %v", err) 45 + } 46 + 47 + if created.ID == 0 { 48 + t.Error("Expected non-zero ID") 49 + } 50 + if created.BlockerDID != expectedBlockerDID { 51 + t.Errorf("Expected BlockerDID=%s, got %s", expectedBlockerDID, created.BlockerDID) 52 + } 53 + if created.BlockedDID != expectedBlockedDID { 54 + t.Errorf("Expected BlockedDID=%s, got %s", expectedBlockedDID, created.BlockedDID) 55 + } 56 + if created.RecordURI != expectedRecordURI { 57 + t.Errorf("Expected RecordURI=%s, got %s", expectedRecordURI, created.RecordURI) 58 + } 59 + if created.RecordCID != expectedRecordCID { 60 + t.Errorf("Expected RecordCID=%s, got %s", expectedRecordCID, created.RecordCID) 61 + } 62 + }) 63 + 64 + t.Run("idempotent on duplicate block", func(t *testing.T) { 65 + blockerDID := "did:plc:test-blocker-idem" 66 + blockedDID := "did:plc:test-blocked-idem" 67 + 68 + block1 := &userblocks.UserBlock{ 69 + BlockerDID: blockerDID, 70 + BlockedDID: blockedDID, 71 + BlockedAt: time.Now(), 72 + RecordURI: "at://did:plc:test-blocker-idem/social.coves.actor.block/first", 73 + RecordCID: "bafyfirst", 74 + } 75 + 76 + firstBlock, err := repo.BlockUser(ctx, block1) 77 + if err != nil { 78 + t.Fatalf("First BlockUser failed: %v", err) 79 + } 80 + firstBlockID := firstBlock.ID 81 + 82 + // Insert again with updated URI/CID (ON CONFLICT DO UPDATE) 83 + expectedURI := "at://did:plc:test-blocker-idem/social.coves.actor.block/second" 84 + expectedCID := "bafysecond" 85 + block2 := &userblocks.UserBlock{ 86 + BlockerDID: blockerDID, 87 + BlockedDID: blockedDID, 88 + BlockedAt: time.Now(), 89 + RecordURI: expectedURI, 90 + RecordCID: expectedCID, 91 + } 92 + 93 + updated, err := repo.BlockUser(ctx, block2) 94 + if err != nil { 95 + t.Fatalf("Second BlockUser (idempotent) failed: %v", err) 96 + } 97 + 98 + // Core invariant: row identity should be stable across upsert 99 + if updated.ID != firstBlockID { 100 + t.Errorf("Expected stable row ID=%d after upsert, got %d", firstBlockID, updated.ID) 101 + } 102 + 103 + // Should have updated URI/CID 104 + if updated.RecordURI != expectedURI { 105 + t.Errorf("Expected updated RecordURI=%s, got %s", expectedURI, updated.RecordURI) 106 + } 107 + if updated.RecordCID != expectedCID { 108 + t.Errorf("Expected updated RecordCID=%s, got %s", expectedCID, updated.RecordCID) 109 + } 110 + 111 + // Should still be only 1 block 112 + blocks, err := repo.ListBlockedUsers(ctx, blockerDID, 10, 0) 113 + if err != nil { 114 + t.Fatalf("ListBlockedUsers failed: %v", err) 115 + } 116 + if len(blocks) != 1 { 117 + t.Errorf("Expected 1 block after idempotent insert, got %d", len(blocks)) 118 + } 119 + }) 120 + } 121 + 122 + // TestUserBlockRepo_UnblockUser tests removing user blocks 123 + func TestUserBlockRepo_UnblockUser(t *testing.T) { 124 + if testing.Short() { 125 + t.Skip("Skipping integration test in short mode") 126 + } 127 + 128 + ctx := context.Background() 129 + db := setupTestDB(t) 130 + defer cleanupUserBlockTestDB(t, db) 131 + 132 + repo := postgresRepo.NewUserBlockRepository(db) 133 + 134 + t.Run("removes existing block", func(t *testing.T) { 135 + blockerDID := "did:plc:test-unblocker-1" 136 + blockedDID := "did:plc:test-unblocked-1" 137 + 138 + // Create a block first 139 + block := &userblocks.UserBlock{ 140 + BlockerDID: blockerDID, 141 + BlockedDID: blockedDID, 142 + BlockedAt: time.Now(), 143 + RecordURI: "at://did:plc:test-unblocker-1/social.coves.actor.block/del1", 144 + RecordCID: "bafydel1", 145 + } 146 + _, err := repo.BlockUser(ctx, block) 147 + if err != nil { 148 + t.Fatalf("BlockUser failed: %v", err) 149 + } 150 + 151 + // Unblock 152 + err = repo.UnblockUser(ctx, blockerDID, blockedDID) 153 + if err != nil { 154 + t.Fatalf("UnblockUser failed: %v", err) 155 + } 156 + 157 + // Verify removed 158 + _, err = repo.GetBlock(ctx, blockerDID, blockedDID) 159 + if !userblocks.IsNotFound(err) { 160 + t.Errorf("Expected ErrBlockNotFound after unblock, got: %v", err) 161 + } 162 + }) 163 + 164 + t.Run("returns ErrBlockNotFound for non-existent block", func(t *testing.T) { 165 + err := repo.UnblockUser(ctx, "did:plc:test-nonexistent-blocker", "did:plc:test-nonexistent-blocked") 166 + if !userblocks.IsNotFound(err) { 167 + t.Errorf("Expected ErrBlockNotFound, got: %v", err) 168 + } 169 + }) 170 + } 171 + 172 + // TestUserBlockRepo_GetBlock tests block retrieval by blocker + blocked DID 173 + func TestUserBlockRepo_GetBlock(t *testing.T) { 174 + if testing.Short() { 175 + t.Skip("Skipping integration test in short mode") 176 + } 177 + 178 + ctx := context.Background() 179 + db := setupTestDB(t) 180 + defer cleanupUserBlockTestDB(t, db) 181 + 182 + repo := postgresRepo.NewUserBlockRepository(db) 183 + 184 + blockerDID := "did:plc:test-getblock-blocker" 185 + blockedDID := "did:plc:test-getblock-blocked" 186 + 187 + t.Run("returns ErrBlockNotFound when not exists", func(t *testing.T) { 188 + _, err := repo.GetBlock(ctx, blockerDID, blockedDID) 189 + if !userblocks.IsNotFound(err) { 190 + t.Errorf("Expected ErrBlockNotFound, got: %v", err) 191 + } 192 + }) 193 + 194 + t.Run("retrieves block by blocker + blocked DID", func(t *testing.T) { 195 + recordURI := "at://did:plc:test-getblock-blocker/social.coves.actor.block/get1" 196 + block := &userblocks.UserBlock{ 197 + BlockerDID: blockerDID, 198 + BlockedDID: blockedDID, 199 + BlockedAt: time.Now(), 200 + RecordURI: recordURI, 201 + RecordCID: "bafyget1", 202 + } 203 + _, err := repo.BlockUser(ctx, block) 204 + if err != nil { 205 + t.Fatalf("BlockUser failed: %v", err) 206 + } 207 + 208 + retrieved, err := repo.GetBlock(ctx, blockerDID, blockedDID) 209 + if err != nil { 210 + t.Fatalf("GetBlock failed: %v", err) 211 + } 212 + 213 + if retrieved.BlockerDID != blockerDID { 214 + t.Errorf("Expected BlockerDID=%s, got %s", blockerDID, retrieved.BlockerDID) 215 + } 216 + if retrieved.BlockedDID != blockedDID { 217 + t.Errorf("Expected BlockedDID=%s, got %s", blockedDID, retrieved.BlockedDID) 218 + } 219 + if retrieved.RecordURI != recordURI { 220 + t.Errorf("Expected RecordURI=%s, got %s", recordURI, retrieved.RecordURI) 221 + } 222 + }) 223 + } 224 + 225 + // TestUserBlockRepo_GetBlockByURI tests block retrieval by record URI 226 + func TestUserBlockRepo_GetBlockByURI(t *testing.T) { 227 + if testing.Short() { 228 + t.Skip("Skipping integration test in short mode") 229 + } 230 + 231 + ctx := context.Background() 232 + db := setupTestDB(t) 233 + defer cleanupUserBlockTestDB(t, db) 234 + 235 + repo := postgresRepo.NewUserBlockRepository(db) 236 + 237 + t.Run("retrieves block by record_uri", func(t *testing.T) { 238 + recordURI := "at://did:plc:test-uri-blocker/social.coves.actor.block/uri1" 239 + block := &userblocks.UserBlock{ 240 + BlockerDID: "did:plc:test-uri-blocker", 241 + BlockedDID: "did:plc:test-uri-blocked", 242 + BlockedAt: time.Now(), 243 + RecordURI: recordURI, 244 + RecordCID: "bafyuri1", 245 + } 246 + _, err := repo.BlockUser(ctx, block) 247 + if err != nil { 248 + t.Fatalf("BlockUser failed: %v", err) 249 + } 250 + 251 + retrieved, err := repo.GetBlockByURI(ctx, recordURI) 252 + if err != nil { 253 + t.Fatalf("GetBlockByURI failed: %v", err) 254 + } 255 + 256 + if retrieved.RecordURI != recordURI { 257 + t.Errorf("Expected RecordURI=%s, got %s", recordURI, retrieved.RecordURI) 258 + } 259 + if retrieved.BlockerDID != "did:plc:test-uri-blocker" { 260 + t.Errorf("Expected BlockerDID=did:plc:test-uri-blocker, got %s", retrieved.BlockerDID) 261 + } 262 + if retrieved.BlockedDID != "did:plc:test-uri-blocked" { 263 + t.Errorf("Expected BlockedDID=did:plc:test-uri-blocked, got %s", retrieved.BlockedDID) 264 + } 265 + }) 266 + 267 + t.Run("returns ErrBlockNotFound for unknown URI", func(t *testing.T) { 268 + _, err := repo.GetBlockByURI(ctx, "at://did:plc:test-unknown/social.coves.actor.block/nonexistent") 269 + if !userblocks.IsNotFound(err) { 270 + t.Errorf("Expected ErrBlockNotFound, got: %v", err) 271 + } 272 + }) 273 + } 274 + 275 + // TestUserBlockRepo_ListBlockedUsers tests listing blocked users with pagination 276 + func TestUserBlockRepo_ListBlockedUsers(t *testing.T) { 277 + if testing.Short() { 278 + t.Skip("Skipping integration test in short mode") 279 + } 280 + 281 + ctx := context.Background() 282 + db := setupTestDB(t) 283 + defer cleanupUserBlockTestDB(t, db) 284 + 285 + repo := postgresRepo.NewUserBlockRepository(db) 286 + 287 + blockerDID := "did:plc:test-list-blocker" 288 + 289 + // Use deterministic timestamps with clear ordering to avoid DB rounding issues. 290 + // i=0 is oldest, i=2 is newest. 291 + baseTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) 292 + blockedDIDs := make([]string, 3) 293 + for i := 0; i < 3; i++ { 294 + blockedDIDs[i] = fmt.Sprintf("did:plc:test-list-blocked-%d", i) 295 + block := &userblocks.UserBlock{ 296 + BlockerDID: blockerDID, 297 + BlockedDID: blockedDIDs[i], 298 + BlockedAt: baseTime.Add(time.Duration(i) * time.Hour), 299 + RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/list%d", blockerDID, i), 300 + RecordCID: fmt.Sprintf("bafylist%d", i), 301 + } 302 + _, err := repo.BlockUser(ctx, block) 303 + if err != nil { 304 + t.Fatalf("Failed to create block %d: %v", i, err) 305 + } 306 + } 307 + 308 + t.Run("lists all blocked users in DESC order", func(t *testing.T) { 309 + blocks, err := repo.ListBlockedUsers(ctx, blockerDID, 10, 0) 310 + if err != nil { 311 + t.Fatalf("ListBlockedUsers failed: %v", err) 312 + } 313 + 314 + if len(blocks) != 3 { 315 + t.Fatalf("Expected 3 blocks, got %d", len(blocks)) 316 + } 317 + 318 + // Verify all blocks belong to correct blocker 319 + for _, block := range blocks { 320 + if block.BlockerDID != blockerDID { 321 + t.Errorf("Expected BlockerDID=%s, got %s", blockerDID, block.BlockerDID) 322 + } 323 + } 324 + 325 + // Verify ORDER BY blocked_at DESC: most recently blocked first 326 + if blocks[0].BlockedDID != blockedDIDs[2] { 327 + t.Errorf("Expected first result (most recent) to be %s, got %s", blockedDIDs[2], blocks[0].BlockedDID) 328 + } 329 + if blocks[2].BlockedDID != blockedDIDs[0] { 330 + t.Errorf("Expected last result (oldest) to be %s, got %s", blockedDIDs[0], blocks[2].BlockedDID) 331 + } 332 + }) 333 + 334 + t.Run("pagination works correctly", func(t *testing.T) { 335 + // Get first 2 336 + blocks, err := repo.ListBlockedUsers(ctx, blockerDID, 2, 0) 337 + if err != nil { 338 + t.Fatalf("ListBlockedUsers with limit failed: %v", err) 339 + } 340 + if len(blocks) != 2 { 341 + t.Errorf("Expected 2 blocks (paginated), got %d", len(blocks)) 342 + } 343 + 344 + // Get next page (should only get 1) 345 + blocksPage2, err := repo.ListBlockedUsers(ctx, blockerDID, 2, 2) 346 + if err != nil { 347 + t.Fatalf("ListBlockedUsers page 2 failed: %v", err) 348 + } 349 + if len(blocksPage2) != 1 { 350 + t.Errorf("Expected 1 block on page 2, got %d", len(blocksPage2)) 351 + } 352 + }) 353 + 354 + t.Run("returns empty list for user with no blocks", func(t *testing.T) { 355 + blocks, err := repo.ListBlockedUsers(ctx, "did:plc:test-no-blocks-user", 10, 0) 356 + if err != nil { 357 + t.Fatalf("ListBlockedUsers failed: %v", err) 358 + } 359 + if len(blocks) != 0 { 360 + t.Errorf("Expected 0 blocks, got %d", len(blocks)) 361 + } 362 + }) 363 + 364 + t.Run("limit=0 returns no results", func(t *testing.T) { 365 + blocks, err := repo.ListBlockedUsers(ctx, blockerDID, 0, 0) 366 + if err != nil { 367 + t.Fatalf("ListBlockedUsers with limit=0 failed: %v", err) 368 + } 369 + if len(blocks) != 0 { 370 + t.Errorf("Expected 0 blocks with limit=0, got %d", len(blocks)) 371 + } 372 + }) 373 + } 374 + 375 + // TestUserBlockRepo_IsBlocked tests the fast block check 376 + func TestUserBlockRepo_IsBlocked(t *testing.T) { 377 + if testing.Short() { 378 + t.Skip("Skipping integration test in short mode") 379 + } 380 + 381 + ctx := context.Background() 382 + db := setupTestDB(t) 383 + defer cleanupUserBlockTestDB(t, db) 384 + 385 + repo := postgresRepo.NewUserBlockRepository(db) 386 + 387 + t.Run("returns false when not blocked", func(t *testing.T) { 388 + isBlocked, err := repo.IsBlocked(ctx, "did:plc:test-isblocked-a", "did:plc:test-isblocked-b") 389 + if err != nil { 390 + t.Fatalf("IsBlocked failed: %v", err) 391 + } 392 + if isBlocked { 393 + t.Error("Expected IsBlocked=false, got true") 394 + } 395 + }) 396 + 397 + t.Run("returns true when blocked", func(t *testing.T) { 398 + blockerDID := "did:plc:test-isblocked-blocker-2" 399 + blockedDID := "did:plc:test-isblocked-blocked-2" 400 + 401 + block := &userblocks.UserBlock{ 402 + BlockerDID: blockerDID, 403 + BlockedDID: blockedDID, 404 + BlockedAt: time.Now(), 405 + RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/isblocked1", blockerDID), 406 + RecordCID: "bafyisblocked1", 407 + } 408 + _, err := repo.BlockUser(ctx, block) 409 + if err != nil { 410 + t.Fatalf("BlockUser failed: %v", err) 411 + } 412 + 413 + isBlocked, err := repo.IsBlocked(ctx, blockerDID, blockedDID) 414 + if err != nil { 415 + t.Fatalf("IsBlocked failed: %v", err) 416 + } 417 + if !isBlocked { 418 + t.Error("Expected IsBlocked=true, got false") 419 + } 420 + }) 421 + 422 + t.Run("returns false after unblock", func(t *testing.T) { 423 + blockerDID := "did:plc:test-isblocked-blocker-3" 424 + blockedDID := "did:plc:test-isblocked-blocked-3" 425 + 426 + // Create block first 427 + block := &userblocks.UserBlock{ 428 + BlockerDID: blockerDID, 429 + BlockedDID: blockedDID, 430 + BlockedAt: time.Now(), 431 + RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/isblocked2", blockerDID), 432 + RecordCID: "bafyisblocked2", 433 + } 434 + _, err := repo.BlockUser(ctx, block) 435 + if err != nil { 436 + t.Fatalf("BlockUser failed: %v", err) 437 + } 438 + 439 + // Unblock 440 + err = repo.UnblockUser(ctx, blockerDID, blockedDID) 441 + if err != nil { 442 + t.Fatalf("UnblockUser failed: %v", err) 443 + } 444 + 445 + isBlocked, err := repo.IsBlocked(ctx, blockerDID, blockedDID) 446 + if err != nil { 447 + t.Fatalf("IsBlocked failed: %v", err) 448 + } 449 + if isBlocked { 450 + t.Error("Expected IsBlocked=false after unblock, got true") 451 + } 452 + }) 453 + } 454 + 455 + // TestUserBlockRepo_AreBlocked tests the batch block check 456 + func TestUserBlockRepo_AreBlocked(t *testing.T) { 457 + if testing.Short() { 458 + t.Skip("Skipping integration test in short mode") 459 + } 460 + 461 + ctx := context.Background() 462 + db := setupTestDB(t) 463 + defer cleanupUserBlockTestDB(t, db) 464 + 465 + repo := postgresRepo.NewUserBlockRepository(db) 466 + 467 + blockerDID := "did:plc:test-areblocked-blocker" 468 + 469 + // Block user 0 and user 2, but NOT user 1 470 + blockedDIDs := []string{ 471 + "did:plc:test-areblocked-target-0", 472 + "did:plc:test-areblocked-target-1", 473 + "did:plc:test-areblocked-target-2", 474 + } 475 + 476 + for _, i := range []int{0, 2} { 477 + block := &userblocks.UserBlock{ 478 + BlockerDID: blockerDID, 479 + BlockedDID: blockedDIDs[i], 480 + BlockedAt: time.Now(), 481 + RecordURI: fmt.Sprintf("at://%s/social.coves.actor.block/areblocked%d", blockerDID, i), 482 + RecordCID: fmt.Sprintf("bafyareblocked%d", i), 483 + } 484 + _, err := repo.BlockUser(ctx, block) 485 + if err != nil { 486 + t.Fatalf("BlockUser failed for target %d: %v", i, err) 487 + } 488 + } 489 + 490 + t.Run("batch check returns correct map for mixed blocked/unblocked DIDs", func(t *testing.T) { 491 + result, err := repo.AreBlocked(ctx, blockerDID, blockedDIDs) 492 + if err != nil { 493 + t.Fatalf("AreBlocked failed: %v", err) 494 + } 495 + 496 + // target-0 should be blocked 497 + if !result[blockedDIDs[0]] { 498 + t.Errorf("Expected %s to be blocked", blockedDIDs[0]) 499 + } 500 + 501 + // target-1 should NOT be blocked 502 + if result[blockedDIDs[1]] { 503 + t.Errorf("Expected %s to NOT be blocked", blockedDIDs[1]) 504 + } 505 + 506 + // target-2 should be blocked 507 + if !result[blockedDIDs[2]] { 508 + t.Errorf("Expected %s to be blocked", blockedDIDs[2]) 509 + } 510 + }) 511 + 512 + t.Run("empty input returns empty map without hitting DB", func(t *testing.T) { 513 + result, err := repo.AreBlocked(ctx, blockerDID, []string{}) 514 + if err != nil { 515 + t.Fatalf("AreBlocked with empty input failed: %v", err) 516 + } 517 + if len(result) != 0 { 518 + t.Errorf("Expected empty map for empty input, got %d entries", len(result)) 519 + } 520 + }) 521 + } 522 + 523 + // TestUserBlockRepo_UnblockByRecordURI tests the full flow of looking up a block 524 + // by record URI and then deleting it — the path used by the Jetstream consumer 525 + // when processing DELETE operations (which only carry the record URI, not DID pairs). 526 + func TestUserBlockRepo_UnblockByRecordURI(t *testing.T) { 527 + if testing.Short() { 528 + t.Skip("Skipping integration test in short mode") 529 + } 530 + 531 + ctx := context.Background() 532 + db := setupTestDB(t) 533 + defer cleanupUserBlockTestDB(t, db) 534 + 535 + repo := postgresRepo.NewUserBlockRepository(db) 536 + 537 + blockerDID := "did:plc:test-uri-unblock-blocker" 538 + blockedDID := "did:plc:test-uri-unblock-blocked" 539 + recordURI := "at://did:plc:test-uri-unblock-blocker/social.coves.actor.block/del1" 540 + 541 + // Create the block 542 + block := &userblocks.UserBlock{ 543 + BlockerDID: blockerDID, 544 + BlockedDID: blockedDID, 545 + BlockedAt: time.Now(), 546 + RecordURI: recordURI, 547 + RecordCID: "bafyuriunblock1", 548 + } 549 + _, err := repo.BlockUser(ctx, block) 550 + if err != nil { 551 + t.Fatalf("BlockUser failed: %v", err) 552 + } 553 + 554 + // Look up by URI (as the Jetstream consumer would) 555 + found, err := repo.GetBlockByURI(ctx, recordURI) 556 + if err != nil { 557 + t.Fatalf("GetBlockByURI failed: %v", err) 558 + } 559 + if found.BlockerDID != blockerDID || found.BlockedDID != blockedDID { 560 + t.Fatalf("GetBlockByURI returned wrong block: blocker=%s blocked=%s", found.BlockerDID, found.BlockedDID) 561 + } 562 + 563 + // Delete using the DIDs from the lookup 564 + err = repo.UnblockUser(ctx, found.BlockerDID, found.BlockedDID) 565 + if err != nil { 566 + t.Fatalf("UnblockUser after URI lookup failed: %v", err) 567 + } 568 + 569 + // Verify block is gone 570 + _, err = repo.GetBlock(ctx, blockerDID, blockedDID) 571 + if !userblocks.IsNotFound(err) { 572 + t.Errorf("Expected ErrBlockNotFound after unblock-by-URI flow, got: %v", err) 573 + } 574 + } 575 + 576 + // cleanupUserBlockTestDB removes test data from the user_blocks table 577 + func cleanupUserBlockTestDB(t *testing.T, db *sql.DB) { 578 + t.Helper() 579 + 580 + _, err := db.Exec("DELETE FROM user_blocks WHERE blocker_did LIKE 'did:plc:test-%'") 581 + if err != nil { 582 + t.Logf("Warning: Failed to clean up user blocks: %v", err) 583 + } 584 + 585 + if closeErr := db.Close(); closeErr != nil { 586 + t.Logf("Failed to close database: %v", closeErr) 587 + } 588 + }