A community based topic aggregation platform built on atproto

Merge branch 'feat/community-blocking'

+172
internal/api/handlers/community/block.go
···
+
package community
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"encoding/json"
+
"log"
+
"net/http"
+
"regexp"
+
"strings"
+
)
+
+
// Package-level compiled regex for DID validation (compiled once at startup)
+
var (
+
didRegex = regexp.MustCompile(`^did:(plc|web):[a-zA-Z0-9._:%-]+$`)
+
)
+
+
// BlockHandler handles community blocking operations
+
type BlockHandler struct {
+
service communities.Service
+
}
+
+
// NewBlockHandler creates a new block handler
+
func NewBlockHandler(service communities.Service) *BlockHandler {
+
return &BlockHandler{
+
service: service,
+
}
+
}
+
+
// HandleBlock blocks a community
+
// POST /xrpc/social.coves.community.blockCommunity
+
//
+
// Request body: { "community": "did:plc:xxx" }
+
// Note: Per lexicon spec, only DIDs are accepted (not handles).
+
// The block record's "subject" field requires format: "did".
+
func (h *BlockHandler) HandleBlock(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var req struct {
+
Community string `json:"community"` // DID only (per lexicon)
+
}
+
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
if req.Community == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// Validate DID format (per lexicon: format must be "did")
+
if !strings.HasPrefix(req.Community, "did:") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"community must be a DID (did:plc:... or did:web:...)")
+
return
+
}
+
+
// Validate DID format with regex: did:method:identifier
+
if !didRegex.MatchString(req.Community) {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
+
return
+
}
+
+
// Extract authenticated user DID and access token from request context (injected by auth middleware)
+
userDID := middleware.GetUserDID(r)
+
if userDID == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
userAccessToken := middleware.GetUserAccessToken(r)
+
if userAccessToken == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token")
+
return
+
}
+
+
// Block via service (write-forward to PDS)
+
block, err := h.service.BlockCommunity(r.Context(), userDID, userAccessToken, req.Community)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response (following atProto conventions for block responses)
+
response := map[string]interface{}{
+
"block": map[string]interface{}{
+
"recordUri": block.RecordURI,
+
"recordCid": block.RecordCID,
+
},
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+
+
// HandleUnblock unblocks a community
+
// POST /xrpc/social.coves.community.unblockCommunity
+
//
+
// Request body: { "community": "did:plc:xxx" }
+
// Note: Per lexicon spec, only DIDs are accepted (not handles).
+
func (h *BlockHandler) HandleUnblock(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var req struct {
+
Community string `json:"community"` // DID only (per lexicon)
+
}
+
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
if req.Community == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// Validate DID format (per lexicon: format must be "did")
+
if !strings.HasPrefix(req.Community, "did:") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"community must be a DID (did:plc:... or did:web:...)")
+
return
+
}
+
+
// Validate DID format with regex: did:method:identifier
+
if !didRegex.MatchString(req.Community) {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
+
return
+
}
+
+
// Extract authenticated user DID and access token from request context (injected by auth middleware)
+
userDID := middleware.GetUserDID(r)
+
if userDID == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
userAccessToken := middleware.GetUserAccessToken(r)
+
if userAccessToken == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token")
+
return
+
}
+
+
// Unblock via service (delete record on PDS)
+
err := h.service.UnblockCommunity(r.Context(), userDID, userAccessToken, req.Community)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(map[string]interface{}{
+
"success": true,
+
}); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+23 -4
internal/api/handlers/community/subscribe.go
···
"encoding/json"
"log"
"net/http"
+
"strings"
)
// SubscribeHandler handles community subscriptions
···
// HandleSubscribe subscribes a user to a community
// POST /xrpc/social.coves.community.subscribe
-
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
//
+
// Request body: { "community": "did:plc:xxx", "contentVisibility": 3 }
+
// Note: Per lexicon spec, only DIDs are accepted for the "subject" field (not handles).
func (h *SubscribeHandler) HandleSubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
// Parse request body
var req struct {
-
Community string `json:"community"`
+
Community string `json:"community"` // DID only (per lexicon)
ContentVisibility int `json:"contentVisibility"` // Optional: 1-5 scale, defaults to 3
}
···
if req.Community == "" {
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// Validate DID format (per lexicon: subject field requires format "did")
+
if !strings.HasPrefix(req.Community, "did:") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"community must be a DID (did:plc:... or did:web:...)")
return
}
···
// HandleUnsubscribe unsubscribes a user from a community
// POST /xrpc/social.coves.community.unsubscribe
-
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
//
+
// Request body: { "community": "did:plc:xxx" }
+
// Note: Per lexicon spec, only DIDs are accepted (not handles).
func (h *SubscribeHandler) HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
// Parse request body
var req struct {
-
Community string `json:"community"`
+
Community string `json:"community"` // DID only (per lexicon)
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
···
if req.Community == "" {
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// Validate DID format (per lexicon: subject field requires format "did")
+
if !strings.HasPrefix(req.Community, "did:") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"community must be a DID (did:plc:... or did:web:...)")
return
}
+7
internal/api/routes/community.go
···
listHandler := community.NewListHandler(service)
searchHandler := community.NewSearchHandler(service)
subscribeHandler := community.NewSubscribeHandler(service)
+
blockHandler := community.NewBlockHandler(service)
// Query endpoints (GET) - public access
// social.coves.community.get - get a single community by identifier
···
// social.coves.community.unsubscribe - unsubscribe from a community
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.unsubscribe", subscribeHandler.HandleUnsubscribe)
+
+
// social.coves.community.blockCommunity - block a community
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.blockCommunity", blockHandler.HandleBlock)
+
+
// social.coves.community.unblockCommunity - unblock a community
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.unblockCommunity", blockHandler.HandleUnblock)
// TODO: Add delete handler when implemented
// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.delete", deleteHandler.HandleDelete)
+99 -1
internal/atproto/jetstream/community_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/utils"
"Coves/internal/core/communities"
"context"
"encoding/json"
···
// IMPORTANT: Collection names refer to RECORD TYPES in repositories, not XRPC procedures
// - social.coves.community.profile: Community profile records (in community's own repo)
// - social.coves.community.subscription: Subscription records (in user's repo)
+
// - social.coves.community.block: Block records (in user's repo)
//
// XRPC procedures (social.coves.community.subscribe/unsubscribe) are just HTTP endpoints
// that CREATE or DELETE records in these collections
···
case "social.coves.community.subscription":
// Handle both create (subscribe) and delete (unsubscribe) operations
return c.handleSubscription(ctx, event.Did, commit)
+
case "social.coves.community.block":
+
// Handle both create (block) and delete (unblock) operations
+
return c.handleBlock(ctx, event.Did, commit)
default:
// Not a community-related collection
return nil
···
uri := fmt.Sprintf("at://%s/social.coves.community.subscription/%s", userDID, commit.RKey)
// Create subscription entity
+
// Parse createdAt from record to preserve chronological ordering during replays
subscription := &communities.Subscription{
UserDID: userDID,
CommunityDID: communityDID,
ContentVisibility: contentVisibility,
-
SubscribedAt: time.Now(),
+
SubscribedAt: utils.ParseCreatedAt(commit.Record),
RecordURI: uri,
RecordCID: commit.CID,
}
···
}
log.Printf("✓ Removed subscription: %s -> %s", userDID, subscription.CommunityDID)
+
return nil
+
}
+
+
// handleBlock processes block create/delete events
+
// CREATE operation = user blocked a community
+
// DELETE operation = user unblocked a community
+
func (c *CommunityEventConsumer) handleBlock(ctx context.Context, userDID string, commit *CommitEvent) error {
+
switch commit.Operation {
+
case "create":
+
return c.createBlock(ctx, userDID, commit)
+
case "delete":
+
return c.deleteBlock(ctx, userDID, commit)
+
default:
+
// Update operations shouldn't happen on blocks, but ignore gracefully
+
log.Printf("Ignoring unexpected operation on block: %s (userDID=%s, rkey=%s)",
+
commit.Operation, userDID, commit.RKey)
+
return nil
+
}
+
}
+
+
// createBlock indexes a new block
+
func (c *CommunityEventConsumer) createBlock(ctx context.Context, userDID string, commit *CommitEvent) error {
+
if commit.Record == nil {
+
return fmt.Errorf("block create event missing record data")
+
}
+
+
// Extract community DID from record's subject field (following atProto conventions)
+
communityDID, ok := commit.Record["subject"].(string)
+
if !ok {
+
return fmt.Errorf("block record missing subject field")
+
}
+
+
// Build AT-URI for block record
+
// The record lives in the USER's repository
+
uri := fmt.Sprintf("at://%s/social.coves.community.block/%s", userDID, commit.RKey)
+
+
// Create block entity
+
// Parse createdAt from record to preserve chronological ordering during replays
+
block := &communities.CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: communityDID,
+
BlockedAt: utils.ParseCreatedAt(commit.Record),
+
RecordURI: uri,
+
RecordCID: commit.CID,
+
}
+
+
// Index the block
+
// This is idempotent - safe for Jetstream replays
+
_, err := c.repo.BlockCommunity(ctx, block)
+
if err != nil {
+
// If already exists, that's fine (idempotency)
+
if communities.IsConflict(err) {
+
log.Printf("Block already indexed: %s -> %s", userDID, communityDID)
+
return nil
+
}
+
return fmt.Errorf("failed to index block: %w", err)
+
}
+
+
log.Printf("✓ Indexed block: %s -> %s", userDID, communityDID)
+
return nil
+
}
+
+
// deleteBlock removes a block from the index
+
// DELETE operations don't include record data, so we need to look up the block
+
// by its URI to find which community the user unblocked
+
func (c *CommunityEventConsumer) deleteBlock(ctx context.Context, userDID string, commit *CommitEvent) error {
+
// Build AT-URI from the rkey
+
uri := fmt.Sprintf("at://%s/social.coves.community.block/%s", userDID, commit.RKey)
+
+
// Look up the block to get the community DID
+
// (DELETE operations don't include record data in Jetstream)
+
block, err := c.repo.GetBlockByURI(ctx, uri)
+
if err != nil {
+
if communities.IsNotFound(err) {
+
// Already deleted - this is fine (idempotency)
+
log.Printf("Block already deleted: %s", uri)
+
return nil
+
}
+
return fmt.Errorf("failed to find block for deletion: %w", err)
+
}
+
+
// Remove the block from the index
+
err = c.repo.UnblockCommunity(ctx, userDID, block.CommunityDID)
+
if err != nil {
+
if communities.IsNotFound(err) {
+
log.Printf("Block already removed: %s -> %s", userDID, block.CommunityDID)
+
return nil
+
}
+
return fmt.Errorf("failed to remove block: %w", err)
+
}
+
+
log.Printf("✓ Removed block: %s -> %s", userDID, block.CommunityDID)
return nil
}
+27
internal/atproto/lexicon/social/coves/community/block.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.block",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Record declaring a block relationship against a community. Blocks are public.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["subject", "createdAt"],
+
"properties": {
+
"subject": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the community being blocked"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the block was created"
+
}
+
}
+
}
+
}
+
}
+
}
+2 -2
internal/atproto/lexicon/social/coves/community/subscription.json
···
"properties": {
"subject": {
"type": "string",
-
"format": "at-identifier",
-
"description": "DID or handle of the community being subscribed to"
+
"format": "did",
+
"description": "DID of the community being subscribed to"
},
"createdAt": {
"type": "string",
+49
internal/atproto/utils/record_utils.go
···
+
package utils
+
+
import (
+
"database/sql"
+
"strings"
+
"time"
+
)
+
+
// ExtractRKeyFromURI extracts the record key from an AT-URI
+
// Format: at://did/collection/rkey -> rkey
+
func ExtractRKeyFromURI(uri string) string {
+
parts := strings.Split(uri, "/")
+
if len(parts) >= 4 {
+
return parts[len(parts)-1]
+
}
+
return ""
+
}
+
+
// StringFromNull converts sql.NullString to string
+
// Returns empty string if the NullString is not valid
+
func StringFromNull(ns sql.NullString) string {
+
if ns.Valid {
+
return ns.String
+
}
+
return ""
+
}
+
+
// ParseCreatedAt extracts and parses the createdAt timestamp from an atProto record
+
// Falls back to time.Now() if the field is missing or invalid
+
// This preserves chronological ordering during Jetstream replays and backfills
+
func ParseCreatedAt(record map[string]interface{}) time.Time {
+
if record == nil {
+
return time.Now()
+
}
+
+
createdAtStr, ok := record["createdAt"].(string)
+
if !ok || createdAtStr == "" {
+
return time.Now()
+
}
+
+
// atProto uses RFC3339 format for datetime fields
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
+
if err != nil {
+
// Fallback to now if parsing fails
+
return time.Now()
+
}
+
+
return createdAt
+
}
+11
internal/core/communities/community.go
···
ID int `json:"id" db:"id"`
}
+
// CommunityBlock represents a user blocking a community
+
// Block records live in the user's repository (at://user_did/social.coves.community.block/{rkey})
+
type CommunityBlock struct {
+
BlockedAt time.Time `json:"blockedAt" db:"blocked_at"`
+
UserDID string `json:"userDid" db:"user_did"`
+
CommunityDID string `json:"communityDid" db:"community_did"`
+
RecordURI string `json:"recordUri,omitempty" db:"record_uri"`
+
RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
+
ID int `json:"id" db:"id"`
+
}
+
// Membership represents active participation with reputation tracking
type Membership struct {
JoinedAt time.Time `json:"joinedAt" db:"joined_at"`
+9 -1
internal/core/communities/errors.go
···
// ErrSubscriptionNotFound is returned when subscription doesn't exist
ErrSubscriptionNotFound = errors.New("subscription not found")
+
// ErrBlockNotFound is returned when block doesn't exist
+
ErrBlockNotFound = errors.New("block not found")
+
+
// ErrBlockAlreadyExists is returned when user has already blocked the community
+
ErrBlockAlreadyExists = errors.New("community already blocked")
+
// ErrMembershipNotFound is returned when membership doesn't exist
ErrMembershipNotFound = errors.New("membership not found")
···
func IsNotFound(err error) bool {
return errors.Is(err, ErrCommunityNotFound) ||
errors.Is(err, ErrSubscriptionNotFound) ||
+
errors.Is(err, ErrBlockNotFound) ||
errors.Is(err, ErrMembershipNotFound)
}
···
func IsConflict(err error) bool {
return errors.Is(err, ErrCommunityAlreadyExists) ||
errors.Is(err, ErrHandleTaken) ||
-
errors.Is(err, ErrSubscriptionAlreadyExists)
+
errors.Is(err, ErrSubscriptionAlreadyExists) ||
+
errors.Is(err, ErrBlockAlreadyExists)
}
// IsValidationError checks if error is a validation error
+14
internal/core/communities/interfaces.go
···
ListSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*Subscription, error)
ListSubscribers(ctx context.Context, communityDID string, limit, offset int) ([]*Subscription, error)
+
// Community Blocks
+
BlockCommunity(ctx context.Context, block *CommunityBlock) (*CommunityBlock, error)
+
UnblockCommunity(ctx context.Context, userDID, communityDID string) error
+
GetBlock(ctx context.Context, userDID, communityDID string) (*CommunityBlock, error)
+
GetBlockByURI(ctx context.Context, recordURI string) (*CommunityBlock, error) // For Jetstream delete operations
+
ListBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*CommunityBlock, error)
+
IsBlocked(ctx context.Context, userDID, communityDID string) (bool, error)
+
// Memberships (active participation with reputation)
CreateMembership(ctx context.Context, membership *Membership) (*Membership, error)
GetMembership(ctx context.Context, userDID, communityDID string) (*Membership, error)
···
UnsubscribeFromCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error
GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*Subscription, error)
GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Subscription, error)
+
+
// Block operations (write-forward: creates record in user's PDS)
+
BlockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) (*CommunityBlock, error)
+
UnblockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error
+
GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*CommunityBlock, error)
+
IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error)
// Membership operations (indexed from firehose, reputation managed internally)
GetMembership(ctx context.Context, userDID, communityIdentifier string) (*Membership, error)
+140 -46
internal/core/communities/service.go
···
package communities
import (
+
"Coves/internal/atproto/utils"
"bytes"
"context"
"encoding/json"
+
"errors"
"fmt"
"io"
"log"
···
}
// Extract rkey from record URI (at://did/collection/rkey)
-
rkey := extractRKeyFromURI(subscription.RecordURI)
+
rkey := utils.ExtractRKeyFromURI(subscription.RecordURI)
if rkey == "" {
return fmt.Errorf("invalid subscription record URI")
}
···
return s.repo.ListMembers(ctx, communityDID, limit, offset)
}
+
// BlockCommunity blocks a community via write-forward to PDS
+
func (s *communityService) BlockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) (*CommunityBlock, error) {
+
if userDID == "" {
+
return nil, NewValidationError("userDid", "required")
+
}
+
if userAccessToken == "" {
+
return nil, NewValidationError("userAccessToken", "required")
+
}
+
+
// Resolve community identifier (also verifies community exists)
+
communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
+
if err != nil {
+
return nil, err
+
}
+
+
// Build block record
+
// CRITICAL: Collection is social.coves.community.block (RECORD TYPE)
+
// This record will be created in the USER's repository: at://user_did/social.coves.community.block/{tid}
+
// Following atProto conventions and Bluesky's app.bsky.graph.block pattern
+
blockRecord := map[string]interface{}{
+
"$type": "social.coves.community.block",
+
"subject": communityDID, // DID of community being blocked
+
"createdAt": time.Now().Format(time.RFC3339),
+
}
+
+
// Write-forward: create block record in user's repo using their access token
+
// Note: We don't check for existing blocks first because:
+
// 1. The PDS may reject duplicates (depending on implementation)
+
// 2. The repository layer handles idempotency with ON CONFLICT DO NOTHING
+
// 3. This avoids a race condition where two concurrent requests both pass the check
+
recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, userDID, "social.coves.community.block", "", blockRecord, userAccessToken)
+
if err != nil {
+
// Check if this is a duplicate/conflict error from PDS
+
// PDS should return 409 Conflict for duplicate records, but we also check common error messages
+
// for compatibility with different PDS implementations
+
errMsg := err.Error()
+
isDuplicate := strings.Contains(errMsg, "status 409") || // HTTP 409 Conflict
+
strings.Contains(errMsg, "duplicate") ||
+
strings.Contains(errMsg, "already exists") ||
+
strings.Contains(errMsg, "AlreadyExists")
+
+
if isDuplicate {
+
// Fetch and return existing block from our indexed view
+
existingBlock, getErr := s.repo.GetBlock(ctx, userDID, communityDID)
+
if getErr == nil {
+
// Block exists in our index - return it
+
return existingBlock, nil
+
}
+
// Only treat as "already exists" if the error is ErrBlockNotFound (race condition)
+
// Any other error (DB outage, connection failure, etc.) should bubble up
+
if errors.Is(getErr, ErrBlockNotFound) {
+
// Race condition: PDS has the block but Jetstream hasn't indexed it yet
+
// Return typed conflict error so handler can return 409 instead of 500
+
// This is normal in eventually-consistent systems
+
return nil, ErrBlockAlreadyExists
+
}
+
// Real datastore error - bubble it up so operators see the failure
+
return nil, fmt.Errorf("PDS reported duplicate block but failed to fetch from index: %w", getErr)
+
}
+
return nil, fmt.Errorf("failed to create block on PDS: %w", err)
+
}
+
+
// Return block representation
+
block := &CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: communityDID,
+
BlockedAt: time.Now(),
+
RecordURI: recordURI,
+
RecordCID: recordCID,
+
}
+
+
return block, nil
+
}
+
+
// UnblockCommunity removes a block via PDS delete
+
func (s *communityService) UnblockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error {
+
if userDID == "" {
+
return NewValidationError("userDid", "required")
+
}
+
if userAccessToken == "" {
+
return NewValidationError("userAccessToken", "required")
+
}
+
+
// Resolve community identifier
+
communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
+
if err != nil {
+
return err
+
}
+
+
// Get the block from AppView to find the record key
+
block, err := s.repo.GetBlock(ctx, userDID, communityDID)
+
if err != nil {
+
return err
+
}
+
+
// Extract rkey from record URI (at://did/collection/rkey)
+
rkey := utils.ExtractRKeyFromURI(block.RecordURI)
+
if rkey == "" {
+
return fmt.Errorf("invalid block record URI")
+
}
+
+
// Write-forward: delete record from PDS using user's access token
+
if err := s.deleteRecordOnPDSAs(ctx, userDID, "social.coves.community.block", rkey, userAccessToken); err != nil {
+
return fmt.Errorf("failed to delete block on PDS: %w", err)
+
}
+
+
return nil
+
}
+
+
// GetBlockedCommunities queries AppView DB for user's blocks
+
func (s *communityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*CommunityBlock, error) {
+
if limit <= 0 || limit > 100 {
+
limit = 50
+
}
+
+
return s.repo.ListBlockedCommunities(ctx, userDID, limit, offset)
+
}
+
+
// IsBlocked checks if a user has blocked a community
+
func (s *communityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) {
+
communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
+
if err != nil {
+
return false, err
+
}
+
+
return s.repo.IsBlocked(ctx, userDID, communityDID)
+
}
+
// ValidateHandle checks if a community handle is valid
func (s *communityService) ValidateHandle(handle string) error {
if handle == "" {
···
return "", ErrInvalidInput
}
-
// If it's already a DID, return it
+
// If it's already a DID, verify the community exists
if strings.HasPrefix(identifier, "did:") {
+
_, err := s.repo.GetByDID(ctx, identifier)
+
if err != nil {
+
if IsNotFound(err) {
+
return "", fmt.Errorf("community not found: %w", err)
+
}
+
return "", fmt.Errorf("failed to verify community DID: %w", err)
+
}
return identifier, nil
}
···
// PDS write-forward helpers
-
func (s *communityService) createRecordOnPDS(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}) (string, string, error) {
-
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/"))
-
-
payload := map[string]interface{}{
-
"repo": repoDID,
-
"collection": collection,
-
"record": record,
-
}
-
-
if rkey != "" {
-
payload["rkey"] = rkey
-
}
-
-
return s.callPDS(ctx, "POST", endpoint, payload)
-
}
-
// createRecordOnPDSAs creates a record with a specific access token (for V2 community auth)
func (s *communityService) createRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) {
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/"))
···
return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
}
-
func (s *communityService) deleteRecordOnPDS(ctx context.Context, repoDID, collection, rkey string) error {
-
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/"))
-
-
payload := map[string]interface{}{
-
"repo": repoDID,
-
"collection": collection,
-
"rkey": rkey,
-
}
-
-
_, _, err := s.callPDS(ctx, "POST", endpoint, payload)
-
return err
-
}
-
// deleteRecordOnPDSAs deletes a record with a specific access token (for user-scoped deletions)
-
func (s *communityService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, accessToken string) error {
+
func (s *communityService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error {
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/"))
payload := map[string]interface{}{
···
_, _, err := s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
return err
-
}
-
-
func (s *communityService) callPDS(ctx context.Context, method, endpoint string, payload map[string]interface{}) (string, string, error) {
-
// Use instance's access token
-
return s.callPDSWithAuth(ctx, method, endpoint, payload, s.pdsAccessToken)
}
// callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication)
···
}
// Helper functions
-
-
func extractRKeyFromURI(uri string) string {
-
// at://did/collection/rkey -> rkey
-
parts := strings.Split(uri, "/")
-
if len(parts) >= 4 {
-
return parts[len(parts)-1]
-
}
-
return ""
-
}
+28
internal/db/migrations/009_create_community_blocks_table.sql
···
+
-- +goose Up
+
CREATE TABLE community_blocks (
+
id SERIAL PRIMARY KEY,
+
user_did TEXT NOT NULL CHECK (user_did ~ '^did:(plc|web):[a-zA-Z0-9._:%-]+$'),
+
community_did TEXT NOT NULL CHECK (community_did ~ '^did:(plc|web):[a-zA-Z0-9._:%-]+$'),
+
blocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+
-- AT-Proto metadata (block record lives in user's repo)
+
-- These are required for atProto record verification and federation
+
record_uri TEXT NOT NULL, -- atProto record identifier (at://user_did/social.coves.community.block/rkey)
+
record_cid TEXT NOT NULL, -- Content address (critical for verification)
+
+
UNIQUE(user_did, community_did)
+
);
+
+
-- Indexes for efficient queries
+
-- Note: UNIQUE constraint on (user_did, community_did) already creates an index for those columns
+
CREATE INDEX idx_blocks_user ON community_blocks(user_did);
+
CREATE INDEX idx_blocks_community ON community_blocks(community_did);
+
CREATE INDEX idx_blocks_record_uri ON community_blocks(record_uri); -- For GetBlockByURI (Jetstream DELETE operations)
+
CREATE INDEX idx_blocks_blocked_at ON community_blocks(blocked_at);
+
+
-- +goose Down
+
DROP INDEX IF EXISTS idx_blocks_blocked_at;
+
DROP INDEX IF EXISTS idx_blocks_record_uri;
+
DROP INDEX IF EXISTS idx_blocks_community;
+
DROP INDEX IF EXISTS idx_blocks_user;
+
DROP TABLE IF EXISTS community_blocks;
+173
internal/db/postgres/community_repo_blocks.go
···
+
package postgres
+
+
import (
+
"Coves/internal/core/communities"
+
"context"
+
"database/sql"
+
"fmt"
+
"log"
+
)
+
+
// BlockCommunity creates a new block record (idempotent)
+
func (r *postgresCommunityRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) {
+
query := `
+
INSERT INTO community_blocks (user_did, community_did, blocked_at, record_uri, record_cid)
+
VALUES ($1, $2, $3, $4, $5)
+
ON CONFLICT (user_did, community_did) DO UPDATE SET
+
record_uri = EXCLUDED.record_uri,
+
record_cid = EXCLUDED.record_cid,
+
blocked_at = EXCLUDED.blocked_at
+
RETURNING id, blocked_at`
+
+
err := r.db.QueryRowContext(ctx, query,
+
block.UserDID,
+
block.CommunityDID,
+
block.BlockedAt,
+
block.RecordURI,
+
block.RecordCID,
+
).Scan(&block.ID, &block.BlockedAt)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create block: %w", err)
+
}
+
+
return block, nil
+
}
+
+
// UnblockCommunity removes a block record
+
func (r *postgresCommunityRepo) UnblockCommunity(ctx context.Context, userDID, communityDID string) error {
+
query := `DELETE FROM community_blocks WHERE user_did = $1 AND community_did = $2`
+
+
result, err := r.db.ExecContext(ctx, query, userDID, communityDID)
+
if err != nil {
+
return fmt.Errorf("failed to unblock community: %w", err)
+
}
+
+
rowsAffected, err := result.RowsAffected()
+
if err != nil {
+
return fmt.Errorf("failed to check unblock result: %w", err)
+
}
+
+
if rowsAffected == 0 {
+
return communities.ErrBlockNotFound
+
}
+
+
return nil
+
}
+
+
// GetBlock retrieves a block record by user DID and community DID
+
func (r *postgresCommunityRepo) GetBlock(ctx context.Context, userDID, communityDID string) (*communities.CommunityBlock, error) {
+
query := `
+
SELECT id, user_did, community_did, blocked_at, record_uri, record_cid
+
FROM community_blocks
+
WHERE user_did = $1 AND community_did = $2`
+
+
var block communities.CommunityBlock
+
+
err := r.db.QueryRowContext(ctx, query, userDID, communityDID).Scan(
+
&block.ID,
+
&block.UserDID,
+
&block.CommunityDID,
+
&block.BlockedAt,
+
&block.RecordURI,
+
&block.RecordCID,
+
)
+
if err != nil {
+
if err == sql.ErrNoRows {
+
return nil, communities.ErrBlockNotFound
+
}
+
return nil, fmt.Errorf("failed to get block: %w", err)
+
}
+
+
return &block, nil
+
}
+
+
// GetBlockByURI retrieves a block record by its AT-URI (for Jetstream DELETE operations)
+
func (r *postgresCommunityRepo) GetBlockByURI(ctx context.Context, recordURI string) (*communities.CommunityBlock, error) {
+
query := `
+
SELECT id, user_did, community_did, blocked_at, record_uri, record_cid
+
FROM community_blocks
+
WHERE record_uri = $1`
+
+
var block communities.CommunityBlock
+
+
err := r.db.QueryRowContext(ctx, query, recordURI).Scan(
+
&block.ID,
+
&block.UserDID,
+
&block.CommunityDID,
+
&block.BlockedAt,
+
&block.RecordURI,
+
&block.RecordCID,
+
)
+
if err != nil {
+
if err == sql.ErrNoRows {
+
return nil, communities.ErrBlockNotFound
+
}
+
return nil, fmt.Errorf("failed to get block by URI: %w", err)
+
}
+
+
return &block, nil
+
}
+
+
// ListBlockedCommunities retrieves all communities blocked by a user
+
func (r *postgresCommunityRepo) ListBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
+
query := `
+
SELECT id, user_did, community_did, blocked_at, record_uri, record_cid
+
FROM community_blocks
+
WHERE user_did = $1
+
ORDER BY blocked_at DESC
+
LIMIT $2 OFFSET $3`
+
+
rows, err := r.db.QueryContext(ctx, query, userDID, limit, offset)
+
if err != nil {
+
return nil, fmt.Errorf("failed to list blocked communities: %w", err)
+
}
+
defer func() {
+
if closeErr := rows.Close(); closeErr != nil {
+
// Log error but don't override the main error
+
log.Printf("Failed to close rows: %v", closeErr)
+
}
+
}()
+
+
var blocks []*communities.CommunityBlock
+
for rows.Next() {
+
// Allocate a new block for each iteration to avoid pointer reuse bug
+
block := &communities.CommunityBlock{}
+
+
err = rows.Scan(
+
&block.ID,
+
&block.UserDID,
+
&block.CommunityDID,
+
&block.BlockedAt,
+
&block.RecordURI,
+
&block.RecordCID,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan block: %w", err)
+
}
+
+
blocks = append(blocks, block)
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating blocks: %w", err)
+
}
+
+
return blocks, nil
+
}
+
+
// IsBlocked checks if a user has blocked a specific community (fast EXISTS check)
+
func (r *postgresCommunityRepo) IsBlocked(ctx context.Context, userDID, communityDID string) (bool, error) {
+
query := `
+
SELECT EXISTS(
+
SELECT 1 FROM community_blocks
+
WHERE user_did = $1 AND community_did = $2
+
)`
+
+
var exists bool
+
err := r.db.QueryRowContext(ctx, query, userDID, communityDID).Scan(&exists)
+
if err != nil {
+
return false, fmt.Errorf("failed to check if blocked: %w", err)
+
}
+
+
return exists, nil
+
}
+470
tests/integration/community_blocking_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
postgresRepo "Coves/internal/db/postgres"
+
"context"
+
"database/sql"
+
"fmt"
+
"testing"
+
"time"
+
)
+
+
// TestCommunityBlocking_Indexing tests Jetstream indexing of block events
+
func TestCommunityBlocking_Indexing(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
ctx := context.Background()
+
db := setupTestDB(t)
+
defer cleanupBlockingTestDB(t, db)
+
+
repo := createBlockingTestCommunityRepo(t, db)
+
consumer := jetstream.NewCommunityEventConsumer(repo)
+
+
// Create test community
+
testDID := fmt.Sprintf("did:plc:test-community-%d", time.Now().UnixNano())
+
community := createBlockingTestCommunity(t, repo, "test-community-blocking", testDID)
+
+
t.Run("indexes block CREATE event", func(t *testing.T) {
+
userDID := "did:plc:test-user-blocker"
+
rkey := "test-block-1"
+
+
// Simulate Jetstream CREATE event
+
event := &jetstream.JetstreamEvent{
+
Did: userDID,
+
Kind: "commit",
+
TimeUS: time.Now().UnixMicro(),
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-rev-1",
+
Operation: "create",
+
Collection: "social.coves.community.block",
+
RKey: rkey,
+
CID: "bafyblock123",
+
Record: map[string]interface{}{
+
"$type": "social.coves.community.block",
+
"subject": community.DID,
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
// Process event
+
err := consumer.HandleEvent(ctx, event)
+
if err != nil {
+
t.Fatalf("Failed to handle block event: %v", err)
+
}
+
+
// Verify block indexed
+
block, err := repo.GetBlock(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("Failed to get block: %v", err)
+
}
+
+
if block.UserDID != userDID {
+
t.Errorf("Expected userDID=%s, got %s", userDID, block.UserDID)
+
}
+
if block.CommunityDID != community.DID {
+
t.Errorf("Expected communityDID=%s, got %s", community.DID, block.CommunityDID)
+
}
+
+
// Verify IsBlocked works
+
isBlocked, err := repo.IsBlocked(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("IsBlocked failed: %v", err)
+
}
+
if !isBlocked {
+
t.Error("Expected IsBlocked=true, got false")
+
}
+
})
+
+
t.Run("indexes block DELETE event", func(t *testing.T) {
+
userDID := "did:plc:test-user-unblocker"
+
rkey := "test-block-2"
+
uri := fmt.Sprintf("at://%s/social.coves.community.block/%s", userDID, rkey)
+
+
// First create a block
+
block := &communities.CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: community.DID,
+
BlockedAt: time.Now(),
+
RecordURI: uri,
+
RecordCID: "bafyblock456",
+
}
+
_, err := repo.BlockCommunity(ctx, block)
+
if err != nil {
+
t.Fatalf("Failed to create block: %v", err)
+
}
+
+
// Simulate DELETE event
+
event := &jetstream.JetstreamEvent{
+
Did: userDID,
+
Kind: "commit",
+
TimeUS: time.Now().UnixMicro(),
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-rev-2",
+
Operation: "delete",
+
Collection: "social.coves.community.block",
+
RKey: rkey,
+
},
+
}
+
+
// Process delete
+
err = consumer.HandleEvent(ctx, event)
+
if err != nil {
+
t.Fatalf("Failed to handle delete event: %v", err)
+
}
+
+
// Verify block removed
+
_, err = repo.GetBlock(ctx, userDID, community.DID)
+
if !communities.IsNotFound(err) {
+
t.Error("Expected block to be deleted")
+
}
+
+
// Verify IsBlocked returns false
+
isBlocked, err := repo.IsBlocked(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("IsBlocked failed: %v", err)
+
}
+
if isBlocked {
+
t.Error("Expected IsBlocked=false, got true")
+
}
+
})
+
+
t.Run("block is idempotent", func(t *testing.T) {
+
userDID := "did:plc:test-user-idempotent"
+
rkey := "test-block-3"
+
+
event := &jetstream.JetstreamEvent{
+
Did: userDID,
+
Kind: "commit",
+
TimeUS: time.Now().UnixMicro(),
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-rev-3",
+
Operation: "create",
+
Collection: "social.coves.community.block",
+
RKey: rkey,
+
CID: "bafyblock789",
+
Record: map[string]interface{}{
+
"$type": "social.coves.community.block",
+
"subject": community.DID,
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
// Process event twice
+
err := consumer.HandleEvent(ctx, event)
+
if err != nil {
+
t.Fatalf("First block failed: %v", err)
+
}
+
+
err = consumer.HandleEvent(ctx, event)
+
if err != nil {
+
t.Fatalf("Second block (idempotent) failed: %v", err)
+
}
+
+
// Should still exist only once
+
blocks, err := repo.ListBlockedCommunities(ctx, userDID, 10, 0)
+
if err != nil {
+
t.Fatalf("ListBlockedCommunities failed: %v", err)
+
}
+
if len(blocks) != 1 {
+
t.Errorf("Expected 1 block, got %d", len(blocks))
+
}
+
})
+
+
t.Run("handles DELETE of non-existent block gracefully", func(t *testing.T) {
+
userDID := "did:plc:test-user-nonexistent"
+
rkey := "test-block-nonexistent"
+
+
// Simulate DELETE event for block that doesn't exist
+
event := &jetstream.JetstreamEvent{
+
Did: userDID,
+
Kind: "commit",
+
TimeUS: time.Now().UnixMicro(),
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-rev-99",
+
Operation: "delete",
+
Collection: "social.coves.community.block",
+
RKey: rkey,
+
},
+
}
+
+
// Should not error (idempotent)
+
err := consumer.HandleEvent(ctx, event)
+
if err != nil {
+
t.Errorf("DELETE of non-existent block should be idempotent, got error: %v", err)
+
}
+
})
+
}
+
+
// TestCommunityBlocking_ListBlocked tests listing blocked communities
+
func TestCommunityBlocking_ListBlocked(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
ctx := context.Background()
+
db := setupTestDB(t)
+
defer cleanupBlockingTestDB(t, db)
+
+
repo := createBlockingTestCommunityRepo(t, db)
+
userDID := "did:plc:test-user-list"
+
+
// Create and block 3 communities
+
testCommunities := make([]*communities.Community, 3)
+
for i := 0; i < 3; i++ {
+
communityDID := fmt.Sprintf("did:plc:test-community-list-%d", i)
+
testCommunities[i] = createBlockingTestCommunity(t, repo, fmt.Sprintf("community-list-%d", i), communityDID)
+
+
block := &communities.CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: testCommunities[i].DID,
+
BlockedAt: time.Now(),
+
RecordURI: fmt.Sprintf("at://%s/social.coves.community.block/%d", userDID, i),
+
RecordCID: fmt.Sprintf("bafyblock%d", i),
+
}
+
_, err := repo.BlockCommunity(ctx, block)
+
if err != nil {
+
t.Fatalf("Failed to block community %d: %v", i, err)
+
}
+
}
+
+
t.Run("lists all blocked communities", func(t *testing.T) {
+
blocks, err := repo.ListBlockedCommunities(ctx, userDID, 10, 0)
+
if err != nil {
+
t.Fatalf("ListBlockedCommunities failed: %v", err)
+
}
+
+
if len(blocks) != 3 {
+
t.Errorf("Expected 3 blocks, got %d", len(blocks))
+
}
+
+
// Verify all blocks belong to correct user
+
for _, block := range blocks {
+
if block.UserDID != userDID {
+
t.Errorf("Expected userDID=%s, got %s", userDID, block.UserDID)
+
}
+
}
+
})
+
+
t.Run("pagination works correctly", func(t *testing.T) {
+
// Get first 2
+
blocks, err := repo.ListBlockedCommunities(ctx, userDID, 2, 0)
+
if err != nil {
+
t.Fatalf("ListBlockedCommunities with limit failed: %v", err)
+
}
+
if len(blocks) != 2 {
+
t.Errorf("Expected 2 blocks (paginated), got %d", len(blocks))
+
}
+
+
// Get next 2 (should only get 1)
+
blocksPage2, err := repo.ListBlockedCommunities(ctx, userDID, 2, 2)
+
if err != nil {
+
t.Fatalf("ListBlockedCommunities page 2 failed: %v", err)
+
}
+
if len(blocksPage2) != 1 {
+
t.Errorf("Expected 1 block on page 2, got %d", len(blocksPage2))
+
}
+
})
+
+
t.Run("returns empty list for user with no blocks", func(t *testing.T) {
+
blocks, err := repo.ListBlockedCommunities(ctx, "did:plc:user-no-blocks", 10, 0)
+
if err != nil {
+
t.Fatalf("ListBlockedCommunities failed: %v", err)
+
}
+
if len(blocks) != 0 {
+
t.Errorf("Expected 0 blocks, got %d", len(blocks))
+
}
+
})
+
}
+
+
// TestCommunityBlocking_IsBlocked tests the fast block check
+
func TestCommunityBlocking_IsBlocked(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
ctx := context.Background()
+
db := setupTestDB(t)
+
defer cleanupBlockingTestDB(t, db)
+
+
repo := createBlockingTestCommunityRepo(t, db)
+
+
userDID := "did:plc:test-user-isblocked"
+
communityDID := fmt.Sprintf("did:plc:test-community-%d", time.Now().UnixNano())
+
community := createBlockingTestCommunity(t, repo, "test-community-isblocked", communityDID)
+
+
t.Run("returns false when not blocked", func(t *testing.T) {
+
isBlocked, err := repo.IsBlocked(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("IsBlocked failed: %v", err)
+
}
+
if isBlocked {
+
t.Error("Expected IsBlocked=false, got true")
+
}
+
})
+
+
t.Run("returns true when blocked", func(t *testing.T) {
+
// Create block
+
block := &communities.CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: community.DID,
+
BlockedAt: time.Now(),
+
RecordURI: fmt.Sprintf("at://%s/social.coves.community.block/test", userDID),
+
RecordCID: "bafyblocktest",
+
}
+
_, err := repo.BlockCommunity(ctx, block)
+
if err != nil {
+
t.Fatalf("Failed to create block: %v", err)
+
}
+
+
// Check IsBlocked
+
isBlocked, err := repo.IsBlocked(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("IsBlocked failed: %v", err)
+
}
+
if !isBlocked {
+
t.Error("Expected IsBlocked=true, got false")
+
}
+
})
+
+
t.Run("returns false after unblock", func(t *testing.T) {
+
// Unblock
+
err := repo.UnblockCommunity(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("UnblockCommunity failed: %v", err)
+
}
+
+
// Check IsBlocked
+
isBlocked, err := repo.IsBlocked(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("IsBlocked failed: %v", err)
+
}
+
if isBlocked {
+
t.Error("Expected IsBlocked=false after unblock, got true")
+
}
+
})
+
}
+
+
// TestCommunityBlocking_GetBlock tests block retrieval
+
func TestCommunityBlocking_GetBlock(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
ctx := context.Background()
+
db := setupTestDB(t)
+
defer cleanupBlockingTestDB(t, db)
+
+
repo := createBlockingTestCommunityRepo(t, db)
+
+
userDID := "did:plc:test-user-getblock"
+
communityDID := fmt.Sprintf("did:plc:test-community-%d", time.Now().UnixNano())
+
community := createBlockingTestCommunity(t, repo, "test-community-getblock", communityDID)
+
+
t.Run("returns error when block doesn't exist", func(t *testing.T) {
+
_, err := repo.GetBlock(ctx, userDID, community.DID)
+
if !communities.IsNotFound(err) {
+
t.Errorf("Expected ErrBlockNotFound, got: %v", err)
+
}
+
})
+
+
t.Run("retrieves block by user and community DID", func(t *testing.T) {
+
// Create block
+
recordURI := fmt.Sprintf("at://%s/social.coves.community.block/test-getblock", userDID)
+
originalBlock := &communities.CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: community.DID,
+
BlockedAt: time.Now(),
+
RecordURI: recordURI,
+
RecordCID: "bafyblockgettest",
+
}
+
_, err := repo.BlockCommunity(ctx, originalBlock)
+
if err != nil {
+
t.Fatalf("Failed to create block: %v", err)
+
}
+
+
// Retrieve by user+community
+
block, err := repo.GetBlock(ctx, userDID, community.DID)
+
if err != nil {
+
t.Fatalf("GetBlock failed: %v", err)
+
}
+
+
if block.UserDID != userDID {
+
t.Errorf("Expected userDID=%s, got %s", userDID, block.UserDID)
+
}
+
if block.CommunityDID != community.DID {
+
t.Errorf("Expected communityDID=%s, got %s", community.DID, block.CommunityDID)
+
}
+
if block.RecordURI != recordURI {
+
t.Errorf("Expected recordURI=%s, got %s", recordURI, block.RecordURI)
+
}
+
})
+
+
t.Run("retrieves block by URI", func(t *testing.T) {
+
recordURI := fmt.Sprintf("at://%s/social.coves.community.block/test-getblock", userDID)
+
+
// Retrieve by URI
+
block, err := repo.GetBlockByURI(ctx, recordURI)
+
if err != nil {
+
t.Fatalf("GetBlockByURI failed: %v", err)
+
}
+
+
if block.RecordURI != recordURI {
+
t.Errorf("Expected recordURI=%s, got %s", recordURI, block.RecordURI)
+
}
+
if block.CommunityDID != community.DID {
+
t.Errorf("Expected communityDID=%s, got %s", community.DID, block.CommunityDID)
+
}
+
})
+
}
+
+
// Helper functions for blocking tests
+
+
func createBlockingTestCommunityRepo(t *testing.T, db *sql.DB) communities.Repository {
+
return postgresRepo.NewCommunityRepository(db)
+
}
+
+
func createBlockingTestCommunity(t *testing.T, repo communities.Repository, name, did string) *communities.Community {
+
community := &communities.Community{
+
DID: did,
+
Handle: fmt.Sprintf("!%s@coves.test", name),
+
Name: name,
+
DisplayName: fmt.Sprintf("Test Community %s", name),
+
Description: "Test community for blocking tests",
+
OwnerDID: did,
+
CreatedByDID: "did:plc:test-creator",
+
HostedByDID: "did:plc:test-instance",
+
Visibility: "public",
+
CreatedAt: time.Now(),
+
UpdatedAt: time.Now(),
+
}
+
+
created, err := repo.Create(context.Background(), community)
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
return created
+
}
+
+
func cleanupBlockingTestDB(t *testing.T, db *sql.DB) {
+
// Clean up test data
+
_, err := db.Exec("DELETE FROM community_blocks WHERE user_did LIKE 'did:plc:test-%'")
+
if err != nil {
+
t.Logf("Warning: Failed to clean up blocks: %v", err)
+
}
+
+
_, err = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test-community-%'")
+
if err != nil {
+
t.Logf("Warning: Failed to clean up communities: %v", err)
+
}
+
+
if closeErr := db.Close(); closeErr != nil {
+
t.Logf("Failed to close database: %v", closeErr)
+
}
+
}
+333 -19
tests/integration/community_e2e_test.go
···
"Coves/internal/api/routes"
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/utils"
"Coves/internal/core/communities"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
···
t.Logf("\n📡 V2: Querying PDS for record in community's repository...")
collection := "social.coves.community.profile"
-
rkey := extractRKeyFromURI(community.RecordURI)
+
rkey := utils.ExtractRKeyFromURI(community.RecordURI)
// V2: Query community's repository (not instance repository!)
getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
···
// NOTE: Using synthetic event for speed. Real Jetstream WebSocket testing
// happens in "Part 2: Real Jetstream Firehose Consumption" above.
t.Logf("🔄 Simulating Jetstream consumer indexing...")
-
rkey := extractRKeyFromURI(createResp.URI)
+
rkey := utils.ExtractRKeyFromURI(createResp.URI)
// V2: Event comes from community's DID (community owns the repo)
event := jetstream.JetstreamEvent{
Did: createResp.DID,
···
pdsURL = "http://localhost:3001"
}
-
rkey := extractRKeyFromURI(subscribeResp.URI)
+
rkey := utils.ExtractRKeyFromURI(subscribeResp.URI)
// CRITICAL: Use correct collection name (record type, not XRPC endpoint)
collection := "social.coves.community.subscription"
···
CID: subscribeResp.CID,
Record: map[string]interface{}{
"$type": "social.coves.community.subscription",
-
"subject": community.DID,
+
"subject": community.DID,
"contentVisibility": float64(5), // JSON numbers are float64
"createdAt": time.Now().Format(time.RFC3339),
},
···
}
// Index the subscription in AppView (simulate firehose event)
-
rkey := extractRKeyFromURI(subscription.RecordURI)
+
rkey := utils.ExtractRKeyFromURI(subscription.RecordURI)
subEvent := jetstream.JetstreamEvent{
Did: instanceDID,
TimeUS: time.Now().UnixMicro(),
···
CID: subscription.RecordCID,
Record: map[string]interface{}{
"$type": "social.coves.community.subscription",
-
"subject": community.DID,
+
"subject": community.DID,
"contentVisibility": float64(3),
"createdAt": time.Now().Format(time.RFC3339),
},
···
Operation: "delete",
Collection: "social.coves.community.subscription",
RKey: rkey,
-
CID: "", // No CID on deletes
-
Record: nil, // No record data on deletes
+
CID: "", // No CID on deletes
+
Record: nil, // No record data on deletes
},
}
if handleErr := consumer.HandleEvent(context.Background(), &deleteEvent); handleErr != nil {
···
t.Logf(" ✓ Subscriber count decremented")
})
+
t.Run("Block via XRPC endpoint", func(t *testing.T) {
+
// Create a community to block
+
community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL)
+
+
t.Logf("🚫 Blocking community via XRPC endpoint...")
+
blockReq := map[string]interface{}{
+
"community": community.DID,
+
}
+
+
blockJSON, err := json.Marshal(blockReq)
+
if err != nil {
+
t.Fatalf("Failed to marshal block request: %v", err)
+
}
+
+
req, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(blockJSON))
+
if err != nil {
+
t.Fatalf("Failed to create block request: %v", err)
+
}
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Bearer "+accessToken)
+
+
resp, err := http.DefaultClient.Do(req)
+
if err != nil {
+
t.Fatalf("Failed to POST block: %v", err)
+
}
+
defer func() { _ = resp.Body.Close() }()
+
+
if resp.StatusCode != http.StatusOK {
+
body, readErr := io.ReadAll(resp.Body)
+
if readErr != nil {
+
t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr)
+
}
+
t.Logf("❌ XRPC Block Failed")
+
t.Logf(" Status: %d", resp.StatusCode)
+
t.Logf(" Response: %s", string(body))
+
t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body))
+
}
+
+
var blockResp struct {
+
Block struct {
+
RecordURI string `json:"recordUri"`
+
RecordCID string `json:"recordCid"`
+
} `json:"block"`
+
}
+
+
if err := json.NewDecoder(resp.Body).Decode(&blockResp); err != nil {
+
t.Fatalf("Failed to decode block response: %v", err)
+
}
+
+
t.Logf("✅ XRPC block response received:")
+
t.Logf(" RecordURI: %s", blockResp.Block.RecordURI)
+
t.Logf(" RecordCID: %s", blockResp.Block.RecordCID)
+
+
// Extract rkey from URI for verification
+
rkey := ""
+
if uriParts := strings.Split(blockResp.Block.RecordURI, "/"); len(uriParts) >= 4 {
+
rkey = uriParts[len(uriParts)-1]
+
}
+
+
// Verify the block record exists on PDS
+
t.Logf("🔍 Verifying block record exists on PDS...")
+
collection := "social.coves.community.block"
+
pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
+
pdsURL, instanceDID, collection, rkey))
+
if pdsErr != nil {
+
t.Fatalf("Failed to query PDS: %v", pdsErr)
+
}
+
defer func() {
+
if closeErr := pdsResp.Body.Close(); closeErr != nil {
+
t.Logf("Failed to close PDS response: %v", closeErr)
+
}
+
}()
+
+
if pdsResp.StatusCode != http.StatusOK {
+
body, readErr := io.ReadAll(pdsResp.Body)
+
if readErr != nil {
+
t.Fatalf("Block record not found on PDS (status: %d, failed to read body: %v)", pdsResp.StatusCode, readErr)
+
}
+
t.Fatalf("Block record not found on PDS (status: %d): %s", pdsResp.StatusCode, string(body))
+
}
+
t.Logf("✅ Block record exists on PDS")
+
+
// CRITICAL: Simulate Jetstream consumer indexing the block
+
t.Logf("🔄 Simulating Jetstream consumer indexing block event...")
+
blockEvent := jetstream.JetstreamEvent{
+
Did: instanceDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-block-rev",
+
Operation: "create",
+
Collection: "social.coves.community.block",
+
RKey: rkey,
+
CID: blockResp.Block.RecordCID,
+
Record: map[string]interface{}{
+
"subject": community.DID,
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
if handleErr := consumer.HandleEvent(context.Background(), &blockEvent); handleErr != nil {
+
t.Fatalf("Failed to handle block event: %v", handleErr)
+
}
+
+
// Verify block was indexed in AppView
+
t.Logf("🔍 Verifying block indexed in AppView...")
+
block, err := communityRepo.GetBlock(ctx, instanceDID, community.DID)
+
if err != nil {
+
t.Fatalf("Failed to get block from AppView: %v", err)
+
}
+
if block.RecordURI != blockResp.Block.RecordURI {
+
t.Errorf("RecordURI mismatch: expected %s, got %s", blockResp.Block.RecordURI, block.RecordURI)
+
}
+
+
t.Logf("✅ TRUE E2E BLOCK FLOW COMPLETE:")
+
t.Logf(" Client → XRPC Block → PDS Create → Firehose → Consumer → AppView ✓")
+
t.Logf(" ✓ Block record created on PDS")
+
t.Logf(" ✓ Block indexed in AppView")
+
})
+
+
t.Run("Unblock via XRPC endpoint", func(t *testing.T) {
+
// Create a community and block it first
+
community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL)
+
+
// Block the community
+
t.Logf("🚫 Blocking community first...")
+
blockReq := map[string]interface{}{
+
"community": community.DID,
+
}
+
blockJSON, err := json.Marshal(blockReq)
+
if err != nil {
+
t.Fatalf("Failed to marshal block request: %v", err)
+
}
+
+
blockHttpReq, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(blockJSON))
+
if err != nil {
+
t.Fatalf("Failed to create block request: %v", err)
+
}
+
blockHttpReq.Header.Set("Content-Type", "application/json")
+
blockHttpReq.Header.Set("Authorization", "Bearer "+accessToken)
+
+
blockResp, err := http.DefaultClient.Do(blockHttpReq)
+
if err != nil {
+
t.Fatalf("Failed to POST block: %v", err)
+
}
+
+
var blockRespData struct {
+
Block struct {
+
RecordURI string `json:"recordUri"`
+
} `json:"block"`
+
}
+
if err := json.NewDecoder(blockResp.Body).Decode(&blockRespData); err != nil {
+
func() { _ = blockResp.Body.Close() }()
+
t.Fatalf("Failed to decode block response: %v", err)
+
}
+
func() { _ = blockResp.Body.Close() }()
+
+
rkey := ""
+
if uriParts := strings.Split(blockRespData.Block.RecordURI, "/"); len(uriParts) >= 4 {
+
rkey = uriParts[len(uriParts)-1]
+
}
+
+
// Index the block via consumer
+
blockEvent := jetstream.JetstreamEvent{
+
Did: instanceDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-block-rev",
+
Operation: "create",
+
Collection: "social.coves.community.block",
+
RKey: rkey,
+
CID: "test-block-cid",
+
Record: map[string]interface{}{
+
"subject": community.DID,
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
if handleErr := consumer.HandleEvent(context.Background(), &blockEvent); handleErr != nil {
+
t.Fatalf("Failed to handle block event: %v", handleErr)
+
}
+
+
// Now unblock the community
+
t.Logf("✅ Unblocking community via XRPC endpoint...")
+
unblockReq := map[string]interface{}{
+
"community": community.DID,
+
}
+
+
unblockJSON, err := json.Marshal(unblockReq)
+
if err != nil {
+
t.Fatalf("Failed to marshal unblock request: %v", err)
+
}
+
+
req, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(unblockJSON))
+
if err != nil {
+
t.Fatalf("Failed to create unblock request: %v", err)
+
}
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Bearer "+accessToken)
+
+
resp, err := http.DefaultClient.Do(req)
+
if err != nil {
+
t.Fatalf("Failed to POST unblock: %v", err)
+
}
+
defer func() { _ = resp.Body.Close() }()
+
+
if resp.StatusCode != http.StatusOK {
+
body, readErr := io.ReadAll(resp.Body)
+
if readErr != nil {
+
t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr)
+
}
+
t.Logf("❌ XRPC Unblock Failed")
+
t.Logf(" Status: %d", resp.StatusCode)
+
t.Logf(" Response: %s", string(body))
+
t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body))
+
}
+
+
var unblockResp struct {
+
Success bool `json:"success"`
+
}
+
+
if err := json.NewDecoder(resp.Body).Decode(&unblockResp); err != nil {
+
t.Fatalf("Failed to decode unblock response: %v", err)
+
}
+
+
if !unblockResp.Success {
+
t.Errorf("Expected success: true, got: %v", unblockResp.Success)
+
}
+
+
// Verify the block record was deleted from PDS
+
t.Logf("🔍 Verifying block record deleted from PDS...")
+
collection := "social.coves.community.block"
+
pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
+
pdsURL, instanceDID, collection, rkey))
+
if pdsErr != nil {
+
t.Fatalf("Failed to query PDS: %v", pdsErr)
+
}
+
defer func() {
+
if closeErr := pdsResp.Body.Close(); closeErr != nil {
+
t.Logf("Failed to close PDS response: %v", closeErr)
+
}
+
}()
+
+
if pdsResp.StatusCode == http.StatusOK {
+
t.Errorf("❌ Block record still exists on PDS (expected 404, got 200)")
+
} else {
+
t.Logf("✅ Block record successfully deleted from PDS (status: %d)", pdsResp.StatusCode)
+
}
+
+
// CRITICAL: Simulate Jetstream consumer indexing the DELETE event
+
t.Logf("🔄 Simulating Jetstream consumer indexing DELETE event...")
+
deleteEvent := jetstream.JetstreamEvent{
+
Did: instanceDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "test-unblock-rev",
+
Operation: "delete",
+
Collection: "social.coves.community.block",
+
RKey: rkey,
+
CID: "",
+
Record: nil,
+
},
+
}
+
if handleErr := consumer.HandleEvent(context.Background(), &deleteEvent); handleErr != nil {
+
t.Fatalf("Failed to handle delete event: %v", handleErr)
+
}
+
+
// Verify block was removed from AppView
+
t.Logf("🔍 Verifying block removed from AppView...")
+
_, err = communityRepo.GetBlock(ctx, instanceDID, community.DID)
+
if err == nil {
+
t.Errorf("❌ Block still exists in AppView (should be deleted)")
+
} else if !communities.IsNotFound(err) {
+
t.Fatalf("Unexpected error querying block: %v", err)
+
} else {
+
t.Logf("✅ Block removed from AppView")
+
}
+
+
t.Logf("✅ TRUE E2E UNBLOCK FLOW COMPLETE:")
+
t.Logf(" Client → XRPC Unblock → PDS Delete → Firehose → Consumer → AppView ✓")
+
t.Logf(" ✓ Block deleted from PDS")
+
t.Logf(" ✓ Block removed from AppView")
+
})
+
+
t.Run("Block fails without authentication", func(t *testing.T) {
+
// Create a community to attempt blocking
+
community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL)
+
+
t.Logf("🔒 Attempting to block community without auth token...")
+
blockReq := map[string]interface{}{
+
"community": community.DID,
+
}
+
+
blockJSON, err := json.Marshal(blockReq)
+
if err != nil {
+
t.Fatalf("Failed to marshal block request: %v", err)
+
}
+
+
req, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(blockJSON))
+
if err != nil {
+
t.Fatalf("Failed to create block request: %v", err)
+
}
+
req.Header.Set("Content-Type", "application/json")
+
// NO Authorization header
+
+
resp, err := http.DefaultClient.Do(req)
+
if err != nil {
+
t.Fatalf("Failed to POST block: %v", err)
+
}
+
defer func() { _ = resp.Body.Close() }()
+
+
// Should fail with 401 Unauthorized
+
if resp.StatusCode != http.StatusUnauthorized {
+
body, _ := io.ReadAll(resp.Body)
+
t.Errorf("Expected 401 Unauthorized, got %d: %s", resp.StatusCode, string(body))
+
} else {
+
t.Logf("✅ Block correctly rejected without authentication (401)")
+
}
+
})
+
t.Run("Update via XRPC endpoint", func(t *testing.T) {
// Create a community first (via service, so it's indexed)
community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL)
···
// Simulate Jetstream consumer picking up the update event
t.Logf("🔄 Simulating Jetstream consumer indexing update...")
-
rkey := extractRKeyFromURI(updateResp.URI)
+
rkey := utils.ExtractRKeyFromURI(updateResp.URI)
// Fetch updated record from PDS
pdsURL := os.Getenv("PDS_URL")
···
// Fetch from PDS to get full record
// V2: Record lives in community's own repository (at://community.DID/...)
collection := "social.coves.community.profile"
-
rkey := extractRKeyFromURI(community.RecordURI)
+
rkey := utils.ExtractRKeyFromURI(community.RecordURI)
pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
pdsURL, community.DID, collection, rkey))
···
return community
-
}
-
-
func extractRKeyFromURI(uri string) string {
-
// at://did/collection/rkey -> rkey
-
parts := strings.Split(uri, "/")
-
if len(parts) >= 4 {
-
return parts[len(parts)-1]
-
}
-
return ""
// authenticateWithPDS authenticates with the PDS and returns access token and DID
+12 -12
tests/integration/subscription_indexing_test.go
···
RKey: rkey,
CID: "bafytest123",
Record: map[string]interface{}{
-
"$type": "social.coves.community.subscription",
-
"subject": community.DID,
+
"$type": "social.coves.community.subscription",
+
"subject": community.DID,
"createdAt": time.Now().Format(time.RFC3339),
"contentVisibility": float64(5), // JSON numbers decode as float64
},
···
RKey: rkey,
CID: "bafydefault",
Record: map[string]interface{}{
-
"$type": "social.coves.community.subscription",
-
"subject": community.DID,
+
"$type": "social.coves.community.subscription",
+
"subject": community.DID,
"createdAt": time.Now().Format(time.RFC3339),
// contentVisibility NOT provided
},
···
t.Run("clamps contentVisibility to valid range (1-5)", func(t *testing.T) {
testCases := []struct {
+
name string
input float64
expected int
-
name string
}{
{input: 0, expected: 1, name: "zero clamped to 1"},
{input: -5, expected: 1, name: "negative clamped to 1"},
···
RKey: rkey,
CID: "bafyidempotent",
Record: map[string]interface{}{
-
"$type": "social.coves.community.subscription",
-
"subject": community.DID,
+
"$type": "social.coves.community.subscription",
+
"subject": community.DID,
"createdAt": time.Now().Format(time.RFC3339),
"contentVisibility": float64(4),
},
···
RKey: rkey,
CID: "bafycreate",
Record: map[string]interface{}{
-
"$type": "social.coves.community.subscription",
-
"subject": community.DID,
+
"$type": "social.coves.community.subscription",
+
"subject": community.DID,
"createdAt": time.Now().Format(time.RFC3339),
"contentVisibility": float64(3),
},
···
Operation: "delete",
Collection: "social.coves.community.subscription",
RKey: rkey,
-
CID: "", // No CID on deletes
+
CID: "", // No CID on deletes
Record: nil, // No record data on deletes
},
}
···
RKey: rkey,
CID: "bafycount",
Record: map[string]interface{}{
-
"$type": "social.coves.community.subscription",
-
"subject": community.DID,
+
"$type": "social.coves.community.subscription",
+
"subject": community.DID,
"createdAt": time.Now().Format(time.RFC3339),
"contentVisibility": float64(3),
},
+24
tests/unit/community_service_test.go
···
return nil, nil
}
+
func (m *mockCommunityRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) {
+
return block, nil
+
}
+
+
func (m *mockCommunityRepo) UnblockCommunity(ctx context.Context, userDID, communityDID string) error {
+
return nil
+
}
+
+
func (m *mockCommunityRepo) GetBlock(ctx context.Context, userDID, communityDID string) (*communities.CommunityBlock, error) {
+
return nil, communities.ErrBlockNotFound
+
}
+
+
func (m *mockCommunityRepo) GetBlockByURI(ctx context.Context, recordURI string) (*communities.CommunityBlock, error) {
+
return nil, communities.ErrBlockNotFound
+
}
+
+
func (m *mockCommunityRepo) ListBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityRepo) IsBlocked(ctx context.Context, userDID, communityDID string) (bool, error) {
+
return false, nil
+
}
+
func (m *mockCommunityRepo) CreateMembership(ctx context.Context, membership *communities.Membership) (*communities.Membership, error) {
return membership, nil
}