A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+5644 -319
.beads
aggregators
cmd
reindex-votes
server
internal
scripts
tests
+3
.beads/beads.left.jsonl
···
+
{"id":"Coves-95q","content_hash":"8ec99d598f067780436b985f9ad57f0fa19632026981038df4f65f192186620b","title":"Add comprehensive API documentation","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-17T20:30:34.835721854-08:00","updated_at":"2025-11-17T20:30:34.835721854-08:00","source_repo":".","dependencies":[{"issue_id":"Coves-95q","depends_on_id":"Coves-e16","type":"blocks","created_at":"2025-11-17T20:30:46.273899399-08:00","created_by":"daemon"}]}
+
{"id":"Coves-e16","content_hash":"7c5d0fc8f0e7f626be3dad62af0e8412467330bad01a244e5a7e52ac5afff1c1","title":"Complete post creation and moderation features","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:12.885991306-08:00","updated_at":"2025-11-17T20:30:12.885991306-08:00","source_repo":"."}
+
{"id":"Coves-fce","content_hash":"26b3e16b99f827316ee0d741cc959464bd0c813446c95aef8105c7fd1e6b09ff","title":"Implement aggregator feed federation","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:21.453326012-08:00","updated_at":"2025-11-17T20:30:21.453326012-08:00","source_repo":"."}
+1
.beads/beads.left.meta.json
···
+
{"version":"0.23.1","timestamp":"2025-12-02T18:25:24.009187871-08:00","commit":"00d7d8d"}
+2 -8
internal/api/handlers/vote/delete_vote_test.go
···
func TestDeleteVoteHandler_ServiceError(t *testing.T) {
tests := []struct {
-
name string
serviceError error
-
expectedStatus int
+
name string
expectedError string
+
expectedStatus int
}{
{
name: "vote not found",
···
expectedStatus: http.StatusNotFound,
expectedError: "VoteNotFound", // Per lexicon: social.coves.feed.vote.delete#VoteNotFound
},
-
{
-
name: "subject not found",
-
serviceError: votes.ErrSubjectNotFound,
-
expectedStatus: http.StatusNotFound,
-
expectedError: "SubjectNotFound", // Per lexicon: social.coves.feed.vote.create#SubjectNotFound
-
},
{
name: "invalid subject",
serviceError: votes.ErrInvalidSubject,
-3
internal/api/handlers/vote/errors.go
···
case errors.Is(err, votes.ErrVoteNotFound):
// Matches: social.coves.feed.vote.delete#VoteNotFound
writeError(w, http.StatusNotFound, "VoteNotFound", "No vote found for this subject")
-
case errors.Is(err, votes.ErrSubjectNotFound):
-
// Matches: social.coves.feed.vote.create#SubjectNotFound
-
writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")
case errors.Is(err, votes.ErrInvalidDirection):
writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
case errors.Is(err, votes.ErrInvalidSubject):
-4
internal/atproto/lexicon/social/coves/feed/vote/create.json
···
}
},
"errors": [
-
{
-
"name": "SubjectNotFound",
-
"description": "The subject post or comment was not found"
-
},
{
"name": "NotAuthorized",
"description": "User is not authorized to vote on this content"
+4 -4
internal/atproto/oauth/handlers_security.go
···
// - Android: Verified via /.well-known/assetlinks.json
var allowedMobileRedirectURIs = map[string]bool{
// Custom scheme per atproto spec (reverse-domain of coves.social)
-
"social.coves:/callback": true,
-
"social.coves://callback": true, // Some platforms add double slash
-
"social.coves:/oauth/callback": true, // Alternative path
-
"social.coves://oauth/callback": true,
+
"social.coves:/callback": true,
+
"social.coves://callback": true, // Some platforms add double slash
+
"social.coves:/oauth/callback": true, // Alternative path
+
"social.coves://oauth/callback": true,
// Universal Links - cryptographically bound to app (preferred for security)
"https://coves.social/app/oauth/callback": true,
}
-3
internal/core/votes/errors.go
···
// ErrVoteNotFound indicates the requested vote doesn't exist
ErrVoteNotFound = errors.New("vote not found")
-
// ErrSubjectNotFound indicates the post/comment being voted on doesn't exist
-
ErrSubjectNotFound = errors.New("subject not found")
-
// ErrInvalidDirection indicates the vote direction is not "up" or "down"
ErrInvalidDirection = errors.New("invalid vote direction: must be 'up' or 'down'")
-50
internal/core/votes/subject_validator.go
···
-
package votes
-
-
import (
-
"context"
-
"strings"
-
)
-
-
// SubjectExistsFunc is a function type that checks if a subject exists
-
type SubjectExistsFunc func(ctx context.Context, uri string) (bool, error)
-
-
// CompositeSubjectValidator validates subjects by checking both posts and comments
-
type CompositeSubjectValidator struct {
-
postExists SubjectExistsFunc
-
commentExists SubjectExistsFunc
-
}
-
-
// NewCompositeSubjectValidator creates a validator that checks both posts and comments
-
// Pass nil for either function to skip validation for that type
-
func NewCompositeSubjectValidator(postExists, commentExists SubjectExistsFunc) *CompositeSubjectValidator {
-
return &CompositeSubjectValidator{
-
postExists: postExists,
-
commentExists: commentExists,
-
}
-
}
-
-
// SubjectExists checks if a post or comment exists at the given URI
-
// Determines type from the collection in the URI (e.g., social.coves.feed.post vs social.coves.feed.comment)
-
func (v *CompositeSubjectValidator) SubjectExists(ctx context.Context, uri string) (bool, error) {
-
// Parse collection from AT-URI: at://did/collection/rkey
-
// Example: at://did:plc:xxx/social.coves.feed.post/abc123
-
if strings.Contains(uri, "/social.coves.feed.post/") {
-
if v.postExists != nil {
-
return v.postExists(ctx, uri)
-
}
-
// If no post checker, assume exists (for testing)
-
return true, nil
-
}
-
-
if strings.Contains(uri, "/social.coves.feed.comment/") {
-
if v.commentExists != nil {
-
return v.commentExists(ctx, uri)
-
}
-
// If no comment checker, assume exists (for testing)
-
return true, nil
-
}
-
-
// Unknown collection type - could be from another app
-
// For now, allow voting on unknown types (future-proofing)
-
return true, nil
-
}
-9
internal/core/votes/vote.go
···
package votes
import (
-
"context"
"time"
)
-
// SubjectValidator validates that vote subjects (posts/comments) exist
-
// This prevents creating votes on non-existent content
-
type SubjectValidator interface {
-
// SubjectExists checks if a post or comment exists at the given URI
-
// Returns true if found, false if not found
-
SubjectExists(ctx context.Context, uri string) (bool, error)
-
}
-
// Vote represents a vote in the AppView database
// Votes are indexed from the firehose after being written to user repositories
type Vote struct {
+3 -2
internal/db/postgres/vote_repo.go
···
return nil
}
-
// GetByURI retrieves a vote by its AT-URI
+
// GetByURI retrieves an active vote by its AT-URI
// Used by Jetstream consumer for DELETE operations
+
// Returns ErrVoteNotFound for soft-deleted votes
func (r *postgresVoteRepo) GetByURI(ctx context.Context, uri string) (*votes.Vote, error) {
query := `
SELECT
···
subject_uri, subject_cid, direction,
created_at, indexed_at, deleted_at
FROM votes
-
WHERE uri = $1
+
WHERE uri = $1 AND deleted_at IS NULL
`
var vote votes.Vote
+18
.env.dev
···
#
PLC_DIRECTORY_URL=http://localhost:3002
+
# =============================================================================
+
# Dev Mode Quick Reference
+
# =============================================================================
+
# REQUIRED for local OAuth to work with local PDS:
+
# IS_DEV_ENV=true # Master switch for dev mode
+
# PDS_URL=http://localhost:3001 # Local PDS for handle resolution
+
# PLC_DIRECTORY_URL=http://localhost:3002 # Local PLC directory
+
# APPVIEW_PUBLIC_URL=http://127.0.0.1:8081 # Use IP not localhost (RFC 8252)
+
#
+
# BUILD TAGS:
+
# make run - Runs with -tags dev (includes localhost OAuth resolvers)
+
# make build - Production binary (no dev code)
+
# make build-dev - Dev binary (includes dev code)
+
#
+
# Dev-only code (only compiled with -tags dev):
+
# - internal/atproto/oauth/dev_resolver.go (handle resolution via local PDS)
+
# - internal/atproto/oauth/dev_auth_resolver.go (localhost OAuth bypass)
+
#
# =============================================================================
# Notes
# =============================================================================
+92
.env.dev.example
···
+
# Coves Local Development Environment Configuration
+
# Copy this to .env.dev and fill in your values
+
#
+
# Quick Start:
+
# 1. cp .env.dev.example .env.dev
+
# 2. Generate OAuth key: go run cmd/genjwks/main.go (copy output to OAUTH_PRIVATE_JWK)
+
# 3. Generate cookie secret: openssl rand -hex 32
+
# 4. make dev-up # Start Docker services
+
# 5. make run # Start the server (uses -tags dev)
+
+
# =============================================================================
+
# Dev Mode Quick Reference
+
# =============================================================================
+
# REQUIRED for local OAuth to work with local PDS:
+
# IS_DEV_ENV=true # Master switch for dev mode
+
# PDS_URL=http://localhost:3001 # Local PDS for handle resolution
+
# PLC_DIRECTORY_URL=http://localhost:3002 # Local PLC directory
+
# APPVIEW_PUBLIC_URL=http://127.0.0.1:8081 # Use IP not localhost (RFC 8252)
+
#
+
# BUILD TAGS:
+
# make run - Runs with -tags dev (includes localhost OAuth resolvers)
+
# make build - Production binary (no dev code)
+
# make build-dev - Dev binary (includes dev code)
+
+
# =============================================================================
+
# PostgreSQL Configuration
+
# =============================================================================
+
POSTGRES_HOST=localhost
+
POSTGRES_PORT=5435
+
POSTGRES_DB=coves_dev
+
POSTGRES_USER=dev_user
+
POSTGRES_PASSWORD=dev_password
+
+
# Test database
+
POSTGRES_TEST_DB=coves_test
+
POSTGRES_TEST_USER=test_user
+
POSTGRES_TEST_PASSWORD=test_password
+
POSTGRES_TEST_PORT=5434
+
+
# =============================================================================
+
# PDS Configuration
+
# =============================================================================
+
PDS_HOSTNAME=localhost
+
PDS_PORT=3001
+
PDS_SERVICE_ENDPOINT=http://localhost:3000
+
PDS_DID_PLC_URL=http://plc-directory:3000
+
PDS_JWT_SECRET=local-dev-jwt-secret-change-in-production
+
PDS_ADMIN_PASSWORD=admin
+
PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social
+
PDS_PLC_ROTATION_KEY=<generate-a-random-hex-key>
+
+
# =============================================================================
+
# AppView Configuration
+
# =============================================================================
+
APPVIEW_PORT=8081
+
FIREHOSE_URL=ws://localhost:3001/xrpc/com.atproto.sync.subscribeRepos
+
PDS_URL=http://localhost:3001
+
APPVIEW_PUBLIC_URL=http://127.0.0.1:8081
+
+
# =============================================================================
+
# Jetstream Configuration
+
# =============================================================================
+
JETSTREAM_URL=ws://localhost:6008/subscribe
+
+
# =============================================================================
+
# Identity Resolution
+
# =============================================================================
+
IDENTITY_CACHE_TTL=24h
+
PLC_DIRECTORY_URL=http://localhost:3002
+
+
# =============================================================================
+
# OAuth Configuration (MUST GENERATE YOUR OWN)
+
# =============================================================================
+
# Generate with: go run cmd/genjwks/main.go
+
OAUTH_PRIVATE_JWK=<generate-your-own-jwk>
+
+
# Generate with: openssl rand -hex 32
+
OAUTH_COOKIE_SECRET=<generate-your-own-secret>
+
+
# =============================================================================
+
# Development Settings
+
# =============================================================================
+
ENV=development
+
NODE_ENV=development
+
IS_DEV_ENV=true
+
LOG_LEVEL=debug
+
LOG_ENABLED=true
+
+
# Security settings (ONLY for local dev - set to false in production!)
+
SKIP_DID_WEB_VERIFICATION=true
+
AUTH_SKIP_VERIFY=true
+
HS256_ISSUERS=http://localhost:3001
+13 -2
internal/atproto/oauth/client.go
···
import (
"encoding/base64"
"fmt"
+
"log/slog"
"net/url"
"time"
···
PublicURL string
SealSecret string
PLCURL string
+
PDSURL string // For dev mode: resolve handles via local PDS
Scopes []string
SessionTTL time.Duration
SealedTokenTTL time.Duration
···
// Create indigo client config
var clientConfig oauth.ClientConfig
if config.DevMode {
-
// Dev mode: localhost with HTTP
-
callbackURL := "http://localhost:3000/oauth/callback"
+
// Dev mode: loopback with HTTP
+
// IMPORTANT: Use 127.0.0.1 instead of localhost per RFC 8252 - PDS rejects localhost
+
// The callback URL must match the APPVIEW_PUBLIC_URL from .env.dev
+
callbackURL := config.PublicURL + "/oauth/callback"
clientConfig = oauth.NewLocalhostConfig(callbackURL, config.Scopes)
+
slog.Info("dev mode: OAuth client configured",
+
"callback_url", callbackURL,
+
"client_id", clientConfig.ClientID)
} else {
// Production mode: public OAuth client with HTTPS
// client_id must be the URL of the client metadata document per atproto OAuth spec
···
// Use pointer since CacheDirectory methods have pointer receivers
cacheDir := identity.NewCacheDirectory(baseDir, 100_000, time.Hour*24, time.Minute*2, time.Minute*5)
clientApp.Dir = &cacheDir
+
// Log the PLC URL being used for OAuth directory resolution
+
fmt.Printf("๐Ÿ” OAuth client directory configured with PLC URL: %s (AllowPrivateIPs: %v)\n", config.PLCURL, config.AllowPrivateIPs)
+
} else {
+
fmt.Println("โš ๏ธ OAuth client using DEFAULT PLC directory (production plc.directory)")
}
return &OAuthClient{
+285
internal/atproto/oauth/dev_auth_resolver.go
···
+
//go:build dev
+
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// DevAuthResolver is a custom OAuth resolver that allows HTTP localhost URLs for development.
+
// The standard indigo OAuth resolver requires HTTPS and no port numbers, which breaks local testing.
+
type DevAuthResolver struct {
+
Client *http.Client
+
UserAgent string
+
PDSURL string // For resolving handles via local PDS
+
handleResolver *DevHandleResolver
+
}
+
+
// ProtectedResourceMetadata matches the OAuth protected resource metadata document format
+
type ProtectedResourceMetadata struct {
+
Resource string `json:"resource"`
+
AuthorizationServers []string `json:"authorization_servers"`
+
}
+
+
// NewDevAuthResolver creates a resolver that accepts localhost HTTP URLs
+
func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver {
+
resolver := &DevAuthResolver{
+
Client: NewSSRFSafeHTTPClient(allowPrivateIPs),
+
UserAgent: "Coves/1.0",
+
PDSURL: pdsURL,
+
}
+
// Create handle resolver for resolving handles via local PDS
+
if pdsURL != "" {
+
resolver.handleResolver = NewDevHandleResolver(pdsURL, allowPrivateIPs)
+
}
+
return resolver
+
}
+
+
// ResolveAuthServerURL resolves a PDS URL to an auth server URL.
+
// Unlike indigo's standard resolver, this allows HTTP and ports for localhost.
+
func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) {
+
u, err := url.Parse(hostURL)
+
if err != nil {
+
return "", err
+
}
+
+
// For localhost, allow HTTP and port numbers
+
isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
+
if !isLocalhost {
+
// For non-localhost, enforce HTTPS and no port (standard rules)
+
if u.Scheme != "https" || u.Port() != "" {
+
return "", fmt.Errorf("not a valid public host URL: %s", hostURL)
+
}
+
}
+
+
// Build the protected resource document URL
+
var docURL string
+
if isLocalhost {
+
// For localhost, preserve the port and use HTTP
+
port := u.Port()
+
if port == "" {
+
port = "3001" // Default PDS port
+
}
+
docURL = fmt.Sprintf("http://%s:%s/.well-known/oauth-protected-resource", u.Hostname(), port)
+
} else {
+
docURL = fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname())
+
}
+
+
// Fetch the protected resource document
+
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
+
if err != nil {
+
return "", err
+
}
+
if r.UserAgent != "" {
+
req.Header.Set("User-Agent", r.UserAgent)
+
}
+
+
resp, err := r.Client.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("fetching protected resource document: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode)
+
}
+
+
var body ProtectedResourceMetadata
+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+
return "", fmt.Errorf("invalid protected resource document: %w", err)
+
}
+
+
if len(body.AuthorizationServers) < 1 {
+
return "", fmt.Errorf("no auth server URL in protected resource document")
+
}
+
+
authURL := body.AuthorizationServers[0]
+
+
// Validate the auth server URL (with localhost exception)
+
au, err := url.Parse(authURL)
+
if err != nil {
+
return "", fmt.Errorf("invalid auth server URL: %w", err)
+
}
+
+
authIsLocalhost := au.Hostname() == "localhost" || au.Hostname() == "127.0.0.1"
+
if !authIsLocalhost {
+
if au.Scheme != "https" || au.Port() != "" {
+
return "", fmt.Errorf("invalid auth server URL: %s", authURL)
+
}
+
}
+
+
return authURL, nil
+
}
+
+
// ResolveAuthServerMetadataDev fetches OAuth server metadata from a given auth server URL.
+
// Unlike indigo's resolver, this allows HTTP and ports for localhost.
+
func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) {
+
u, err := url.Parse(serverURL)
+
if err != nil {
+
return nil, err
+
}
+
+
// Build metadata URL - preserve port for localhost
+
var metaURL string
+
isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
+
if isLocalhost && u.Port() != "" {
+
metaURL = fmt.Sprintf("%s://%s:%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname(), u.Port())
+
} else if isLocalhost {
+
metaURL = fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname())
+
} else {
+
metaURL = fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname())
+
}
+
+
slog.Debug("dev mode: fetching auth server metadata", "url", metaURL)
+
+
req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil)
+
if err != nil {
+
return nil, err
+
}
+
if r.UserAgent != "" {
+
req.Header.Set("User-Agent", r.UserAgent)
+
}
+
+
resp, err := r.Client.Do(req)
+
if err != nil {
+
return nil, fmt.Errorf("fetching auth server metadata: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode)
+
}
+
+
var metadata oauthlib.AuthServerMetadata
+
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
+
return nil, fmt.Errorf("invalid auth server metadata: %w", err)
+
}
+
+
// Skip validation for localhost (indigo's Validate checks HTTPS)
+
if !isLocalhost {
+
if err := metadata.Validate(serverURL); err != nil {
+
return nil, fmt.Errorf("invalid auth server metadata: %w", err)
+
}
+
}
+
+
return &metadata, nil
+
}
+
+
// StartDevAuthFlow performs OAuth flow for localhost development.
+
// This bypasses indigo's HTTPS validation for the auth server URL.
+
// It resolves the identity, gets the PDS endpoint, fetches auth server metadata,
+
// and returns a redirect URL for the user to approve.
+
func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+
var accountDID syntax.DID
+
var pdsEndpoint string
+
+
// Check if identifier is a handle or DID
+
if strings.HasPrefix(identifier, "did:") {
+
// It's a DID - look up via directory (PLC)
+
atid, err := syntax.ParseAtIdentifier(identifier)
+
if err != nil {
+
return "", fmt.Errorf("not a valid DID (%s): %w", identifier, err)
+
}
+
ident, err := dir.Lookup(ctx, *atid)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve DID (%s): %w", identifier, err)
+
}
+
accountDID = ident.DID
+
pdsEndpoint = ident.PDSEndpoint()
+
} else {
+
// It's a handle - resolve via local PDS first
+
if r.handleResolver == nil {
+
return "", fmt.Errorf("handle resolution not configured (PDS URL not set)")
+
}
+
+
// Resolve handle to DID via local PDS
+
did, err := r.handleResolver.ResolveHandle(ctx, identifier)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve handle via PDS (%s): %w", identifier, err)
+
}
+
if did == "" {
+
return "", fmt.Errorf("handle not found: %s", identifier)
+
}
+
+
slog.Info("dev mode: resolved handle via local PDS", "handle", identifier, "did", did)
+
+
// Parse the DID
+
parsedDID, err := syntax.ParseDID(did)
+
if err != nil {
+
return "", fmt.Errorf("invalid DID from PDS (%s): %w", did, err)
+
}
+
accountDID = parsedDID
+
+
// Now look up the DID document via PLC to get PDS endpoint
+
atid, err := syntax.ParseAtIdentifier(did)
+
if err != nil {
+
return "", fmt.Errorf("not a valid DID (%s): %w", did, err)
+
}
+
ident, err := dir.Lookup(ctx, *atid)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve DID document (%s): %w", did, err)
+
}
+
pdsEndpoint = ident.PDSEndpoint()
+
}
+
+
if pdsEndpoint == "" {
+
return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
+
}
+
+
slog.Debug("dev mode: resolving auth server",
+
"did", accountDID,
+
"pds", pdsEndpoint)
+
+
// Resolve auth server URL (allowing HTTP for localhost)
+
authServerURL, err := r.ResolveAuthServerURL(ctx, pdsEndpoint)
+
if err != nil {
+
return "", fmt.Errorf("resolving auth server: %w", err)
+
}
+
+
slog.Info("dev mode: resolved auth server", "url", authServerURL)
+
+
// Fetch auth server metadata using our dev-friendly resolver
+
authMeta, err := r.ResolveAuthServerMetadataDev(ctx, authServerURL)
+
if err != nil {
+
return "", fmt.Errorf("fetching auth server metadata: %w", err)
+
}
+
+
slog.Debug("dev mode: got auth server metadata",
+
"issuer", authMeta.Issuer,
+
"authorization_endpoint", authMeta.AuthorizationEndpoint,
+
"token_endpoint", authMeta.TokenEndpoint)
+
+
// Send auth request (PAR) using indigo's method
+
info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier)
+
if err != nil {
+
return "", fmt.Errorf("auth request failed: %w", err)
+
}
+
+
// Set the account DID
+
info.AccountDID = &accountDID
+
+
// Persist auth request info
+
client.ClientApp.Store.SaveAuthRequestInfo(ctx, *info)
+
+
// Build redirect URL
+
params := url.Values{}
+
params.Set("client_id", client.ClientApp.Config.ClientID)
+
params.Set("request_uri", info.RequestURI)
+
+
authEndpoint := authMeta.AuthorizationEndpoint
+
redirectURL := fmt.Sprintf("%s?%s", authEndpoint, params.Encode())
+
+
slog.Info("dev mode: OAuth redirect URL built", "url_prefix", authEndpoint)
+
+
return redirectURL, nil
+
}
+106
internal/atproto/oauth/dev_resolver.go
···
+
//go:build dev
+
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
)
+
+
// DevHandleResolver resolves handles via local PDS for development
+
// This is needed because local handles (e.g., user.local.coves.dev) can't be
+
// resolved via standard DNS/HTTP well-known methods - they only exist on the local PDS.
+
type DevHandleResolver struct {
+
pdsURL string
+
httpClient *http.Client
+
}
+
+
// NewDevHandleResolver creates a resolver that queries local PDS for handle resolution
+
func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver {
+
return &DevHandleResolver{
+
pdsURL: strings.TrimSuffix(pdsURL, "/"),
+
httpClient: NewSSRFSafeHTTPClient(allowPrivateIPs),
+
}
+
}
+
+
// ResolveHandle queries the local PDS to resolve a handle to a DID
+
// Returns the DID if successful, or empty string if not found
+
func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
+
if r.pdsURL == "" {
+
return "", fmt.Errorf("PDS URL not configured")
+
}
+
+
// Build the resolve handle URL
+
resolveURL := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s",
+
r.pdsURL, url.QueryEscape(handle))
+
+
// Create request with context and timeout
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+
defer cancel()
+
+
req, err := http.NewRequestWithContext(ctx, "GET", resolveURL, nil)
+
if err != nil {
+
return "", fmt.Errorf("failed to create request: %w", err)
+
}
+
req.Header.Set("User-Agent", "Coves/1.0")
+
+
// Execute request
+
resp, err := r.httpClient.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("failed to query PDS: %w", err)
+
}
+
defer resp.Body.Close()
+
+
// Check response status
+
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
+
return "", nil // Handle not found
+
}
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("PDS returned status %d", resp.StatusCode)
+
}
+
+
// Parse response
+
var result struct {
+
DID string `json:"did"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return "", fmt.Errorf("failed to parse PDS response: %w", err)
+
}
+
+
if result.DID == "" {
+
return "", nil // No DID in response
+
}
+
+
slog.Debug("resolved handle via local PDS",
+
"handle", handle,
+
"did", result.DID,
+
"pds_url", r.pdsURL)
+
+
return result.DID, nil
+
}
+
+
// ResolveIdentifier attempts to resolve a handle to DID, or returns the DID if already provided
+
// This is the main entry point for the handlers
+
func (r *DevHandleResolver) ResolveIdentifier(ctx context.Context, identifier string) (string, error) {
+
// If it's already a DID, return as-is
+
if strings.HasPrefix(identifier, "did:") {
+
return identifier, nil
+
}
+
+
// Try to resolve the handle via local PDS
+
did, err := r.ResolveHandle(ctx, identifier)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve handle via PDS: %w", err)
+
}
+
if did == "" {
+
return "", fmt.Errorf("handle not found on local PDS: %s", identifier)
+
}
+
+
return did, nil
+
}
+41
internal/atproto/oauth/dev_stubs.go
···
+
//go:build !dev
+
+
package oauth
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/atproto/identity"
+
)
+
+
// DevHandleResolver is a stub for production builds.
+
// The actual implementation is in dev_resolver.go (only compiled with -tags dev).
+
type DevHandleResolver struct{}
+
+
// NewDevHandleResolver returns nil in production builds.
+
// Dev mode features are only available when built with -tags dev.
+
func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver {
+
return nil
+
}
+
+
// ResolveHandle is a stub that should never be called in production.
+
// The nil check in handlers.go prevents this from being reached.
+
func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
+
panic("dev mode: ResolveHandle called in production build - this should never happen")
+
}
+
+
// DevAuthResolver is a stub for production builds.
+
// The actual implementation is in dev_auth_resolver.go (only compiled with -tags dev).
+
type DevAuthResolver struct{}
+
+
// NewDevAuthResolver returns nil in production builds.
+
// Dev mode features are only available when built with -tags dev.
+
func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver {
+
return nil
+
}
+
+
// StartDevAuthFlow is a stub that should never be called in production.
+
// The nil check in handlers.go prevents this from being reached.
+
func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+
panic("dev mode: StartDevAuthFlow called in production build - this should never happen")
+
}
+5 -1
scripts/dev-run.sh
···
#!/bin/bash
# Development server runner - loads .env.dev before starting
+
# Uses -tags dev to include dev-only code (localhost OAuth resolvers, etc.)
set -a # automatically export all variables
source .env.dev
···
echo " IS_DEV_ENV: $IS_DEV_ENV"
echo " PLC_DIRECTORY_URL: $PLC_DIRECTORY_URL"
echo " JETSTREAM_URL: $JETSTREAM_URL"
+
echo " APPVIEW_PUBLIC_URL: $APPVIEW_PUBLIC_URL"
+
echo " PDS_URL: $PDS_URL"
+
echo " Build tags: dev"
echo ""
-
go run ./cmd/server
+
go run -tags dev ./cmd/server
+125
internal/atproto/pds/factory.go
···
+
package pds
+
+
import (
+
"context"
+
"fmt"
+
"net/http"
+
+
"github.com/bluesky-social/indigo/atproto/atclient"
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// NewFromOAuthSession creates a PDS client from an OAuth session.
+
// This uses DPoP authentication - the correct method for OAuth tokens.
+
//
+
// The oauthClient is used to resume the session and get a properly configured
+
// APIClient that handles DPoP proof generation and nonce rotation automatically.
+
func NewFromOAuthSession(ctx context.Context, oauthClient *oauth.ClientApp, sessionData *oauth.ClientSessionData) (Client, error) {
+
if oauthClient == nil {
+
return nil, fmt.Errorf("oauthClient is required")
+
}
+
if sessionData == nil {
+
return nil, fmt.Errorf("sessionData is required")
+
}
+
+
// ResumeSession reconstructs the OAuth session with DPoP key
+
// and returns a ClientSession that can generate authenticated requests
+
sess, err := oauthClient.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resume OAuth session: %w", err)
+
}
+
+
// APIClient() returns an *atclient.APIClient configured with DPoP auth
+
apiClient := sess.APIClient()
+
+
return &client{
+
apiClient: apiClient,
+
did: sessionData.AccountDID.String(),
+
host: sessionData.HostURL,
+
}, nil
+
}
+
+
// NewFromPasswordAuth creates a PDS client using password authentication.
+
// This uses Bearer token authentication from com.atproto.server.createSession.
+
//
+
// Primarily used for:
+
// - E2E tests with local PDS
+
// - Development/debugging tools
+
// - Non-OAuth clients
+
//
+
// Note: This establishes a new session with the PDS. For repeated calls,
+
// consider using NewFromAccessToken if you already have a valid access token.
+
func NewFromPasswordAuth(ctx context.Context, host, handle, password string) (Client, error) {
+
if host == "" {
+
return nil, fmt.Errorf("host is required")
+
}
+
if handle == "" {
+
return nil, fmt.Errorf("handle is required")
+
}
+
if password == "" {
+
return nil, fmt.Errorf("password is required")
+
}
+
+
// LoginWithPasswordHost creates a session and returns an authenticated APIClient
+
// This handles the createSession call and Bearer token setup
+
apiClient, err := atclient.LoginWithPasswordHost(ctx, host, handle, password, "", nil)
+
if err != nil {
+
return nil, fmt.Errorf("failed to login with password: %w", err)
+
}
+
+
// Get DID from the authenticated client
+
did := ""
+
if apiClient.AccountDID != nil {
+
did = apiClient.AccountDID.String()
+
}
+
+
return &client{
+
apiClient: apiClient,
+
did: did,
+
host: host,
+
}, nil
+
}
+
+
// NewFromAccessToken creates a PDS client from an existing access token.
+
// This is useful when you already have a valid Bearer token (e.g., from createSession)
+
// and don't want to re-authenticate.
+
//
+
// WARNING: This creates a client with Bearer auth only. Do NOT use this with
+
// OAuth access tokens - those require DPoP proofs. Use NewFromOAuthSession instead.
+
func NewFromAccessToken(host, did, accessToken string) (Client, error) {
+
if host == "" {
+
return nil, fmt.Errorf("host is required")
+
}
+
if did == "" {
+
return nil, fmt.Errorf("did is required")
+
}
+
if accessToken == "" {
+
return nil, fmt.Errorf("accessToken is required")
+
}
+
+
// Create APIClient with Bearer auth
+
apiClient := atclient.NewAPIClient(host)
+
apiClient.Auth = &bearerAuth{token: accessToken}
+
+
return &client{
+
apiClient: apiClient,
+
did: did,
+
host: host,
+
}, nil
+
}
+
+
// bearerAuth implements atclient.AuthMethod for simple Bearer token auth.
+
// This is used for password-based sessions where DPoP is not required.
+
type bearerAuth struct {
+
token string
+
}
+
+
// Ensure bearerAuth implements atclient.AuthMethod.
+
var _ atclient.AuthMethod = (*bearerAuth)(nil)
+
+
// DoWithAuth adds the Bearer token to the request and executes it.
+
func (b *bearerAuth) DoWithAuth(c *http.Client, req *http.Request, _ syntax.NSID) (*http.Response, error) {
+
req.Header.Set("Authorization", "Bearer "+b.token)
+
return c.Do(req)
+
}
+18
tests/integration/helpers.go
···
import (
"Coves/internal/api/middleware"
"Coves/internal/atproto/oauth"
+
"Coves/internal/atproto/pds"
"Coves/internal/core/users"
+
"Coves/internal/core/votes"
"bytes"
"context"
"database/sql"
···
e.store.AddSessionWithPDS(did, sessionID, pdsAccessToken, pdsURL)
return token
}
+
+
// PasswordAuthPDSClientFactory creates a PDSClientFactory that uses password-based Bearer auth.
+
// This is for E2E tests that use createSession instead of OAuth.
+
// The factory extracts the access token and host URL from the session data.
+
func PasswordAuthPDSClientFactory() votes.PDSClientFactory {
+
return func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
}
+267
cmd/reindex-votes/main.go
···
+
// cmd/reindex-votes/main.go
+
// Quick tool to reindex votes from PDS to AppView database
+
package main
+
+
import (
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"log"
+
"net/http"
+
"net/url"
+
"os"
+
"strings"
+
"time"
+
+
_ "github.com/lib/pq"
+
)
+
+
type ListRecordsResponse struct {
+
Records []Record `json:"records"`
+
Cursor string `json:"cursor"`
+
}
+
+
type Record struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
Value map[string]interface{} `json:"value"`
+
}
+
+
func main() {
+
// Get config from env
+
dbURL := os.Getenv("DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable"
+
}
+
pdsURL := os.Getenv("PDS_URL")
+
if pdsURL == "" {
+
pdsURL = "http://localhost:3001"
+
}
+
+
log.Printf("Connecting to database...")
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
log.Fatalf("Failed to connect to database: %v", err)
+
}
+
defer db.Close()
+
+
ctx := context.Background()
+
+
// Get all accounts directly from the PDS
+
log.Printf("Fetching accounts from PDS (%s)...", pdsURL)
+
dids, err := fetchAllAccountsFromPDS(pdsURL)
+
if err != nil {
+
log.Fatalf("Failed to fetch accounts from PDS: %v", err)
+
}
+
log.Printf("Found %d accounts on PDS to check for votes", len(dids))
+
+
// Reset vote counts first
+
log.Printf("Resetting all vote counts...")
+
if _, err := db.ExecContext(ctx, "DELETE FROM votes"); err != nil {
+
log.Fatalf("Failed to clear votes table: %v", err)
+
}
+
if _, err := db.ExecContext(ctx, "UPDATE posts SET upvote_count = 0, downvote_count = 0, score = 0"); err != nil {
+
log.Fatalf("Failed to reset post vote counts: %v", err)
+
}
+
if _, err := db.ExecContext(ctx, "UPDATE comments SET upvote_count = 0, downvote_count = 0, score = 0"); err != nil {
+
log.Fatalf("Failed to reset comment vote counts: %v", err)
+
}
+
+
// For each user, fetch their votes from PDS
+
totalVotes := 0
+
for _, did := range dids {
+
votes, err := fetchVotesFromPDS(pdsURL, did)
+
if err != nil {
+
log.Printf("Warning: failed to fetch votes for %s: %v", did, err)
+
continue
+
}
+
+
if len(votes) == 0 {
+
continue
+
}
+
+
log.Printf("Found %d votes for %s", len(votes), did)
+
+
// Index each vote
+
for _, vote := range votes {
+
if err := indexVote(ctx, db, did, vote); err != nil {
+
log.Printf("Warning: failed to index vote %s: %v", vote.URI, err)
+
continue
+
}
+
totalVotes++
+
}
+
}
+
+
log.Printf("โœ“ Reindexed %d votes from PDS", totalVotes)
+
}
+
+
// fetchAllAccountsFromPDS queries the PDS sync API to get all repo DIDs
+
func fetchAllAccountsFromPDS(pdsURL string) ([]string, error) {
+
// Use com.atproto.sync.listRepos to get all repos on this PDS
+
var allDIDs []string
+
cursor := ""
+
+
for {
+
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.listRepos?limit=100", pdsURL)
+
if cursor != "" {
+
reqURL += "&cursor=" + url.QueryEscape(cursor)
+
}
+
+
resp, err := http.Get(reqURL)
+
if err != nil {
+
return nil, fmt.Errorf("HTTP request failed: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != 200 {
+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+
}
+
+
var result struct {
+
Repos []struct {
+
DID string `json:"did"`
+
} `json:"repos"`
+
Cursor string `json:"cursor"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return nil, fmt.Errorf("failed to decode response: %w", err)
+
}
+
+
for _, repo := range result.Repos {
+
allDIDs = append(allDIDs, repo.DID)
+
}
+
+
if result.Cursor == "" {
+
break
+
}
+
cursor = result.Cursor
+
}
+
+
return allDIDs, nil
+
}
+
+
func fetchVotesFromPDS(pdsURL, did string) ([]Record, error) {
+
var allRecords []Record
+
cursor := ""
+
collection := "social.coves.feed.vote"
+
+
for {
+
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=100",
+
pdsURL, url.QueryEscape(did), url.QueryEscape(collection))
+
if cursor != "" {
+
reqURL += "&cursor=" + url.QueryEscape(cursor)
+
}
+
+
resp, err := http.Get(reqURL)
+
if err != nil {
+
return nil, fmt.Errorf("HTTP request failed: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode == 400 {
+
// User doesn't exist on this PDS or has no records - that's OK
+
return nil, nil
+
}
+
if resp.StatusCode != 200 {
+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+
}
+
+
var result ListRecordsResponse
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return nil, fmt.Errorf("failed to decode response: %w", err)
+
}
+
+
allRecords = append(allRecords, result.Records...)
+
+
if result.Cursor == "" {
+
break
+
}
+
cursor = result.Cursor
+
}
+
+
return allRecords, nil
+
}
+
+
func indexVote(ctx context.Context, db *sql.DB, voterDID string, record Record) error {
+
// Extract vote data from record
+
subject, ok := record.Value["subject"].(map[string]interface{})
+
if !ok {
+
return fmt.Errorf("missing subject")
+
}
+
subjectURI, _ := subject["uri"].(string)
+
subjectCID, _ := subject["cid"].(string)
+
direction, _ := record.Value["direction"].(string)
+
createdAtStr, _ := record.Value["createdAt"].(string)
+
+
if subjectURI == "" || direction == "" {
+
return fmt.Errorf("invalid vote record: missing required fields")
+
}
+
+
// Parse created_at
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
+
if err != nil {
+
createdAt = time.Now()
+
}
+
+
// Extract rkey from URI (at://did/collection/rkey)
+
parts := strings.Split(record.URI, "/")
+
if len(parts) < 5 {
+
return fmt.Errorf("invalid URI format: %s", record.URI)
+
}
+
rkey := parts[len(parts)-1]
+
+
// Start transaction
+
tx, err := db.BeginTx(ctx, nil)
+
if err != nil {
+
return fmt.Errorf("failed to begin transaction: %w", err)
+
}
+
defer tx.Rollback()
+
+
// Insert vote
+
_, err = tx.ExecContext(ctx, `
+
INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at)
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
+
ON CONFLICT (uri) DO NOTHING
+
`, record.URI, record.CID, rkey, voterDID, subjectURI, subjectCID, direction, createdAt)
+
if err != nil {
+
return fmt.Errorf("failed to insert vote: %w", err)
+
}
+
+
// Update post/comment counts
+
collection := extractCollectionFromURI(subjectURI)
+
var updateQuery string
+
+
switch collection {
+
case "social.coves.community.post":
+
if direction == "up" {
+
updateQuery = `UPDATE posts SET upvote_count = upvote_count + 1, score = upvote_count + 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
} else {
+
updateQuery = `UPDATE posts SET downvote_count = downvote_count + 1, score = upvote_count - (downvote_count + 1) WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
case "social.coves.community.comment":
+
if direction == "up" {
+
updateQuery = `UPDATE comments SET upvote_count = upvote_count + 1, score = upvote_count + 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
} else {
+
updateQuery = `UPDATE comments SET downvote_count = downvote_count + 1, score = upvote_count - (downvote_count + 1) WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
default:
+
// Unknown collection, just index the vote
+
return tx.Commit()
+
}
+
+
if _, err := tx.ExecContext(ctx, updateQuery, subjectURI); err != nil {
+
return fmt.Errorf("failed to update vote counts: %w", err)
+
}
+
+
return tx.Commit()
+
}
+
+
func extractCollectionFromURI(uri string) string {
+
// at://did:plc:xxx/social.coves.community.post/rkey
+
parts := strings.Split(uri, "/")
+
if len(parts) >= 4 {
+
return parts[3]
+
}
+
return ""
+
}
+7 -5
internal/api/routes/communityFeed.go
···
import (
"Coves/internal/api/handlers/communityFeed"
+
"Coves/internal/api/middleware"
"Coves/internal/core/communityFeeds"
+
"Coves/internal/core/votes"
"github.com/go-chi/chi/v5"
)
···
func RegisterCommunityFeedRoutes(
r chi.Router,
feedService communityFeeds.Service,
+
voteService votes.Service,
+
authMiddleware *middleware.OAuthAuthMiddleware,
) {
// Create handlers
-
getCommunityHandler := communityFeed.NewGetCommunityHandler(feedService)
+
getCommunityHandler := communityFeed.NewGetCommunityHandler(feedService, voteService)
// GET /xrpc/social.coves.communityFeed.getCommunity
-
// Public endpoint - basic community sorting only for Alpha
-
// TODO(feed-generator): Add OptionalAuth middleware when implementing viewer-specific state
-
// (blocks, upvotes, saves, etc.) in feed generator skeleton
-
r.Get("/xrpc/social.coves.communityFeed.getCommunity", getCommunityHandler.HandleGetCommunity)
+
// Public endpoint with optional auth for viewer-specific state (vote state)
+
r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.communityFeed.getCommunity", getCommunityHandler.HandleGetCommunity)
}
+3 -1
internal/api/routes/timeline.go
···
"Coves/internal/api/handlers/timeline"
"Coves/internal/api/middleware"
timelineCore "Coves/internal/core/timeline"
+
"Coves/internal/core/votes"
"github.com/go-chi/chi/v5"
)
···
func RegisterTimelineRoutes(
r chi.Router,
timelineService timelineCore.Service,
+
voteService votes.Service,
authMiddleware *middleware.OAuthAuthMiddleware,
) {
// Create handlers
-
getTimelineHandler := timeline.NewGetTimelineHandler(timelineService)
+
getTimelineHandler := timeline.NewGetTimelineHandler(timelineService, voteService)
// GET /xrpc/social.coves.feed.getTimeline
// Requires authentication - user must be logged in to see their timeline
+221
internal/core/votes/cache.go
···
+
package votes
+
+
import (
+
"context"
+
"fmt"
+
"log/slog"
+
"strings"
+
"sync"
+
"time"
+
+
"Coves/internal/atproto/pds"
+
)
+
+
// CachedVote represents a vote stored in the cache
+
type CachedVote struct {
+
Direction string // "up" or "down"
+
URI string // vote record URI (at://did/collection/rkey)
+
RKey string // record key
+
}
+
+
// VoteCache provides an in-memory cache of user votes fetched from their PDS.
+
// This avoids eventual consistency issues with the AppView database.
+
type VoteCache struct {
+
mu sync.RWMutex
+
votes map[string]map[string]*CachedVote // userDID -> subjectURI -> vote
+
expiry map[string]time.Time // userDID -> expiry time
+
ttl time.Duration
+
logger *slog.Logger
+
}
+
+
// NewVoteCache creates a new vote cache with the specified TTL
+
func NewVoteCache(ttl time.Duration, logger *slog.Logger) *VoteCache {
+
if logger == nil {
+
logger = slog.Default()
+
}
+
return &VoteCache{
+
votes: make(map[string]map[string]*CachedVote),
+
expiry: make(map[string]time.Time),
+
ttl: ttl,
+
logger: logger,
+
}
+
}
+
+
// GetVotesForUser returns all cached votes for a user.
+
// Returns nil if cache is empty or expired for this user.
+
func (c *VoteCache) GetVotesForUser(userDID string) map[string]*CachedVote {
+
c.mu.RLock()
+
defer c.mu.RUnlock()
+
+
// Check if cache exists and is not expired
+
expiry, exists := c.expiry[userDID]
+
if !exists || time.Now().After(expiry) {
+
return nil
+
}
+
+
return c.votes[userDID]
+
}
+
+
// GetVote returns the cached vote for a specific subject, or nil if not found/expired
+
func (c *VoteCache) GetVote(userDID, subjectURI string) *CachedVote {
+
votes := c.GetVotesForUser(userDID)
+
if votes == nil {
+
return nil
+
}
+
return votes[subjectURI]
+
}
+
+
// IsCached returns true if the user's votes are cached and not expired
+
func (c *VoteCache) IsCached(userDID string) bool {
+
c.mu.RLock()
+
defer c.mu.RUnlock()
+
+
expiry, exists := c.expiry[userDID]
+
return exists && time.Now().Before(expiry)
+
}
+
+
// SetVotesForUser replaces all cached votes for a user
+
func (c *VoteCache) SetVotesForUser(userDID string, votes map[string]*CachedVote) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
c.votes[userDID] = votes
+
c.expiry[userDID] = time.Now().Add(c.ttl)
+
+
c.logger.Debug("vote cache updated",
+
"user", userDID,
+
"vote_count", len(votes),
+
"expires_at", c.expiry[userDID])
+
}
+
+
// SetVote adds or updates a single vote in the cache
+
func (c *VoteCache) SetVote(userDID, subjectURI string, vote *CachedVote) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
if c.votes[userDID] == nil {
+
c.votes[userDID] = make(map[string]*CachedVote)
+
}
+
+
c.votes[userDID][subjectURI] = vote
+
+
// Always extend expiry on vote action - active users keep their cache fresh
+
c.expiry[userDID] = time.Now().Add(c.ttl)
+
+
c.logger.Debug("vote cached",
+
"user", userDID,
+
"subject", subjectURI,
+
"direction", vote.Direction)
+
}
+
+
// RemoveVote removes a vote from the cache (for toggle-off)
+
func (c *VoteCache) RemoveVote(userDID, subjectURI string) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
if c.votes[userDID] != nil {
+
delete(c.votes[userDID], subjectURI)
+
+
// Extend expiry on vote action - active users keep their cache fresh
+
c.expiry[userDID] = time.Now().Add(c.ttl)
+
+
c.logger.Debug("vote removed from cache",
+
"user", userDID,
+
"subject", subjectURI)
+
}
+
}
+
+
// Invalidate removes all cached votes for a user
+
func (c *VoteCache) Invalidate(userDID string) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
delete(c.votes, userDID)
+
delete(c.expiry, userDID)
+
+
c.logger.Debug("vote cache invalidated", "user", userDID)
+
}
+
+
// FetchAndCacheFromPDS fetches all votes from the user's PDS and caches them.
+
// This should be called on first authenticated request or when cache is expired.
+
func (c *VoteCache) FetchAndCacheFromPDS(ctx context.Context, pdsClient pds.Client) error {
+
userDID := pdsClient.DID()
+
+
c.logger.Debug("fetching votes from PDS",
+
"user", userDID,
+
"pds", pdsClient.HostURL())
+
+
votes, err := c.fetchAllVotesFromPDS(ctx, pdsClient)
+
if err != nil {
+
return fmt.Errorf("failed to fetch votes from PDS: %w", err)
+
}
+
+
c.SetVotesForUser(userDID, votes)
+
+
c.logger.Info("vote cache populated from PDS",
+
"user", userDID,
+
"vote_count", len(votes))
+
+
return nil
+
}
+
+
// fetchAllVotesFromPDS paginates through all vote records on the user's PDS
+
func (c *VoteCache) fetchAllVotesFromPDS(ctx context.Context, pdsClient pds.Client) (map[string]*CachedVote, error) {
+
votes := make(map[string]*CachedVote)
+
cursor := ""
+
const pageSize = 100
+
const collection = "social.coves.feed.vote"
+
+
for {
+
result, err := pdsClient.ListRecords(ctx, collection, pageSize, cursor)
+
if err != nil {
+
if pds.IsAuthError(err) {
+
return nil, ErrNotAuthorized
+
}
+
return nil, fmt.Errorf("listRecords failed: %w", err)
+
}
+
+
for _, rec := range result.Records {
+
// Extract subject from record value
+
subject, ok := rec.Value["subject"].(map[string]any)
+
if !ok {
+
continue
+
}
+
+
subjectURI, ok := subject["uri"].(string)
+
if !ok || subjectURI == "" {
+
continue
+
}
+
+
direction, _ := rec.Value["direction"].(string)
+
if direction == "" {
+
continue
+
}
+
+
// Extract rkey from URI
+
rkey := extractRKeyFromURI(rec.URI)
+
+
votes[subjectURI] = &CachedVote{
+
Direction: direction,
+
URI: rec.URI,
+
RKey: rkey,
+
}
+
}
+
+
if result.Cursor == "" {
+
break
+
}
+
cursor = result.Cursor
+
}
+
+
return votes, nil
+
}
+
+
// extractRKeyFromURI extracts the rkey from an AT-URI (at://did/collection/rkey)
+
func extractRKeyFromURI(uri string) string {
+
parts := strings.Split(uri, "/")
+
if len(parts) >= 5 {
+
return parts[len(parts)-1]
+
}
+
return ""
+
}
+84 -2
internal/core/votes/service_impl.go
···
oauthStore oauth.ClientAuthStore
logger *slog.Logger
pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth.
+
cache *VoteCache // In-memory cache of user votes from PDS
}
// NewService creates a new vote service instance
-
func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {
+
func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, cache *VoteCache, logger *slog.Logger) Service {
if logger == nil {
logger = slog.Default()
}
···
repo: repo,
oauthClient: oauthClient,
oauthStore: oauthStore,
+
cache: cache,
logger: logger,
}
}
// NewServiceWithPDSFactory creates a vote service with a custom PDS client factory.
// This is primarily for testing with password-based authentication.
-
func NewServiceWithPDSFactory(repo Repository, logger *slog.Logger, factory PDSClientFactory) Service {
+
func NewServiceWithPDSFactory(repo Repository, cache *VoteCache, logger *slog.Logger, factory PDSClientFactory) Service {
if logger == nil {
logger = slog.Default()
}
return &voteService{
repo: repo,
+
cache: cache,
logger: logger,
pdsClientFactory: factory,
}
···
"subject", req.Subject.URI,
"direction", req.Direction)
+
// Update cache - remove the vote
+
if s.cache != nil {
+
s.cache.RemoveVote(session.AccountDID.String(), req.Subject.URI)
+
}
+
// Return empty response to indicate deletion
return &CreateVoteResponse{
URI: "",
···
"uri", uri,
"cid", cid)
+
// Update cache - add the new vote
+
if s.cache != nil {
+
s.cache.SetVote(session.AccountDID.String(), req.Subject.URI, &CachedVote{
+
Direction: req.Direction,
+
URI: uri,
+
RKey: extractRKeyFromURI(uri),
+
})
+
}
+
return &CreateVoteResponse{
URI: uri,
CID: cid,
···
"subject", req.Subject.URI,
"uri", existing.URI)
+
// Update cache - remove the vote
+
if s.cache != nil {
+
s.cache.RemoveVote(session.AccountDID.String(), req.Subject.URI)
+
}
+
return nil
}
···
// No vote found for this subject after checking all pages
return nil, nil
}
+
+
// EnsureCachePopulated fetches the user's votes from their PDS if not already cached.
+
func (s *voteService) EnsureCachePopulated(ctx context.Context, session *oauth.ClientSessionData) error {
+
if s.cache == nil {
+
return nil // No cache configured
+
}
+
+
// Check if already cached
+
if s.cache.IsCached(session.AccountDID.String()) {
+
return nil
+
}
+
+
// Create PDS client for this session
+
pdsClient, err := s.getPDSClient(ctx, session)
+
if err != nil {
+
s.logger.Error("failed to create PDS client for cache population",
+
"error", err,
+
"user", session.AccountDID)
+
return fmt.Errorf("failed to create PDS client: %w", err)
+
}
+
+
// Fetch and cache votes from PDS
+
if err := s.cache.FetchAndCacheFromPDS(ctx, pdsClient); err != nil {
+
s.logger.Error("failed to populate vote cache from PDS",
+
"error", err,
+
"user", session.AccountDID)
+
return fmt.Errorf("failed to populate vote cache: %w", err)
+
}
+
+
return nil
+
}
+
+
// GetViewerVote returns the viewer's vote for a specific subject, or nil if not voted.
+
func (s *voteService) GetViewerVote(userDID, subjectURI string) *CachedVote {
+
if s.cache == nil {
+
return nil
+
}
+
return s.cache.GetVote(userDID, subjectURI)
+
}
+
+
// GetViewerVotesForSubjects returns the viewer's votes for multiple subjects.
+
func (s *voteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*CachedVote {
+
result := make(map[string]*CachedVote)
+
if s.cache == nil {
+
return result
+
}
+
+
allVotes := s.cache.GetVotesForUser(userDID)
+
if allVotes == nil {
+
return result
+
}
+
+
for _, uri := range subjectURIs {
+
if vote, exists := allVotes[uri]; exists {
+
result[uri] = vote
+
}
+
}
+
+
return result
+
}
+76 -16
internal/atproto/jetstream/vote_consumer.go
···
}
// Atomically: Index vote + Update post counts
-
if err := c.indexVoteAndUpdateCounts(ctx, vote); err != nil {
+
wasNew, err := c.indexVoteAndUpdateCounts(ctx, vote)
+
if err != nil {
return fmt.Errorf("failed to index vote and update counts: %w", err)
}
-
log.Printf("โœ“ Indexed vote: %s (%s on %s)", uri, vote.Direction, vote.SubjectURI)
+
if wasNew {
+
log.Printf("โœ“ Indexed vote: %s (%s on %s)", uri, vote.Direction, vote.SubjectURI)
+
}
return nil
}
···
}
// indexVoteAndUpdateCounts atomically indexes a vote and updates post vote counts
-
func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) error {
+
// Returns (true, nil) if vote was newly inserted, (false, nil) if already existed (idempotent)
+
func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) (bool, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
-
return fmt.Errorf("failed to begin transaction: %w", err)
+
return false, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
···
}
}()
-
// 1. Index the vote (idempotent with ON CONFLICT DO NOTHING)
+
// 1. Check for existing active vote with different URI (stale record)
+
// This handles cases where:
+
// - User voted on another client and we missed the delete event
+
// - Vote was reindexed but user created a new vote with different rkey
+
// - Any other state mismatch between PDS and AppView
+
var existingDirection sql.NullString
+
checkQuery := `
+
SELECT direction FROM votes
+
WHERE voter_did = $1
+
AND subject_uri = $2
+
AND deleted_at IS NULL
+
AND uri != $3
+
LIMIT 1
+
`
+
if err := tx.QueryRowContext(ctx, checkQuery, vote.VoterDID, vote.SubjectURI, vote.URI).Scan(&existingDirection); err != nil && err != sql.ErrNoRows {
+
return false, fmt.Errorf("failed to check existing vote: %w", err)
+
}
+
+
// If there's a stale vote, soft-delete it and adjust counts
+
if existingDirection.Valid {
+
softDeleteQuery := `
+
UPDATE votes
+
SET deleted_at = NOW()
+
WHERE voter_did = $1
+
AND subject_uri = $2
+
AND deleted_at IS NULL
+
AND uri != $3
+
`
+
if _, err := tx.ExecContext(ctx, softDeleteQuery, vote.VoterDID, vote.SubjectURI, vote.URI); err != nil {
+
return false, fmt.Errorf("failed to soft-delete existing votes: %w", err)
+
}
+
+
// Decrement the old vote's count (will be re-incremented below if same direction)
+
collection := utils.ExtractCollectionFromURI(vote.SubjectURI)
+
var decrementQuery string
+
if existingDirection.String == "up" {
+
if collection == "social.coves.community.post" {
+
decrementQuery = `UPDATE posts SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
} else if collection == "social.coves.community.comment" {
+
decrementQuery = `UPDATE comments SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
} else {
+
if collection == "social.coves.community.post" {
+
decrementQuery = `UPDATE posts SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`
+
} else if collection == "social.coves.community.comment" {
+
decrementQuery = `UPDATE comments SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
}
+
if decrementQuery != "" {
+
if _, err := tx.ExecContext(ctx, decrementQuery, vote.SubjectURI); err != nil {
+
return false, fmt.Errorf("failed to decrement old vote count: %w", err)
+
}
+
}
+
log.Printf("Cleaned up stale vote for %s on %s (was %s)", vote.VoterDID, vote.SubjectURI, existingDirection.String)
+
}
+
+
// 2. Index the vote (idempotent with ON CONFLICT DO NOTHING)
query := `
INSERT INTO votes (
uri, cid, rkey, voter_did,
···
// If no rows returned, vote already exists (idempotent - OK for Jetstream replays)
if err == sql.ErrNoRows {
-
log.Printf("Vote already indexed: %s (idempotent)", vote.URI)
+
// Silently handle idempotent case - no log needed for replayed events
if commitErr := tx.Commit(); commitErr != nil {
-
return fmt.Errorf("failed to commit transaction: %w", commitErr)
+
return false, fmt.Errorf("failed to commit transaction: %w", commitErr)
}
-
return nil
+
return false, nil // Vote already existed
}
if err != nil {
-
return fmt.Errorf("failed to insert vote: %w", err)
+
return false, fmt.Errorf("failed to insert vote: %w", err)
}
-
// 2. Update vote counts on the subject (post or comment)
+
// 3. Update vote counts on the subject (post or comment)
// Parse collection from subject URI to determine target table
collection := utils.ExtractCollectionFromURI(vote.SubjectURI)
···
// Vote is still indexed in votes table, we just don't update denormalized counts
log.Printf("Vote subject has unsupported collection: %s (vote indexed, counts not updated)", collection)
if commitErr := tx.Commit(); commitErr != nil {
-
return fmt.Errorf("failed to commit transaction: %w", commitErr)
+
return false, fmt.Errorf("failed to commit transaction: %w", commitErr)
}
-
return nil
+
return true, nil // Vote was newly indexed
}
result, err := tx.ExecContext(ctx, updateQuery, vote.SubjectURI)
if err != nil {
-
return fmt.Errorf("failed to update vote counts: %w", err)
+
return false, fmt.Errorf("failed to update vote counts: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
-
return fmt.Errorf("failed to check update result: %w", err)
+
return false, fmt.Errorf("failed to check update result: %w", err)
}
// If subject doesn't exist or is deleted, that's OK (vote still indexed)
···
// Commit transaction
if err := tx.Commit(); err != nil {
-
return fmt.Errorf("failed to commit transaction: %w", err)
+
return false, fmt.Errorf("failed to commit transaction: %w", err)
}
-
return nil
+
return true, nil // Vote was newly indexed
}
// deleteVoteAndUpdateCounts atomically soft-deletes a vote and updates post vote counts
+109
internal/atproto/lexicon/social/coves/community/comment/create.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.create",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a comment on a post or another comment. Comments support nested threading, rich text, embeds, and self-labeling.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["reply", "content"],
+
"properties": {
+
"reply": {
+
"type": "object",
+
"description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).",
+
"required": ["root", "parent"],
+
"properties": {
+
"root": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the original post that started the thread"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the immediate parent (post or comment) being replied to"
+
}
+
}
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the created comment"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the created comment record"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InvalidReply",
+
"description": "The reply reference is invalid, malformed, or refers to non-existent content"
+
},
+
{
+
"name": "ContentTooLong",
+
"description": "Comment content exceeds maximum length constraints"
+
},
+
{
+
"name": "ContentEmpty",
+
"description": "Comment content is empty or contains only whitespace"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to create comments on this content"
+
}
+
]
+
}
+
}
+
}
+41
internal/atproto/lexicon/social/coves/community/comment/delete.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.delete",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Delete a comment. Only the comment author can delete their own comments.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment to delete"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"properties": {}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommentNotFound",
+
"description": "Comment with the specified URI does not exist"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to delete this comment (not the author)"
+
}
+
]
+
}
+
}
+
}
+97
internal/atproto/lexicon/social/coves/community/comment/update.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.update",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Update an existing comment's content, facets, embed, languages, or labels. Threading references (reply.root and reply.parent) are immutable and cannot be changed.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "content"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment to update"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Updated comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Updated annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Updated embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Updated languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Updated self-applied content labels"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the updated comment (unchanged from input)"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "New CID of the updated comment record"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommentNotFound",
+
"description": "Comment with the specified URI does not exist"
+
},
+
{
+
"name": "ContentTooLong",
+
"description": "Updated comment content exceeds maximum length constraints"
+
},
+
{
+
"name": "ContentEmpty",
+
"description": "Updated comment content is empty or contains only whitespace"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to update this comment (not the author)"
+
}
+
]
+
}
+
}
+
}
+38
internal/core/comments/types.go
···
+
package comments
+
+
// CreateCommentRequest contains parameters for creating a comment
+
type CreateCommentRequest struct {
+
Reply ReplyRef `json:"reply"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
}
+
+
// CreateCommentResponse contains the result of creating a comment
+
type CreateCommentResponse struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// UpdateCommentRequest contains parameters for updating a comment
+
type UpdateCommentRequest struct {
+
URI string `json:"uri"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
}
+
+
// UpdateCommentResponse contains the result of updating a comment
+
type UpdateCommentResponse struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// DeleteCommentRequest contains parameters for deleting a comment
+
type DeleteCommentRequest struct {
+
URI string `json:"uri"`
+
}
+130
internal/api/handlers/comments/create_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// CreateCommentHandler handles comment creation requests
+
type CreateCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewCreateCommentHandler creates a new handler for creating comments
+
func NewCreateCommentHandler(service comments.Service) *CreateCommentHandler {
+
return &CreateCommentHandler{
+
service: service,
+
}
+
}
+
+
// CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create
+
type CreateCommentInput struct {
+
Reply struct {
+
Root struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"root"`
+
Parent struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"parent"`
+
} `json:"reply"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels interface{} `json:"labels,omitempty"`
+
}
+
+
// CreateCommentOutput matches the lexicon output schema
+
type CreateCommentOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleCreate handles comment creation requests
+
// POST /xrpc/social.coves.community.comment.create
+
//
+
// Request body: { "reply": { "root": {...}, "parent": {...} }, "content": "..." }
+
// Response: { "uri": "at://...", "cid": "..." }
+
func (h *CreateCommentHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into CreateCommentInput
+
var input CreateCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert labels interface{} to *comments.SelfLabels if provided
+
var labels *comments.SelfLabels
+
if input.Labels != nil {
+
labelsJSON, err := json.Marshal(input.Labels)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format")
+
return
+
}
+
var selfLabels comments.SelfLabels
+
if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure")
+
return
+
}
+
labels = &selfLabels
+
}
+
+
// 6. Convert input to CreateCommentRequest
+
req := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: input.Reply.Root.URI,
+
CID: input.Reply.Root.CID,
+
},
+
Parent: comments.StrongRef{
+
URI: input.Reply.Parent.URI,
+
CID: input.Reply.Parent.CID,
+
},
+
},
+
Content: input.Content,
+
Facets: input.Facets,
+
Embed: input.Embed,
+
Langs: input.Langs,
+
Labels: labels,
+
}
+
+
// 7. Call service to create comment
+
response, err := h.service.CreateComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 8. Return JSON response with URI and CID
+
output := CreateCommentOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+80
internal/api/handlers/comments/delete_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// DeleteCommentHandler handles comment deletion requests
+
type DeleteCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewDeleteCommentHandler creates a new handler for deleting comments
+
func NewDeleteCommentHandler(service comments.Service) *DeleteCommentHandler {
+
return &DeleteCommentHandler{
+
service: service,
+
}
+
}
+
+
// DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete
+
type DeleteCommentInput struct {
+
URI string `json:"uri"`
+
}
+
+
// DeleteCommentOutput is empty per lexicon specification
+
type DeleteCommentOutput struct{}
+
+
// HandleDelete handles comment deletion requests
+
// POST /xrpc/social.coves.community.comment.delete
+
//
+
// Request body: { "uri": "at://..." }
+
// Response: {}
+
func (h *DeleteCommentHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into DeleteCommentInput
+
var input DeleteCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert input to DeleteCommentRequest
+
req := comments.DeleteCommentRequest{
+
URI: input.URI,
+
}
+
+
// 6. Call service to delete comment
+
err := h.service.DeleteComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 7. Return empty JSON object per lexicon specification
+
output := DeleteCommentOutput{}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+34 -2
internal/api/handlers/comments/errors.go
···
import (
"Coves/internal/core/comments"
"encoding/json"
+
"errors"
"log"
"net/http"
)
···
func handleServiceError(w http.ResponseWriter, err error) {
switch {
case comments.IsNotFound(err):
-
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
// Map specific not found errors to appropriate messages
+
switch {
+
case errors.Is(err, comments.ErrCommentNotFound):
+
writeError(w, http.StatusNotFound, "CommentNotFound", "Comment not found")
+
case errors.Is(err, comments.ErrParentNotFound):
+
writeError(w, http.StatusNotFound, "ParentNotFound", "Parent post or comment not found")
+
case errors.Is(err, comments.ErrRootNotFound):
+
writeError(w, http.StatusNotFound, "RootNotFound", "Root post not found")
+
default:
+
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
}
case comments.IsValidationError(err):
-
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
// Map specific validation errors to appropriate messages
+
switch {
+
case errors.Is(err, comments.ErrInvalidReply):
+
writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed")
+
case errors.Is(err, comments.ErrContentTooLong):
+
writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes")
+
case errors.Is(err, comments.ErrContentEmpty):
+
writeError(w, http.StatusBadRequest, "ContentEmpty", "Comment content is required")
+
default:
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
}
+
+
case errors.Is(err, comments.ErrNotAuthorized):
+
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action")
+
+
case errors.Is(err, comments.ErrBanned):
+
writeError(w, http.StatusForbidden, "Banned", "User is banned from this community")
+
+
// NOTE: IsConflict case removed - the PDS handles duplicate detection via CreateRecord,
+
// so ErrCommentAlreadyExists is never returned from the service layer. If the PDS rejects
+
// a duplicate record, it returns an auth/validation error which is handled by other cases.
+
// Keeping this code would be dead code that never executes.
default:
// Don't leak internal error details to clients
+112
internal/api/handlers/comments/update_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// UpdateCommentHandler handles comment update requests
+
type UpdateCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewUpdateCommentHandler creates a new handler for updating comments
+
func NewUpdateCommentHandler(service comments.Service) *UpdateCommentHandler {
+
return &UpdateCommentHandler{
+
service: service,
+
}
+
}
+
+
// UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update
+
type UpdateCommentInput struct {
+
URI string `json:"uri"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels interface{} `json:"labels,omitempty"`
+
}
+
+
// UpdateCommentOutput matches the lexicon output schema
+
type UpdateCommentOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleUpdate handles comment update requests
+
// POST /xrpc/social.coves.community.comment.update
+
//
+
// Request body: { "uri": "at://...", "content": "..." }
+
// Response: { "uri": "at://...", "cid": "..." }
+
func (h *UpdateCommentHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into UpdateCommentInput
+
var input UpdateCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert labels interface{} to *comments.SelfLabels if provided
+
var labels *comments.SelfLabels
+
if input.Labels != nil {
+
labelsJSON, err := json.Marshal(input.Labels)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format")
+
return
+
}
+
var selfLabels comments.SelfLabels
+
if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure")
+
return
+
}
+
labels = &selfLabels
+
}
+
+
// 6. Convert input to UpdateCommentRequest
+
req := comments.UpdateCommentRequest{
+
URI: input.URI,
+
Content: input.Content,
+
Facets: input.Facets,
+
Embed: input.Embed,
+
Langs: input.Langs,
+
Labels: labels,
+
}
+
+
// 7. Call service to update comment
+
response, err := h.service.UpdateComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 8. Return JSON response with URI and CID
+
output := UpdateCommentOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+35
internal/api/routes/comment.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/comments"
+
"Coves/internal/api/middleware"
+
commentsCore "Coves/internal/core/comments"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterCommentRoutes registers comment-related XRPC endpoints on the router
+
// Implements social.coves.community.comment.* lexicon endpoints
+
// All write operations (create, update, delete) require authentication
+
func RegisterCommentRoutes(r chi.Router, service commentsCore.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+
// Initialize handlers
+
createHandler := comments.NewCreateCommentHandler(service)
+
updateHandler := comments.NewUpdateCommentHandler(service)
+
deleteHandler := comments.NewDeleteCommentHandler(service)
+
+
// Procedure endpoints (POST) - require authentication
+
// social.coves.community.comment.create - create a new comment on a post or another comment
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.create",
+
createHandler.HandleCreate)
+
+
// social.coves.community.comment.update - update an existing comment's content
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.update",
+
updateHandler.HandleUpdate)
+
+
// social.coves.community.comment.delete - soft delete a comment
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.delete",
+
deleteHandler.HandleDelete)
+
}
+4 -2
tests/integration/comment_query_test.go
···
postRepo := postgres.NewPostRepository(db)
userRepo := postgres.NewUserRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
-
return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - these tests only use the read path (GetComments)
+
return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
}
// Helper: createTestCommentWithScore creates a comment with specific vote counts
···
postRepo := postgres.NewPostRepository(db)
userRepo := postgres.NewUserRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
-
service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - these tests only use the read path (GetComments)
+
service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
return &testCommentServiceAdapter{service: service}
}
+6 -3
tests/integration/comment_vote_test.go
···
}
// Query comments with viewer authentication
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
···
}
// Query with authentication but no vote
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
···
t.Run("Unauthenticated request has no viewer state", func(t *testing.T) {
// Query without authentication
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
+1 -1
go.mod
···
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
-
github.com/rivo/uniseg v0.1.0 // indirect
+
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
+2
go.sum
···
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+66
internal/db/migrations/021_add_comment_deletion_metadata.sql
···
+
-- +goose Up
+
-- Add deletion reason tracking to preserve thread structure while respecting privacy
+
-- When comments are deleted, we blank content but keep the record for threading
+
+
-- Create enum type for deletion reasons
+
CREATE TYPE deletion_reason AS ENUM ('author', 'moderator');
+
+
-- Add new columns to comments table
+
ALTER TABLE comments ADD COLUMN deletion_reason deletion_reason;
+
ALTER TABLE comments ADD COLUMN deleted_by TEXT;
+
+
-- Add comments for new columns
+
COMMENT ON COLUMN comments.deletion_reason IS 'Reason for deletion: author (user deleted), moderator (community mod removed)';
+
COMMENT ON COLUMN comments.deleted_by IS 'DID of the actor who performed the deletion';
+
+
-- Backfill existing deleted comments as author-deleted
+
-- This handles existing soft-deleted comments gracefully
+
UPDATE comments
+
SET deletion_reason = 'author',
+
deleted_by = commenter_did
+
WHERE deleted_at IS NOT NULL AND deletion_reason IS NULL;
+
+
-- Modify existing indexes to NOT filter deleted_at IS NULL
+
-- This allows deleted comments to appear in thread queries for structure preservation
+
-- Note: We drop and recreate to change the partial index condition
+
+
-- Drop old partial indexes that exclude deleted comments
+
DROP INDEX IF EXISTS idx_comments_root;
+
DROP INDEX IF EXISTS idx_comments_parent;
+
DROP INDEX IF EXISTS idx_comments_parent_score;
+
DROP INDEX IF EXISTS idx_comments_uri_active;
+
+
-- Recreate indexes without the deleted_at filter (include all comments for threading)
+
CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC);
+
CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC);
+
CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC);
+
CREATE INDEX idx_comments_uri_lookup ON comments(uri);
+
+
-- Add index for querying by deletion_reason (for moderation dashboard)
+
CREATE INDEX idx_comments_deleted_reason ON comments(deletion_reason, deleted_at DESC)
+
WHERE deleted_at IS NOT NULL;
+
+
-- Add index for querying by deleted_by (for moderation audit/filtering)
+
CREATE INDEX idx_comments_deleted_by ON comments(deleted_by, deleted_at DESC)
+
WHERE deleted_at IS NOT NULL;
+
+
-- +goose Down
+
-- Remove deletion metadata columns and restore original indexes
+
+
DROP INDEX IF EXISTS idx_comments_deleted_by;
+
DROP INDEX IF EXISTS idx_comments_deleted_reason;
+
DROP INDEX IF EXISTS idx_comments_uri_lookup;
+
DROP INDEX IF EXISTS idx_comments_parent_score;
+
DROP INDEX IF EXISTS idx_comments_parent;
+
DROP INDEX IF EXISTS idx_comments_root;
+
+
-- Restore original partial indexes (excluding deleted comments)
+
CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_uri_active ON comments(uri) WHERE deleted_at IS NULL;
+
+
ALTER TABLE comments DROP COLUMN IF EXISTS deleted_by;
+
ALTER TABLE comments DROP COLUMN IF EXISTS deletion_reason;
+
+
DROP TYPE IF EXISTS deletion_reason;
+17 -13
internal/core/comments/view_models.go
···
// CommentView represents the full view of a comment with all metadata
// Matches social.coves.community.comment.getComments#commentView lexicon
// Used in thread views and get endpoints
+
// For deleted comments, IsDeleted=true and content-related fields are empty/nil
type CommentView struct {
-
Embed interface{} `json:"embed,omitempty"`
-
Record interface{} `json:"record"`
-
Viewer *CommentViewerState `json:"viewer,omitempty"`
-
Author *posts.AuthorView `json:"author"`
-
Post *CommentRef `json:"post"`
-
Parent *CommentRef `json:"parent,omitempty"`
-
Stats *CommentStats `json:"stats"`
-
Content string `json:"content"`
-
CreatedAt string `json:"createdAt"`
-
IndexedAt string `json:"indexedAt"`
-
URI string `json:"uri"`
-
CID string `json:"cid"`
-
ContentFacets []interface{} `json:"contentFacets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Record interface{} `json:"record"`
+
Viewer *CommentViewerState `json:"viewer,omitempty"`
+
Author *posts.AuthorView `json:"author"`
+
Post *CommentRef `json:"post"`
+
Parent *CommentRef `json:"parent,omitempty"`
+
Stats *CommentStats `json:"stats"`
+
Content string `json:"content"`
+
CreatedAt string `json:"createdAt"`
+
IndexedAt string `json:"indexedAt"`
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
ContentFacets []interface{} `json:"contentFacets,omitempty"`
+
IsDeleted bool `json:"isDeleted,omitempty"`
+
DeletionReason *string `json:"deletionReason,omitempty"`
+
DeletedAt *string `json:"deletedAt,omitempty"`
}
// ThreadViewComment represents a comment with its nested replies
+23 -1
internal/core/comments/interfaces.go
···
package comments
-
import "context"
+
import (
+
"context"
+
"database/sql"
+
)
// Repository defines the data access interface for comments
// Used by Jetstream consumer to index comments from firehose
···
// Delete soft-deletes a comment (sets deleted_at)
// Called by Jetstream consumer after comment is deleted from PDS
+
// Deprecated: Use SoftDeleteWithReason for new code to preserve thread structure
Delete(ctx context.Context, uri string) error
+
// SoftDeleteWithReason performs a soft delete that blanks content but preserves thread structure
+
// This allows deleted comments to appear as "[deleted]" placeholders in thread views
+
// reason: "author" (user deleted) or "moderator" (mod removed)
+
// deletedByDID: DID of the actor who performed the deletion
+
SoftDeleteWithReason(ctx context.Context, uri, reason, deletedByDID string) error
+
// ListByRoot retrieves all comments in a thread (flat)
// Used for fetching entire comment threads on posts
ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error)
···
limitPerParent int,
) (map[string][]*Comment, error)
}
+
+
// RepositoryTx provides transaction-aware operations for consumers that need atomicity
+
// Used by Jetstream consumer to perform atomic delete + count updates
+
// Implementations that support transactions should also implement this interface
+
type RepositoryTx interface {
+
// SoftDeleteWithReasonTx performs a soft delete within a transaction
+
// If tx is nil, executes directly against the database
+
// Returns rows affected count for callers that need to check idempotency
+
// reason: must be DeletionReasonAuthor or DeletionReasonModerator
+
// deletedByDID: DID of the actor who performed the deletion
+
SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error)
+
}
+5 -6
internal/core/comments/comment_service.go
···
CreatedAt: createdAt, // Preserve original timestamp
}
-
// Update the record on PDS (putRecord)
-
// Note: This creates a new CID even though the URI stays the same
-
// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.
-
// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.
-
// However, PutRecord is not yet implemented in internal/atproto/pds/client.go.
-
uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord)
+
// Update the record on PDS with optimistic locking via swapRecord CID
+
uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)
if err != nil {
s.logger.Error("failed to update comment on PDS",
"error", err,
···
if pds.IsAuthError(err) {
return nil, ErrNotAuthorized
}
+
if errors.Is(err, pds.ErrConflict) {
+
return nil, ErrConcurrentModification
+
}
return nil, fmt.Errorf("failed to update comment: %w", err)
}
+73
internal/api/handlers/common/viewer_state.go
···
+
package common
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/votes"
+
"context"
+
"log"
+
"net/http"
+
)
+
+
// FeedPostProvider is implemented by any feed post wrapper that contains a PostView.
+
// This allows the helper to work with different feed post types (discover, timeline, communityFeed).
+
type FeedPostProvider interface {
+
GetPost() *posts.PostView
+
}
+
+
// PopulateViewerVoteState enriches feed posts with the authenticated user's vote state.
+
// This is a no-op if voteService is nil or the request is unauthenticated.
+
//
+
// Parameters:
+
// - ctx: Request context for PDS calls
+
// - r: HTTP request (used to extract OAuth session)
+
// - voteService: Vote service for cache lookup (may be nil)
+
// - feedPosts: Posts to enrich with viewer state (must implement FeedPostProvider)
+
//
+
// The function logs but does not fail on errors - viewer state is optional enrichment.
+
func PopulateViewerVoteState[T FeedPostProvider](
+
ctx context.Context,
+
r *http.Request,
+
voteService votes.Service,
+
feedPosts []T,
+
) {
+
if voteService == nil {
+
return
+
}
+
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
return
+
}
+
+
userDID := middleware.GetUserDID(r)
+
+
// Ensure vote cache is populated from PDS
+
if err := voteService.EnsureCachePopulated(ctx, session); err != nil {
+
log.Printf("Warning: failed to populate vote cache: %v", err)
+
return
+
}
+
+
// Collect post URIs to batch lookup
+
postURIs := make([]string, 0, len(feedPosts))
+
for _, feedPost := range feedPosts {
+
if post := feedPost.GetPost(); post != nil {
+
postURIs = append(postURIs, post.URI)
+
}
+
}
+
+
// Get viewer votes for all posts
+
viewerVotes := voteService.GetViewerVotesForSubjects(userDID, postURIs)
+
+
// Populate viewer state on each post
+
for _, feedPost := range feedPosts {
+
if post := feedPost.GetPost(); post != nil {
+
if vote, exists := viewerVotes[post.URI]; exists {
+
post.Viewer = &posts.ViewerState{
+
Vote: &vote.Direction,
+
VoteURI: &vote.URI,
+
}
+
}
+
}
+
}
+
}
+11 -4
internal/api/handlers/discover/get_discover.go
···
package discover
import (
+
"Coves/internal/api/handlers/common"
"Coves/internal/core/discover"
"Coves/internal/core/posts"
+
"Coves/internal/core/votes"
"encoding/json"
"log"
"net/http"
···
// GetDiscoverHandler handles discover feed retrieval
type GetDiscoverHandler struct {
-
service discover.Service
+
service discover.Service
+
voteService votes.Service
}
// NewGetDiscoverHandler creates a new discover handler
-
func NewGetDiscoverHandler(service discover.Service) *GetDiscoverHandler {
+
func NewGetDiscoverHandler(service discover.Service, voteService votes.Service) *GetDiscoverHandler {
return &GetDiscoverHandler{
-
service: service,
+
service: service,
+
voteService: voteService,
}
}
// HandleGetDiscover retrieves posts from all communities (public feed)
// GET /xrpc/social.coves.feed.getDiscover?sort=hot&limit=15&cursor=...
-
// Public endpoint - no authentication required
+
// Public endpoint with optional auth - if authenticated, includes viewer vote state
func (h *GetDiscoverHandler) HandleGetDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
return
}
+
// Populate viewer vote state if authenticated
+
common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed)
+
// Transform blob refs to URLs for all posts
for _, feedPost := range response.Feed {
if feedPost.Post != nil {
+9 -4
internal/api/routes/discover.go
···
import (
"Coves/internal/api/handlers/discover"
+
"Coves/internal/api/middleware"
discoverCore "Coves/internal/core/discover"
+
"Coves/internal/core/votes"
"github.com/go-chi/chi/v5"
)
···
// RegisterDiscoverRoutes registers discover-related XRPC endpoints
//
// SECURITY & RATE LIMITING:
-
// - Discover feed is PUBLIC (no authentication required)
+
// - Discover feed is PUBLIC (works without authentication)
+
// - Optional auth: if authenticated, includes viewer vote state on posts
// - Protected by global rate limiter: 100 requests/minute per IP (main.go:84)
// - Query timeout enforced via context (prevents long-running queries)
// - Result limit capped at 50 posts per request (validated in service layer)
···
func RegisterDiscoverRoutes(
r chi.Router,
discoverService discoverCore.Service,
+
voteService votes.Service,
+
authMiddleware *middleware.OAuthAuthMiddleware,
) {
// Create handlers
-
getDiscoverHandler := discover.NewGetDiscoverHandler(discoverService)
+
getDiscoverHandler := discover.NewGetDiscoverHandler(discoverService, voteService)
// GET /xrpc/social.coves.feed.getDiscover
-
// Public endpoint - no authentication required
+
// Public endpoint with optional auth for viewer-specific state (vote state)
// Shows posts from ALL communities (not personalized)
// Rate limited: 100 req/min per IP via global middleware
-
r.Get("/xrpc/social.coves.feed.getDiscover", getDiscoverHandler.HandleGetDiscover)
+
r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.feed.getDiscover", getDiscoverHandler.HandleGetDiscover)
}
+5
internal/core/communityFeeds/types.go
···
Reply *ReplyRef `json:"reply,omitempty"` // Reply context
}
+
// GetPost returns the underlying PostView for viewer state enrichment
+
func (f *FeedViewPost) GetPost() *posts.PostView {
+
return f.Post
+
}
+
// FeedReason is a union type for feed context
// Can be reasonRepost or reasonPin
type FeedReason struct {
+5
internal/core/discover/types.go
···
Reply *ReplyRef `json:"reply,omitempty"`
}
+
// GetPost returns the underlying PostView for viewer state enrichment
+
func (f *FeedViewPost) GetPost() *posts.PostView {
+
return f.Post
+
}
+
// FeedReason is a union type for feed context
type FeedReason struct {
Repost *ReasonRepost `json:"-"`
+5
internal/core/timeline/types.go
···
Reply *ReplyRef `json:"reply,omitempty"` // Reply context
}
+
// GetPost returns the underlying PostView for viewer state enrichment
+
func (f *FeedViewPost) GetPost() *posts.PostView {
+
return f.Post
+
}
+
// FeedReason is a union type for feed context
// Future: Can be reasonRepost or reasonCommunity
type FeedReason struct {
+193 -5
tests/integration/discover_test.go
···
import (
"Coves/internal/api/handlers/discover"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
"Coves/internal/db/postgres"
"context"
"encoding/json"
···
discoverCore "Coves/internal/core/discover"
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+
// mockVoteService implements votes.Service for testing viewer vote state
+
type mockVoteService struct {
+
cachedVotes map[string]*votes.CachedVote // userDID:subjectURI -> vote
+
}
+
+
func newMockVoteService() *mockVoteService {
+
return &mockVoteService{
+
cachedVotes: make(map[string]*votes.CachedVote),
+
}
+
}
+
+
func (m *mockVoteService) AddVote(userDID, subjectURI, direction, voteURI string) {
+
key := userDID + ":" + subjectURI
+
m.cachedVotes[key] = &votes.CachedVote{
+
Direction: direction,
+
URI: voteURI,
+
}
+
}
+
+
func (m *mockVoteService) CreateVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) {
+
return &votes.CreateVoteResponse{}, nil
+
}
+
+
func (m *mockVoteService) DeleteVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.DeleteVoteRequest) error {
+
return nil
+
}
+
+
func (m *mockVoteService) EnsureCachePopulated(_ context.Context, _ *oauthlib.ClientSessionData) error {
+
return nil // Mock always succeeds - votes pre-populated via AddVote
+
}
+
+
func (m *mockVoteService) GetViewerVote(userDID, subjectURI string) *votes.CachedVote {
+
key := userDID + ":" + subjectURI
+
return m.cachedVotes[key]
+
}
+
+
func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote {
+
result := make(map[string]*votes.CachedVote)
+
for _, uri := range subjectURIs {
+
key := userDID + ":" + uri
+
if vote, exists := m.cachedVotes[key]; exists {
+
result[uri] = vote
+
}
+
}
+
return result
+
}
+
// TestGetDiscover_ShowsAllCommunities tests discover feed shows posts from ALL communities
func TestGetDiscover_ShowsAllCommunities(t *testing.T) {
if testing.Short() {
···
// Setup services
discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret")
discoverService := discoverCore.NewDiscoverService(discoverRepo)
-
handler := discover.NewGetDiscoverHandler(discoverService)
+
handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret")
discoverService := discoverCore.NewDiscoverService(discoverRepo)
-
handler := discover.NewGetDiscoverHandler(discoverService)
+
handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret")
discoverService := discoverCore.NewDiscoverService(discoverRepo)
-
handler := discover.NewGetDiscoverHandler(discoverService)
+
handler := discover.NewGetDiscoverHandler(discoverService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret")
discoverService := discoverCore.NewDiscoverService(discoverRepo)
-
handler := discover.NewGetDiscoverHandler(discoverService)
+
handler := discover.NewGetDiscoverHandler(discoverService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret")
discoverService := discoverCore.NewDiscoverService(discoverRepo)
-
handler := discover.NewGetDiscoverHandler(discoverService)
+
handler := discover.NewGetDiscoverHandler(discoverService, nil)
t.Run("Limit exceeds maximum", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=100", nil)
···
assert.Contains(t, errorResp["message"], "limit")
})
}
+
+
// TestGetDiscover_ViewerVoteState tests that authenticated users see their vote state on posts
+
func TestGetDiscover_ViewerVoteState(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
db := setupTestDB(t)
+
t.Cleanup(func() { _ = db.Close() })
+
+
ctx := context.Background()
+
testID := time.Now().UnixNano()
+
+
// Create community and posts
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("votes-%d", testID), fmt.Sprintf("alice-%d.test", testID))
+
require.NoError(t, err)
+
+
post1URI := createTestPost(t, db, communityDID, "did:plc:author1", "Post with upvote", 10, time.Now().Add(-1*time.Hour))
+
post2URI := createTestPost(t, db, communityDID, "did:plc:author2", "Post with downvote", 5, time.Now().Add(-2*time.Hour))
+
_ = createTestPost(t, db, communityDID, "did:plc:author3", "Post without vote", 3, time.Now().Add(-3*time.Hour))
+
+
// Setup mock vote service with pre-populated votes
+
viewerDID := "did:plc:viewer123"
+
mockVotes := newMockVoteService()
+
mockVotes.AddVote(viewerDID, post1URI, "up", "at://"+viewerDID+"/social.coves.vote/vote1")
+
mockVotes.AddVote(viewerDID, post2URI, "down", "at://"+viewerDID+"/social.coves.vote/vote2")
+
+
// Setup handler with mock vote service
+
discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret")
+
discoverService := discoverCore.NewDiscoverService(discoverRepo)
+
handler := discover.NewGetDiscoverHandler(discoverService, mockVotes)
+
+
// Create request with authenticated user context
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil)
+
+
// Inject OAuth session into context (simulates OptionalAuth middleware)
+
did, _ := syntax.ParseDID(viewerDID)
+
session := &oauthlib.ClientSessionData{
+
AccountDID: did,
+
AccessToken: "test_token",
+
}
+
reqCtx := context.WithValue(req.Context(), middleware.UserDIDKey, viewerDID)
+
reqCtx = context.WithValue(reqCtx, middleware.OAuthSessionKey, session)
+
req = req.WithContext(reqCtx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleGetDiscover(rec, req)
+
+
// Assertions
+
assert.Equal(t, http.StatusOK, rec.Code)
+
+
var response discoverCore.DiscoverResponse
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
+
require.NoError(t, err)
+
+
// Find our test posts and verify vote state
+
var foundPost1, foundPost2, foundPost3 bool
+
for _, feedPost := range response.Feed {
+
switch feedPost.Post.URI {
+
case post1URI:
+
foundPost1 = true
+
require.NotNil(t, feedPost.Post.Viewer, "Post1 should have viewer state")
+
require.NotNil(t, feedPost.Post.Viewer.Vote, "Post1 should have vote direction")
+
assert.Equal(t, "up", *feedPost.Post.Viewer.Vote, "Post1 should show upvote")
+
require.NotNil(t, feedPost.Post.Viewer.VoteURI, "Post1 should have vote URI")
+
assert.Contains(t, *feedPost.Post.Viewer.VoteURI, "vote1", "Post1 should have correct vote URI")
+
+
case post2URI:
+
foundPost2 = true
+
require.NotNil(t, feedPost.Post.Viewer, "Post2 should have viewer state")
+
require.NotNil(t, feedPost.Post.Viewer.Vote, "Post2 should have vote direction")
+
assert.Equal(t, "down", *feedPost.Post.Viewer.Vote, "Post2 should show downvote")
+
require.NotNil(t, feedPost.Post.Viewer.VoteURI, "Post2 should have vote URI")
+
+
default:
+
// Posts without votes should have nil Viewer or nil Vote
+
if feedPost.Post.Viewer != nil && feedPost.Post.Viewer.Vote != nil {
+
// This post has a vote from our viewer - it's not post3
+
continue
+
}
+
foundPost3 = true
+
}
+
}
+
+
assert.True(t, foundPost1, "Should find post1 with upvote")
+
assert.True(t, foundPost2, "Should find post2 with downvote")
+
assert.True(t, foundPost3, "Should find post3 without vote")
+
}
+
+
// TestGetDiscover_NoViewerStateWithoutAuth tests that unauthenticated users don't get viewer state
+
func TestGetDiscover_NoViewerStateWithoutAuth(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
db := setupTestDB(t)
+
t.Cleanup(func() { _ = db.Close() })
+
+
ctx := context.Background()
+
testID := time.Now().UnixNano()
+
+
// Create community and post
+
communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("noauth-%d", testID), fmt.Sprintf("alice-%d.test", testID))
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, communityDID, "did:plc:author", "Some post", 10, time.Now())
+
+
// Setup mock vote service with a vote (but request will be unauthenticated)
+
mockVotes := newMockVoteService()
+
mockVotes.AddVote("did:plc:someuser", postURI, "up", "at://did:plc:someuser/social.coves.vote/vote1")
+
+
// Setup handler with mock vote service
+
discoverRepo := postgres.NewDiscoverRepository(db, "test-cursor-secret")
+
discoverService := discoverCore.NewDiscoverService(discoverRepo)
+
handler := discover.NewGetDiscoverHandler(discoverService, mockVotes)
+
+
// Create request WITHOUT auth context
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil)
+
rec := httptest.NewRecorder()
+
handler.HandleGetDiscover(rec, req)
+
+
// Should succeed
+
assert.Equal(t, http.StatusOK, rec.Code)
+
+
var response discoverCore.DiscoverResponse
+
err = json.Unmarshal(rec.Body.Bytes(), &response)
+
require.NoError(t, err)
+
+
// Find our post and verify NO viewer state (unauthenticated)
+
for _, feedPost := range response.Feed {
+
if feedPost.Post.URI == postURI {
+
assert.Nil(t, feedPost.Post.Viewer, "Unauthenticated request should not have viewer state")
+
return
+
}
+
}
+
t.Fatal("Test post not found in response")
+
}
+11 -11
tests/integration/feed_test.go
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test data: community, users, and posts
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test data
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test data
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test data with many posts
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Request feed for non-existent community
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.communityFeed.getCommunity?community=did:plc:nonexistent&sort=hot&limit=10", nil)
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test community
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Create community with no posts
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test community
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test data
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test data
ctx := context.Background()
···
nil,
)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
-
handler := communityFeed.NewGetCommunityHandler(feedService)
+
handler := communityFeed.NewGetCommunityHandler(feedService, nil)
// Setup test data
ctx := context.Background()
+7 -7
tests/integration/timeline_test.go
···
// Setup services
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
timelineService := timelineCore.NewTimelineService(timelineRepo)
-
handler := timeline.NewGetTimelineHandler(timelineService)
+
handler := timeline.NewGetTimelineHandler(timelineService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
timelineService := timelineCore.NewTimelineService(timelineRepo)
-
handler := timeline.NewGetTimelineHandler(timelineService)
+
handler := timeline.NewGetTimelineHandler(timelineService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
timelineService := timelineCore.NewTimelineService(timelineRepo)
-
handler := timeline.NewGetTimelineHandler(timelineService)
+
handler := timeline.NewGetTimelineHandler(timelineService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
timelineService := timelineCore.NewTimelineService(timelineRepo)
-
handler := timeline.NewGetTimelineHandler(timelineService)
+
handler := timeline.NewGetTimelineHandler(timelineService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
timelineService := timelineCore.NewTimelineService(timelineRepo)
-
handler := timeline.NewGetTimelineHandler(timelineService)
+
handler := timeline.NewGetTimelineHandler(timelineService, nil)
// Request timeline WITHOUT auth context
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)
···
// Setup services
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
timelineService := timelineCore.NewTimelineService(timelineRepo)
-
handler := timeline.NewGetTimelineHandler(timelineService)
+
handler := timeline.NewGetTimelineHandler(timelineService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
···
// Setup services
timelineRepo := postgres.NewTimelineRepository(db, "test-cursor-secret")
timelineService := timelineCore.NewTimelineService(timelineRepo)
-
handler := timeline.NewGetTimelineHandler(timelineService)
+
handler := timeline.NewGetTimelineHandler(timelineService, nil)
ctx := context.Background()
testID := time.Now().UnixNano()
+2
scripts/generate_deep_thread.go
···
+
//go:build ignore
+
package main
import (
+2
scripts/generate_nba_comments.go
···
+
//go:build ignore
+
package main
import (
+2
scripts/generate_test_comments.go
···
+
//go:build ignore
+
package main
import (
+38 -6
internal/atproto/jetstream/user_consumer.go
···
"github.com/gorilla/websocket"
)
+
// SessionHandleUpdater is an interface for updating OAuth session handles
+
// when identity changes occur. This keeps active sessions in sync with
+
// the user's current handle.
+
type SessionHandleUpdater interface {
+
UpdateHandleByDID(ctx context.Context, did, newHandle string) (int64, error)
+
}
+
// JetstreamEvent represents an event from the Jetstream firehose
// Jetstream documentation: https://docs.bsky.app/docs/advanced-guides/jetstream
type JetstreamEvent struct {
···
// UserEventConsumer consumes user-related events from Jetstream
type UserEventConsumer struct {
-
userService users.UserService
-
identityResolver identity.Resolver
-
wsURL string
-
pdsFilter string // Optional: only index users from specific PDS
+
userService users.UserService
+
identityResolver identity.Resolver
+
sessionHandleUpdater SessionHandleUpdater // Optional: updates OAuth sessions on handle change
+
wsURL string
+
pdsFilter string // Optional: only index users from specific PDS
+
}
+
+
// ConsumerOption is a functional option for configuring UserEventConsumer
+
type ConsumerOption func(*UserEventConsumer)
+
+
// WithSessionHandleUpdater sets the session handle updater for syncing OAuth sessions
+
// when identity changes occur. If not set, OAuth sessions won't be updated on handle changes.
+
func WithSessionHandleUpdater(updater SessionHandleUpdater) ConsumerOption {
+
return func(c *UserEventConsumer) {
+
c.sessionHandleUpdater = updater
+
}
}
// NewUserEventConsumer creates a new Jetstream consumer for user events
-
func NewUserEventConsumer(userService users.UserService, identityResolver identity.Resolver, wsURL, pdsFilter string) *UserEventConsumer {
-
return &UserEventConsumer{
+
func NewUserEventConsumer(userService users.UserService, identityResolver identity.Resolver, wsURL, pdsFilter string, opts ...ConsumerOption) *UserEventConsumer {
+
c := &UserEventConsumer{
userService: userService,
identityResolver: identityResolver,
wsURL: wsURL,
pdsFilter: pdsFilter,
}
+
for _, opt := range opts {
+
opt(c)
+
}
+
return c
}
// Start begins consuming events from Jetstream
···
log.Printf("Warning: failed to purge DID cache for %s: %v", did, purgeErr)
}
+
// Update OAuth session handles to keep mobile/web sessions in sync
+
if c.sessionHandleUpdater != nil {
+
if sessionsUpdated, updateErr := c.sessionHandleUpdater.UpdateHandleByDID(ctx, did, handle); updateErr != nil {
+
log.Printf("Warning: failed to update OAuth session handles for %s: %v", did, updateErr)
+
} else if sessionsUpdated > 0 {
+
log.Printf("Updated %d OAuth session(s) with new handle: %s", sessionsUpdated, handle)
+
}
+
}
+
log.Printf("Updated handle and purged cache: %s โ†’ %s", existingUser.Handle, handle)
} else {
log.Printf("Handle unchanged for %s (%s)", handle, did)
+29
internal/atproto/oauth/store.go
···
return rows, nil
}
+
// UpdateHandleByDID updates the handle for all OAuth sessions belonging to a DID.
+
// This is called when identity events indicate a handle change, keeping active
+
// sessions in sync with the user's current handle.
+
// Returns the number of sessions updated.
+
func (s *PostgresOAuthStore) UpdateHandleByDID(ctx context.Context, did, newHandle string) (int64, error) {
+
query := `
+
UPDATE oauth_sessions
+
SET handle = $2, updated_at = NOW()
+
WHERE did = $1 AND expires_at > NOW()
+
`
+
+
result, err := s.db.ExecContext(ctx, query, did, newHandle)
+
if err != nil {
+
return 0, fmt.Errorf("failed to update session handle: %w", err)
+
}
+
+
rows, err := result.RowsAffected()
+
if err != nil {
+
return 0, fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
if rows > 0 {
+
slog.Info("updated OAuth session handles for identity change",
+
"did", did, "new_handle", newHandle, "sessions_updated", rows)
+
}
+
+
return rows, nil
+
}
+
// MobileOAuthData holds mobile-specific OAuth flow data
type MobileOAuthData struct {
CSRFToken string
+375
tests/integration/oauth_session_handle_sync_test.go
···
+
package integration
+
+
import (
+
"context"
+
"fmt"
+
"net/http"
+
"testing"
+
"time"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/stretchr/testify/require"
+
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/oauth"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
+
)
+
+
// TestOAuthSessionHandleSync tests that OAuth session handles are updated
+
// when identity events indicate a handle change.
+
//
+
// This ensures mobile/web apps display the correct handle after a user
+
// changes their handle on their PDS.
+
//
+
// Prerequisites:
+
// - Test database on localhost:5434
+
//
+
// Run with:
+
//
+
// docker-compose --profile test up -d postgres-test
+
// TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
// go test -v ./tests/integration/ -run "TestOAuthSessionHandleSync"
+
func TestOAuthSessionHandleSync(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
+
// Set up real infrastructure components
+
userRepo := postgres.NewUserRepository(db)
+
resolver := identity.NewResolver(db, identity.DefaultConfig())
+
userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
+
+
// Create real OAuth store (with session handle updater capability)
+
baseOAuthStore := oauth.NewPostgresOAuthStore(db, 24*time.Hour)
+
+
t.Run("Handle change syncs to active OAuth sessions", func(t *testing.T) {
+
testDID := "did:plc:oauthsync123"
+
oldHandle := "oldhandle.oauth.sync.test"
+
newHandle := "newhandle.oauth.sync.test"
+
sessionID := "test-session-oauth-sync-001"
+
+
// 1. Create user with old handle
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err, "Failed to create test user")
+
t.Logf("โœ… Created user: %s (%s)", oldHandle, testDID)
+
+
// 2. Create OAuth session with old handle
+
parsedDID, err := syntax.ParseDID(testDID)
+
require.NoError(t, err, "Failed to parse DID")
+
+
session := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: sessionID,
+
HostURL: "https://bsky.social",
+
AccessToken: "test-access-token",
+
RefreshToken: "test-refresh-token",
+
Scopes: []string{"atproto"},
+
}
+
err = baseOAuthStore.SaveSession(ctx, session)
+
require.NoError(t, err, "Failed to save OAuth session")
+
t.Logf("โœ… Created OAuth session: %s", sessionID)
+
+
// 3. Verify session was created with correct data
+
savedSession, err := baseOAuthStore.GetSession(ctx, parsedDID, sessionID)
+
require.NoError(t, err, "Failed to retrieve saved session")
+
require.NotNil(t, savedSession, "Session should exist")
+
t.Logf("โœ… Verified session exists for DID: %s", testDID)
+
+
// 4. Cast store to SessionHandleUpdater (what the consumer uses)
+
sessionUpdater, ok := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
require.True(t, ok, "OAuth store should implement SessionHandleUpdater")
+
+
// 5. Create consumer with session handle updater
+
consumer := jetstream.NewUserEventConsumer(
+
userService,
+
resolver,
+
"", // No WebSocket URL - we'll call HandleIdentityEventPublic directly
+
"",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
// 6. Simulate identity event with NEW handle (as if PDS sent handle change)
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 999999,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
t.Logf("๐Ÿ“ก Simulating identity event: %s โ†’ %s", oldHandle, newHandle)
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err, "Failed to handle identity event")
+
t.Logf("โœ… Identity event processed")
+
+
// 7. Verify users table was updated
+
user, err := userService.GetUserByDID(ctx, testDID)
+
require.NoError(t, err, "Failed to get user after handle change")
+
require.Equal(t, newHandle, user.Handle, "User handle should be updated in database")
+
t.Logf("โœ… Users table updated: handle=%s", user.Handle)
+
+
// 8. Verify OAuth session handle was updated
+
var sessionHandle string
+
err = db.QueryRowContext(ctx,
+
"SELECT handle FROM oauth_sessions WHERE did = $1 AND session_id = $2",
+
testDID, sessionID,
+
).Scan(&sessionHandle)
+
require.NoError(t, err, "Failed to query session handle")
+
require.Equal(t, newHandle, sessionHandle, "OAuth session handle should be updated")
+
t.Logf("โœ… OAuth session handle updated: %s", sessionHandle)
+
})
+
+
t.Run("Multiple sessions updated on handle change", func(t *testing.T) {
+
testDID := "did:plc:multisession456"
+
oldHandle := "multi.old.handle.test"
+
newHandle := "multi.new.handle.test"
+
+
// 1. Create user
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err)
+
+
// 2. Create multiple OAuth sessions (simulating login from multiple devices)
+
parsedDID, _ := syntax.ParseDID(testDID)
+
for i := 1; i <= 3; i++ {
+
session := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: fmt.Sprintf("multi-session-%d", i),
+
HostURL: "https://bsky.social",
+
AccessToken: fmt.Sprintf("access-token-%d", i),
+
RefreshToken: fmt.Sprintf("refresh-token-%d", i),
+
Scopes: []string{"atproto"},
+
}
+
err = baseOAuthStore.SaveSession(ctx, session)
+
require.NoError(t, err)
+
}
+
t.Logf("โœ… Created 3 OAuth sessions for user")
+
+
// 3. Process identity event with new handle
+
sessionUpdater := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
consumer := jetstream.NewUserEventConsumer(
+
userService, resolver, "", "",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 888888,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err)
+
+
// 4. Verify ALL sessions were updated
+
var count int
+
err = db.QueryRowContext(ctx,
+
"SELECT COUNT(*) FROM oauth_sessions WHERE did = $1 AND handle = $2",
+
testDID, newHandle,
+
).Scan(&count)
+
require.NoError(t, err)
+
require.Equal(t, 3, count, "All 3 sessions should have updated handles")
+
t.Logf("โœ… All %d sessions updated with new handle", count)
+
})
+
+
t.Run("No sessions updated when user has no active sessions", func(t *testing.T) {
+
testDID := "did:plc:nosessions789"
+
oldHandle := "nosession.old.test"
+
newHandle := "nosession.new.test"
+
+
// 1. Create user with no OAuth sessions
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err)
+
+
// 2. Process identity event
+
sessionUpdater := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
consumer := jetstream.NewUserEventConsumer(
+
userService, resolver, "", "",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 777777,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
// Should not error even when no sessions exist
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err, "Should handle event gracefully with no sessions")
+
+
// 3. Verify user was still updated
+
user, err := userService.GetUserByDID(ctx, testDID)
+
require.NoError(t, err)
+
require.Equal(t, newHandle, user.Handle)
+
t.Logf("โœ… User updated correctly even with no active sessions")
+
})
+
+
t.Run("Consumer works without session updater (backward compat)", func(t *testing.T) {
+
testDID := "did:plc:nosyncer000"
+
oldHandle := "nosyncer.old.test"
+
newHandle := "nosyncer.new.test"
+
+
// 1. Create user
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err)
+
+
// 2. Create consumer WITHOUT session handle updater
+
consumer := jetstream.NewUserEventConsumer(
+
userService, resolver, "", "",
+
// No WithSessionHandleUpdater - testing backward compatibility
+
)
+
+
// 3. Process identity event - should work without error
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 666666,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err, "Consumer should work without session updater")
+
+
// 4. Verify user was updated
+
user, err := userService.GetUserByDID(ctx, testDID)
+
require.NoError(t, err)
+
require.Equal(t, newHandle, user.Handle)
+
t.Logf("โœ… Consumer works correctly without session handle updater")
+
})
+
}
+
+
// TestOAuthSessionHandleSync_LiveJetstream tests the full flow with real Jetstream
+
// This requires the dev infrastructure to be running.
+
//
+
// Prerequisites:
+
// - PDS running on localhost:3001
+
// - Jetstream running on localhost:6008
+
// - Test database on localhost:5434
+
//
+
// Run with:
+
//
+
// docker-compose --profile test --profile jetstream up -d
+
// TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
// go test -v ./tests/integration/ -run "TestOAuthSessionHandleSync_LiveJetstream"
+
func TestOAuthSessionHandleSync_LiveJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping live Jetstream test in short mode")
+
}
+
+
// Check if Jetstream is available
+
if !isServiceAvailable("http://localhost:6008") {
+
t.Skip("Jetstream not available at localhost:6008 - run 'docker-compose --profile jetstream up -d' first")
+
}
+
+
// Check if PDS is available
+
if !isServiceAvailable("http://localhost:3001/xrpc/_health") {
+
t.Skip("PDS not available at localhost:3001 - run 'docker-compose up -d pds' first")
+
}
+
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+
defer cancel()
+
+
// Set up real infrastructure
+
userRepo := postgres.NewUserRepository(db)
+
resolver := identity.NewResolver(db, identity.DefaultConfig())
+
userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
+
baseOAuthStore := oauth.NewPostgresOAuthStore(db, 24*time.Hour)
+
sessionUpdater := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
+
// Start consumer connected to real Jetstream
+
consumer := jetstream.NewUserEventConsumer(
+
userService,
+
resolver,
+
"ws://localhost:6008/subscribe",
+
"",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
// Start consumer in background
+
consumerCtx, consumerCancel := context.WithCancel(ctx)
+
defer consumerCancel()
+
+
go func() {
+
if err := consumer.Start(consumerCtx); err != nil && err != context.Canceled {
+
t.Logf("Consumer stopped: %v", err)
+
}
+
}()
+
+
// Give consumer time to connect
+
time.Sleep(500 * time.Millisecond)
+
+
t.Run("Real Jetstream integration", func(t *testing.T) {
+
t.Log("๐Ÿ”Œ Connected to live Jetstream - waiting for identity events...")
+
t.Log("Note: This test verifies the consumer is properly configured with session sync.")
+
t.Log("To fully test handle sync, create a user on the PDS and change their handle.")
+
+
// For now, just verify the consumer is running with the session updater
+
// A full E2E test would require:
+
// 1. Create user on PDS
+
// 2. Create OAuth session
+
// 3. Update handle on PDS (via user credentials)
+
// 4. Wait for Jetstream to deliver identity event
+
// 5. Verify session handle updated
+
+
t.Log("โœ… Consumer running with OAuth session sync enabled")
+
})
+
}
+
+
// isServiceAvailable checks if an HTTP service is responding
+
func isServiceAvailable(url string) bool {
+
client := &http.Client{Timeout: 2 * time.Second}
+
resp, err := client.Get(url)
+
if err != nil {
+
return false
+
}
+
defer resp.Body.Close()
+
return resp.StatusCode < 500
+
}
+13 -6
aggregators/kagi-news/src/coves_client.py
···
self,
uri: str,
title: str,
-
description: str
+
description: str,
+
sources: Optional[List[Dict]] = None
) -> Dict:
"""
Create external embed object for hot-linked content.
···
uri: URL of the external content
title: Title of the content
description: Description/summary
+
sources: Optional list of source dicts with uri, title, domain
Returns:
Embed dictionary ready for post creation
"""
+
external = {
+
"uri": uri,
+
"title": title,
+
"description": description
+
}
+
+
if sources:
+
external["sources"] = sources
+
return {
"$type": "social.coves.embed.external",
-
"external": {
-
"uri": uri,
-
"title": title,
-
"description": description
-
}
+
"external": external
}
def _get_timestamp(self) -> str:
+8 -2
aggregators/kagi-news/src/main.py
···
# Format as rich text
rich_text = self.richtext_formatter.format_full(story)
-
# Create external embed
+
# Create external embed with sources
+
sources = [
+
{"uri": s.url, "title": s.title, "domain": s.domain}
+
for s in story.sources
+
] if story.sources else None
+
embed = self.coves_client.create_external_embed(
uri=story.link,
title=story.title,
-
description=story.summary[:200] if len(story.summary) > 200 else story.summary
+
description=story.summary[:200] if len(story.summary) > 200 else story.summary,
+
sources=sources
)
# Post to community
+142
aggregators/kagi-news/tests/test_coves_client.py
···
+
"""
+
Unit tests for CovesClient.
+
+
Tests the client's local functionality without requiring live infrastructure.
+
"""
+
import pytest
+
from src.coves_client import CovesClient
+
+
+
class TestCreateExternalEmbed:
+
"""Tests for create_external_embed method."""
+
+
@pytest.fixture
+
def client(self):
+
"""Create a CovesClient instance for testing."""
+
return CovesClient(
+
api_url="http://localhost:8081",
+
handle="test.handle",
+
password="test_password"
+
)
+
+
def test_creates_embed_without_sources(self, client):
+
"""Test basic embed creation without sources."""
+
embed = client.create_external_embed(
+
uri="https://example.com/article",
+
title="Test Article",
+
description="Test description"
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
assert embed["external"]["uri"] == "https://example.com/article"
+
assert embed["external"]["title"] == "Test Article"
+
assert embed["external"]["description"] == "Test description"
+
assert "sources" not in embed["external"]
+
+
def test_creates_embed_with_sources(self, client):
+
"""Test embed creation with sources array."""
+
sources = [
+
{"uri": "https://source1.com/article", "title": "Source 1", "domain": "source1.com"},
+
{"uri": "https://source2.com/article", "title": "Source 2", "domain": "source2.com"},
+
]
+
+
embed = client.create_external_embed(
+
uri="https://example.com/article",
+
title="Test Article",
+
description="Test description",
+
sources=sources
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
assert embed["external"]["uri"] == "https://example.com/article"
+
assert "sources" in embed["external"]
+
assert len(embed["external"]["sources"]) == 2
+
assert embed["external"]["sources"][0]["uri"] == "https://source1.com/article"
+
assert embed["external"]["sources"][0]["title"] == "Source 1"
+
assert embed["external"]["sources"][0]["domain"] == "source1.com"
+
assert embed["external"]["sources"][1]["uri"] == "https://source2.com/article"
+
+
def test_creates_embed_with_empty_sources_list(self, client):
+
"""Test that empty sources list is excluded from embed."""
+
embed = client.create_external_embed(
+
uri="https://example.com/article",
+
title="Test Article",
+
description="Test description",
+
sources=[]
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
assert "sources" not in embed["external"]
+
+
def test_creates_embed_with_none_sources(self, client):
+
"""Test that None sources is handled correctly."""
+
embed = client.create_external_embed(
+
uri="https://example.com/article",
+
title="Test Article",
+
description="Test description",
+
sources=None
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
assert "sources" not in embed["external"]
+
+
def test_creates_embed_with_single_source(self, client):
+
"""Test embed creation with single source."""
+
sources = [
+
{"uri": "https://single.com/article", "title": "Single Source", "domain": "single.com"}
+
]
+
+
embed = client.create_external_embed(
+
uri="https://example.com/article",
+
title="Test Article",
+
description="Test description",
+
sources=sources
+
)
+
+
assert len(embed["external"]["sources"]) == 1
+
assert embed["external"]["sources"][0]["uri"] == "https://single.com/article"
+
+
def test_embed_structure_matches_lexicon(self, client):
+
"""Test that embed structure matches social.coves.embed.external lexicon."""
+
sources = [
+
{"uri": "https://source.com/article", "title": "Source", "domain": "source.com"}
+
]
+
+
embed = client.create_external_embed(
+
uri="https://example.com/article",
+
title="Test Article",
+
description="Test description",
+
sources=sources
+
)
+
+
# Verify top-level structure
+
assert "$type" in embed
+
assert "external" in embed
+
assert len(embed) == 2 # Only $type and external
+
+
# Verify external object structure
+
external = embed["external"]
+
assert "uri" in external
+
assert "title" in external
+
assert "description" in external
+
assert "sources" in external
+
+
def test_preserves_source_structure(self, client):
+
"""Test that source dictionaries are passed through unchanged."""
+
sources = [
+
{
+
"uri": "https://source.com/article",
+
"title": "Source Title",
+
"domain": "source.com",
+
"extra_field": "should be preserved" # Extra fields should pass through
+
}
+
]
+
+
embed = client.create_external_embed(
+
uri="https://example.com/article",
+
title="Test Article",
+
description="Test description",
+
sources=sources
+
)
+
+
assert embed["external"]["sources"][0]["extra_field"] == "should be preserved"
+76 -1
aggregators/kagi-news/tests/test_e2e.py
···
assert embed["external"]["description"] == "Test description"
# Thumbnail is not included - server's unfurl service handles it
assert "thumb" not in embed["external"]
-
print("\nโœ… External embed format correct")
+
# Sources should not be present when not provided
+
assert "sources" not in embed["external"]
+
print("\nโœ… External embed format correct (no sources)")
+
+
+
def test_coves_client_external_embed_with_sources(aggregator_credentials):
+
"""
+
Test external embed formatting with sources.
+
+
Verifies:
+
- Embed structure matches social.coves.embed.external
+
- Sources array is included when provided
+
- Each source has uri, title, domain
+
"""
+
handle, password = aggregator_credentials
+
+
client = CovesClient(
+
api_url="http://localhost:8081",
+
handle=handle,
+
password=password
+
)
+
+
# Create external embed with sources
+
sources = [
+
{"uri": "https://source1.com/article", "title": "Source 1 Article", "domain": "source1.com"},
+
{"uri": "https://source2.com/story", "title": "Source 2 Story", "domain": "source2.com"},
+
]
+
+
embed = client.create_external_embed(
+
uri="https://example.com/story",
+
title="Test Story With Sources",
+
description="Test description with sources",
+
sources=sources
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
assert embed["external"]["uri"] == "https://example.com/story"
+
assert embed["external"]["title"] == "Test Story With Sources"
+
assert embed["external"]["description"] == "Test description with sources"
+
# Sources should be present
+
assert "sources" in embed["external"]
+
assert len(embed["external"]["sources"]) == 2
+
assert embed["external"]["sources"][0]["uri"] == "https://source1.com/article"
+
assert embed["external"]["sources"][0]["title"] == "Source 1 Article"
+
assert embed["external"]["sources"][0]["domain"] == "source1.com"
+
assert embed["external"]["sources"][1]["uri"] == "https://source2.com/story"
+
print("\nโœ… External embed format correct (with sources)")
+
+
+
def test_coves_client_external_embed_with_empty_sources(aggregator_credentials):
+
"""
+
Test external embed formatting with empty sources list.
+
+
Verifies:
+
- Empty sources list is not included in embed (regression test)
+
"""
+
handle, password = aggregator_credentials
+
+
client = CovesClient(
+
api_url="http://localhost:8081",
+
handle=handle,
+
password=password
+
)
+
+
# Create external embed with empty sources list
+
embed = client.create_external_embed(
+
uri="https://example.com/story",
+
title="Test Story",
+
description="Test description",
+
sources=[]
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
# Empty sources list should not be included
+
assert "sources" not in embed["external"]
+
print("\nโœ… External embed format correct (empty sources excluded)")
+137
aggregators/kagi-news/tests/test_main.py
···
assert call_kwargs["embed"]["external"]["title"] == sample_story.title
# Thumbnail is not included - server's unfurl service handles it
assert "thumb" not in call_kwargs["embed"]["external"]
+
+
def test_create_post_with_sources_in_embed(self, mock_config, mock_rss_feed, sample_story, tmp_path):
+
"""Test that posts include sources in external embeds when available."""
+
state_file = tmp_path / "state.json"
+
mock_client = Mock()
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
+
+
# Mock create_external_embed to return proper embed structure with sources
+
mock_client.create_external_embed.return_value = {
+
"$type": "social.coves.embed.external",
+
"external": {
+
"uri": sample_story.link,
+
"title": sample_story.title,
+
"description": sample_story.summary,
+
"sources": [
+
{"uri": "https://example.com/1", "title": "Source 1", "domain": "example.com"}
+
]
+
}
+
}
+
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
+
patch('src.main.RichTextFormatter') as MockFormatter:
+
+
# Setup mocks
+
mock_loader = Mock()
+
mock_loader.load.return_value = mock_config
+
MockConfigLoader.return_value = mock_loader
+
+
mock_fetcher = Mock()
+
single_entry_feed = MagicMock(bozo=0, entries=[mock_rss_feed.entries[0]])
+
mock_fetcher.fetch_feed.return_value = single_entry_feed
+
MockRSSFetcher.return_value = mock_fetcher
+
+
mock_parser = Mock()
+
mock_parser.parse_to_story.return_value = sample_story
+
MockHTMLParser.return_value = mock_parser
+
+
mock_formatter = Mock()
+
mock_formatter.format_full.return_value = {
+
"content": "Test content",
+
"facets": []
+
}
+
MockFormatter.return_value = mock_formatter
+
+
# Run aggregator
+
aggregator = Aggregator(
+
config_path=Path("config.yaml"),
+
state_file=state_file,
+
coves_client=mock_client
+
)
+
aggregator.run()
+
+
# Verify create_external_embed was called with sources
+
mock_client.create_external_embed.assert_called()
+
call_kwargs = mock_client.create_external_embed.call_args.kwargs
+
+
# Verify sources were passed
+
assert "sources" in call_kwargs
+
assert len(call_kwargs["sources"]) == 1
+
assert call_kwargs["sources"][0]["uri"] == "https://example.com/1"
+
assert call_kwargs["sources"][0]["title"] == "Source 1"
+
assert call_kwargs["sources"][0]["domain"] == "example.com"
+
+
def test_create_post_without_sources(self, mock_config, mock_rss_feed, tmp_path):
+
"""Test that posts without sources don't include sources in embed."""
+
state_file = tmp_path / "state.json"
+
mock_client = Mock()
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
+
+
# Create a story without sources
+
story_without_sources = KagiStory(
+
title="Test Story No Sources",
+
link="https://kite.kagi.com/test/world/1",
+
guid="https://kite.kagi.com/test/world/1",
+
pub_date=datetime(2024, 1, 15, 12, 0, 0),
+
categories=["World"],
+
summary="Test summary",
+
highlights=[],
+
perspectives=[],
+
quote=None,
+
sources=[], # No sources
+
image_url=None,
+
image_alt=None
+
)
+
+
# Mock create_external_embed to return proper embed structure without sources
+
mock_client.create_external_embed.return_value = {
+
"$type": "social.coves.embed.external",
+
"external": {
+
"uri": story_without_sources.link,
+
"title": story_without_sources.title,
+
"description": story_without_sources.summary
+
}
+
}
+
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
+
patch('src.main.RichTextFormatter') as MockFormatter:
+
+
# Setup mocks
+
mock_loader = Mock()
+
mock_loader.load.return_value = mock_config
+
MockConfigLoader.return_value = mock_loader
+
+
mock_fetcher = Mock()
+
single_entry_feed = MagicMock(bozo=0, entries=[mock_rss_feed.entries[0]])
+
mock_fetcher.fetch_feed.return_value = single_entry_feed
+
MockRSSFetcher.return_value = mock_fetcher
+
+
mock_parser = Mock()
+
mock_parser.parse_to_story.return_value = story_without_sources
+
MockHTMLParser.return_value = mock_parser
+
+
mock_formatter = Mock()
+
mock_formatter.format_full.return_value = {
+
"content": "Test content",
+
"facets": []
+
}
+
MockFormatter.return_value = mock_formatter
+
+
# Run aggregator
+
aggregator = Aggregator(
+
config_path=Path("config.yaml"),
+
state_file=state_file,
+
coves_client=mock_client
+
)
+
aggregator.run()
+
+
# Verify create_external_embed was called
+
mock_client.create_external_embed.assert_called()
+
call_kwargs = mock_client.create_external_embed.call_args.kwargs
+
+
# Verify sources is None (empty list becomes None)
+
assert call_kwargs.get("sources") is None
+1188
tests/integration/comment_e2e_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/pds"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net"
+
"net/http"
+
"os"
+
"strings"
+
"testing"
+
"time"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/gorilla/websocket"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
)
+
+
// TestCommentE2E_CreateWithJetstream tests the full comment creation flow with real Jetstream
+
// Flow: Client โ†’ Service โ†’ PDS Write โ†’ Jetstream Firehose โ†’ Consumer โ†’ AppView
+
func TestCommentE2E_CreateWithJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
// Check if PDS is running
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
// Check if Jetstream is running
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
pdsHostname = strings.Split(pdsHostname, ":")[0]
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)
+
+
testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
t.Skipf("Jetstream not running at %s: %v. Run 'docker-compose --profile jetstream up' to start.", jetstreamURL, err)
+
}
+
_ = testConn.Close()
+
+
ctx := context.Background()
+
+
// Setup repositories
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create test user on PDS
+
// Use shorter handle to avoid PDS length limits (max 20 chars for label)
+
testUserHandle := fmt.Sprintf("cmt%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("cmt%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
t.Logf("Creating test user on PDS: %s", testUserHandle)
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
t.Logf("Test user created: DID=%s", userDID)
+
+
// Index user in AppView
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
// Create test community and post to comment on
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-e2e-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Comments", 0, time.Now())
+
postCID := "bafyposte2etest"
+
+
// Setup comment service with PDS factory
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create mock OAuth session
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
t.Run("create comment with real Jetstream indexing", func(t *testing.T) {
+
// Setup Jetstream consumer
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
// Channels for event communication
+
eventChan := make(chan *jetstream.JetstreamEvent, 10)
+
errorChan := make(chan error, 1)
+
done := make(chan bool)
+
+
// Start Jetstream consumer in background BEFORE writing to PDS
+
t.Logf("\n๐Ÿ”„ Starting Jetstream consumer for comments...")
+
go func() {
+
subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)
+
if subscribeErr != nil {
+
errorChan <- subscribeErr
+
}
+
}()
+
+
// Give Jetstream a moment to connect
+
time.Sleep(500 * time.Millisecond)
+
+
// Create comment via service (writes to PDS)
+
t.Logf("\n๐Ÿ“ Creating comment via service (writes to PDS)...")
+
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: postURI,
+
CID: postCID,
+
},
+
Parent: comments.StrongRef{
+
URI: postURI,
+
CID: postCID,
+
},
+
},
+
Content: "This is a TRUE E2E test comment via Jetstream!",
+
Langs: []string{"en"},
+
}
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
+
commentResp, err := commentService.CreateComment(ctx, session, commentReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
t.Logf("โœ… Comment written to PDS:")
+
t.Logf(" URI: %s", commentResp.URI)
+
t.Logf(" CID: %s", commentResp.CID)
+
+
// Wait for Jetstream event
+
t.Logf("\nโณ Waiting for Jetstream event (max 30 seconds)...")
+
+
select {
+
case event := <-eventChan:
+
t.Logf("โœ… Received real Jetstream event!")
+
t.Logf(" Event DID: %s", event.Did)
+
t.Logf(" Collection: %s", event.Commit.Collection)
+
t.Logf(" Operation: %s", event.Commit.Operation)
+
t.Logf(" RKey: %s", event.Commit.RKey)
+
+
// Verify it's our comment
+
if event.Did != userDID {
+
t.Errorf("Expected DID %s, got %s", userDID, event.Did)
+
}
+
if event.Commit.Collection != "social.coves.community.comment" {
+
t.Errorf("Expected collection social.coves.community.comment, got %s", event.Commit.Collection)
+
}
+
if event.Commit.Operation != "create" {
+
t.Errorf("Expected operation create, got %s", event.Commit.Operation)
+
}
+
+
// Verify indexed in AppView database
+
t.Logf("\n๐Ÿ” Querying AppView database...")
+
indexedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Comment not indexed in AppView: %v", err)
+
}
+
+
t.Logf("โœ… Comment indexed in AppView:")
+
t.Logf(" CommenterDID: %s", indexedComment.CommenterDID)
+
t.Logf(" Content: %s", indexedComment.Content)
+
t.Logf(" RootURI: %s", indexedComment.RootURI)
+
t.Logf(" ParentURI: %s", indexedComment.ParentURI)
+
+
// Verify comment details
+
if indexedComment.CommenterDID != userDID {
+
t.Errorf("Expected commenter_did %s, got %s", userDID, indexedComment.CommenterDID)
+
}
+
if indexedComment.Content != "This is a TRUE E2E test comment via Jetstream!" {
+
t.Errorf("Expected content mismatch, got %s", indexedComment.Content)
+
}
+
+
close(done)
+
+
case err := <-errorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout: No Jetstream event received within 30 seconds")
+
}
+
+
t.Logf("\nโœ… TRUE E2E COMMENT CREATE FLOW COMPLETE:")
+
t.Logf(" Client โ†’ Service โ†’ PDS โ†’ Jetstream โ†’ Consumer โ†’ AppView โœ“")
+
})
+
}
+
+
// TestCommentE2E_UpdateWithJetstream tests comment update with real Jetstream indexing
+
func TestCommentE2E_UpdateWithJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
// Check if PDS is running
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
// Check if Jetstream is running
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
pdsHostname = strings.Split(pdsHostname, ":")[0]
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)
+
+
testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
t.Skipf("Jetstream not running at %s: %v", jetstreamURL, err)
+
}
+
_ = testConn.Close()
+
+
ctx := context.Background()
+
+
// Setup repositories
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create test user on PDS
+
testUserHandle := fmt.Sprintf("cmtup%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("cmtup%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-upd-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Update", 0, time.Now())
+
postCID := "bafypostupdate"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
t.Run("update comment with real Jetstream indexing", func(t *testing.T) {
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
// First, create a comment and wait for it to be indexed
+
eventChan := make(chan *jetstream.JetstreamEvent, 10)
+
errorChan := make(chan error, 1)
+
done := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)
+
if subscribeErr != nil {
+
errorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
// Create initial comment
+
t.Logf("\n๐Ÿ“ Creating initial comment...")
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "Original comment content",
+
Langs: []string{"en"},
+
}
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
commentResp, err := commentService.CreateComment(ctx, session, commentReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
// Wait for create event
+
select {
+
case <-eventChan:
+
t.Logf("โœ… Create event received and indexed")
+
case err := <-errorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout waiting for create event")
+
}
+
close(done)
+
+
// Now update the comment
+
t.Logf("\n๐Ÿ“ Updating comment via service...")
+
+
// Start new Jetstream subscription for update event
+
updateEventChan := make(chan *jetstream.JetstreamEvent, 10)
+
updateErrorChan := make(chan error, 1)
+
updateDone := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForCommentUpdate(ctx, jetstreamURL, userDID, commentConsumer, updateEventChan, updateDone)
+
if subscribeErr != nil {
+
updateErrorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
// Get existing comment CID from PDS for optimistic locking
+
rkey := utils.ExtractRKeyFromURI(commentResp.URI)
+
pdsResp, httpErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",
+
pdsURL, userDID, rkey))
+
if httpErr != nil {
+
t.Fatalf("Failed to get record from PDS: %v", httpErr)
+
}
+
defer func() { _ = pdsResp.Body.Close() }()
+
if pdsResp.StatusCode != http.StatusOK {
+
body, _ := io.ReadAll(pdsResp.Body)
+
t.Fatalf("Failed to get record from PDS: status=%d body=%s", pdsResp.StatusCode, string(body))
+
}
+
var pdsRecord struct {
+
CID string `json:"cid"`
+
}
+
if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil {
+
t.Fatalf("Failed to decode PDS response: %v", decodeErr)
+
}
+
+
updateReq := comments.UpdateCommentRequest{
+
URI: commentResp.URI,
+
Content: "Updated comment content via E2E test!",
+
Langs: []string{"en"},
+
}
+
+
updatedComment, err := commentService.UpdateComment(ctx, session, updateReq)
+
if err != nil {
+
t.Fatalf("Failed to update comment: %v", err)
+
}
+
+
t.Logf("โœ… Comment updated on PDS:")
+
t.Logf(" URI: %s", updatedComment.URI)
+
t.Logf(" CID: %s", updatedComment.CID)
+
+
// Wait for update event from Jetstream
+
t.Logf("\nโณ Waiting for update event from Jetstream...")
+
+
select {
+
case event := <-updateEventChan:
+
t.Logf("โœ… Received update event from Jetstream!")
+
t.Logf(" Operation: %s", event.Commit.Operation)
+
+
if event.Commit.Operation != "update" {
+
t.Errorf("Expected operation 'update', got '%s'", event.Commit.Operation)
+
}
+
+
// Verify updated content in AppView
+
indexedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Failed to get updated comment: %v", err)
+
}
+
+
if indexedComment.Content != "Updated comment content via E2E test!" {
+
t.Errorf("Expected updated content, got: %s", indexedComment.Content)
+
}
+
+
t.Logf("โœ… Comment updated in AppView:")
+
t.Logf(" Content: %s", indexedComment.Content)
+
+
close(updateDone)
+
+
case err := <-updateErrorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout: No update event received within 30 seconds")
+
}
+
+
t.Logf("\nโœ… TRUE E2E COMMENT UPDATE FLOW COMPLETE:")
+
t.Logf(" Client โ†’ Service โ†’ PDS PutRecord โ†’ Jetstream โ†’ Consumer โ†’ AppView โœ“")
+
})
+
}
+
+
// TestCommentE2E_DeleteWithJetstream tests comment deletion with real Jetstream indexing
+
func TestCommentE2E_DeleteWithJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
pdsHostname = strings.Split(pdsHostname, ":")[0]
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)
+
+
testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
t.Skipf("Jetstream not running at %s: %v", jetstreamURL, err)
+
}
+
_ = testConn.Close()
+
+
ctx := context.Background()
+
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
testUserHandle := fmt.Sprintf("cmtdl%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("cmtdl%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-del-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Delete", 0, time.Now())
+
postCID := "bafypostdelete"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
t.Run("delete comment with real Jetstream indexing", func(t *testing.T) {
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
// First, create a comment
+
eventChan := make(chan *jetstream.JetstreamEvent, 10)
+
errorChan := make(chan error, 1)
+
done := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)
+
if subscribeErr != nil {
+
errorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
t.Logf("\n๐Ÿ“ Creating comment to delete...")
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "This comment will be deleted",
+
Langs: []string{"en"},
+
}
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
commentResp, err := commentService.CreateComment(ctx, session, commentReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
// Wait for create event
+
select {
+
case <-eventChan:
+
t.Logf("โœ… Create event received")
+
case err := <-errorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout waiting for create event")
+
}
+
close(done)
+
+
// Verify comment exists
+
_, err = commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Comment should exist before delete: %v", err)
+
}
+
+
// Now delete the comment
+
t.Logf("\n๐Ÿ—‘๏ธ Deleting comment via service...")
+
+
deleteEventChan := make(chan *jetstream.JetstreamEvent, 10)
+
deleteErrorChan := make(chan error, 1)
+
deleteDone := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForCommentDelete(ctx, jetstreamURL, userDID, commentConsumer, deleteEventChan, deleteDone)
+
if subscribeErr != nil {
+
deleteErrorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
err = commentService.DeleteComment(ctx, session, comments.DeleteCommentRequest{URI: commentResp.URI})
+
if err != nil {
+
t.Fatalf("Failed to delete comment: %v", err)
+
}
+
+
t.Logf("โœ… Comment delete request sent to PDS")
+
+
// Wait for delete event from Jetstream
+
t.Logf("\nโณ Waiting for delete event from Jetstream...")
+
+
select {
+
case event := <-deleteEventChan:
+
t.Logf("โœ… Received delete event from Jetstream!")
+
t.Logf(" Operation: %s", event.Commit.Operation)
+
+
if event.Commit.Operation != "delete" {
+
t.Errorf("Expected operation 'delete', got '%s'", event.Commit.Operation)
+
}
+
+
// Verify comment is soft-deleted in AppView
+
deletedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Failed to get deleted comment: %v", err)
+
}
+
+
if deletedComment.DeletedAt == nil {
+
t.Errorf("Expected comment to be soft-deleted (deleted_at should be set)")
+
} else {
+
t.Logf("โœ… Comment soft-deleted in AppView at: %v", *deletedComment.DeletedAt)
+
}
+
+
close(deleteDone)
+
+
case err := <-deleteErrorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout: No delete event received within 30 seconds")
+
}
+
+
t.Logf("\nโœ… TRUE E2E COMMENT DELETE FLOW COMPLETE:")
+
t.Logf(" Client โ†’ Service โ†’ PDS DeleteRecord โ†’ Jetstream โ†’ Consumer โ†’ AppView โœ“")
+
})
+
}
+
+
// subscribeToJetstreamForComment subscribes to real Jetstream firehose for comment create events
+
func subscribeToJetstreamForComment(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
consumer *jetstream.CommentEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
done <-chan bool,
+
) error {
+
conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() { _ = conn.Close() }()
+
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+
return fmt.Errorf("failed to set read deadline: %w", err)
+
}
+
+
var event jetstream.JetstreamEvent
+
err := conn.ReadJSON(&event)
+
if err != nil {
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue
+
}
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
// Check if this is a comment create event for the target DID
+
if event.Did == targetDID && event.Kind == "commit" &&
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.comment" &&
+
event.Commit.Operation == "create" {
+
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+
+
// subscribeToJetstreamForCommentUpdate subscribes for comment update events
+
func subscribeToJetstreamForCommentUpdate(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
consumer *jetstream.CommentEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
done <-chan bool,
+
) error {
+
conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() { _ = conn.Close() }()
+
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+
return fmt.Errorf("failed to set read deadline: %w", err)
+
}
+
+
var event jetstream.JetstreamEvent
+
err := conn.ReadJSON(&event)
+
if err != nil {
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue
+
}
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
if event.Did == targetDID && event.Kind == "commit" &&
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.comment" &&
+
event.Commit.Operation == "update" {
+
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+
+
// subscribeToJetstreamForCommentDelete subscribes for comment delete events
+
func subscribeToJetstreamForCommentDelete(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
consumer *jetstream.CommentEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
done <-chan bool,
+
) error {
+
conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() { _ = conn.Close() }()
+
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+
return fmt.Errorf("failed to set read deadline: %w", err)
+
}
+
+
var event jetstream.JetstreamEvent
+
err := conn.ReadJSON(&event)
+
if err != nil {
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue
+
}
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
if event.Did == targetDID && event.Kind == "commit" &&
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.comment" &&
+
event.Commit.Operation == "delete" {
+
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+
+
// TestCommentE2E_Authorization tests that users cannot modify other users' comments
+
func TestCommentE2E_Authorization(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
ctx := context.Background()
+
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create two test users on PDS
+
userAHandle := fmt.Sprintf("usera%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
userAEmail := fmt.Sprintf("usera%d@test.local", time.Now().UnixNano()%1000000)
+
userAPassword := "test-password-123"
+
+
userBHandle := fmt.Sprintf("userb%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
userBEmail := fmt.Sprintf("userb%d@test.local", time.Now().UnixNano()%1000000)
+
userBPassword := "test-password-123"
+
+
pdsAccessTokenA, userADID, err := createPDSAccount(pdsURL, userAHandle, userAEmail, userAPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user A on PDS: %v", err)
+
}
+
+
pdsAccessTokenB, userBDID, err := createPDSAccount(pdsURL, userBHandle, userBEmail, userBPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user B on PDS: %v", err)
+
}
+
+
testUserA := createTestUser(t, db, userAHandle, userADID)
+
_ = createTestUser(t, db, userBHandle, userBDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "auth-test-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUserA.DID, "Auth Test Post", 0, time.Now())
+
postCID := "bafypostauthtest"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userADID, "session-"+userADID, pdsAccessTokenA, pdsURL)
+
mockStore.AddSessionWithPDS(userBDID, "session-"+userBDID, pdsAccessTokenB, pdsURL)
+
+
t.Run("user cannot update another user's comment", func(t *testing.T) {
+
// User A creates a comment
+
parsedDIDA, parseErr := syntax.ParseDID(userADID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID A: %v", parseErr)
+
}
+
sessionA, sessionErr := mockStore.GetSession(ctx, parsedDIDA, "session-"+userADID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session A: %v", sessionErr)
+
}
+
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "User A's comment",
+
Langs: []string{"en"},
+
}
+
+
commentResp, err := commentService.CreateComment(ctx, sessionA, commentReq)
+
if err != nil {
+
t.Fatalf("User A failed to create comment: %v", err)
+
}
+
t.Logf("User A created comment: %s", commentResp.URI)
+
+
// User B tries to update User A's comment
+
parsedDIDB, parseErr := syntax.ParseDID(userBDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID B: %v", parseErr)
+
}
+
sessionB, sessionErr := mockStore.GetSession(ctx, parsedDIDB, "session-"+userBDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session B: %v", sessionErr)
+
}
+
+
updateReq := comments.UpdateCommentRequest{
+
URI: commentResp.URI,
+
Content: "User B trying to update User A's comment",
+
Langs: []string{"en"},
+
}
+
+
_, err = commentService.UpdateComment(ctx, sessionB, updateReq)
+
if err == nil {
+
t.Errorf("Expected error when User B tries to update User A's comment, got nil")
+
} else if err != comments.ErrNotAuthorized {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: User B cannot update User A's comment")
+
}
+
})
+
+
t.Run("user cannot delete another user's comment", func(t *testing.T) {
+
// User A creates a comment
+
parsedDIDA, parseErr := syntax.ParseDID(userADID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID A: %v", parseErr)
+
}
+
sessionA, sessionErr := mockStore.GetSession(ctx, parsedDIDA, "session-"+userADID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session A: %v", sessionErr)
+
}
+
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "User A's comment for delete test",
+
Langs: []string{"en"},
+
}
+
+
commentResp, err := commentService.CreateComment(ctx, sessionA, commentReq)
+
if err != nil {
+
t.Fatalf("User A failed to create comment: %v", err)
+
}
+
t.Logf("User A created comment: %s", commentResp.URI)
+
+
// User B tries to delete User A's comment
+
parsedDIDB, parseErr := syntax.ParseDID(userBDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID B: %v", parseErr)
+
}
+
sessionB, sessionErr := mockStore.GetSession(ctx, parsedDIDB, "session-"+userBDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session B: %v", sessionErr)
+
}
+
+
deleteReq := comments.DeleteCommentRequest{
+
URI: commentResp.URI,
+
}
+
+
err = commentService.DeleteComment(ctx, sessionB, deleteReq)
+
if err == nil {
+
t.Errorf("Expected error when User B tries to delete User A's comment, got nil")
+
} else if err != comments.ErrNotAuthorized {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: User B cannot delete User A's comment")
+
}
+
})
+
}
+
+
// TestCommentE2E_ValidationErrors tests that validation errors are properly returned
+
func TestCommentE2E_ValidationErrors(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
ctx := context.Background()
+
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create test user on PDS
+
testUserHandle := fmt.Sprintf("valtest%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("valtest%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "val-test-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Validation Test Post", 0, time.Now())
+
postCID := "bafypostvaltest"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
+
t.Run("empty content returns ErrContentEmpty", func(t *testing.T) {
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "",
+
Langs: []string{"en"},
+
}
+
+
_, err := commentService.CreateComment(ctx, session, commentReq)
+
if err == nil {
+
t.Errorf("Expected error for empty content, got nil")
+
} else if err != comments.ErrContentEmpty {
+
t.Errorf("Expected ErrContentEmpty, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: empty content returns ErrContentEmpty")
+
}
+
})
+
+
t.Run("whitespace-only content returns ErrContentEmpty", func(t *testing.T) {
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: " \t\n ",
+
Langs: []string{"en"},
+
}
+
+
_, err := commentService.CreateComment(ctx, session, commentReq)
+
if err == nil {
+
t.Errorf("Expected error for whitespace-only content, got nil")
+
} else if err != comments.ErrContentEmpty {
+
t.Errorf("Expected ErrContentEmpty, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: whitespace-only content returns ErrContentEmpty")
+
}
+
})
+
+
t.Run("invalid reply reference returns ErrInvalidReply", func(t *testing.T) {
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: "", CID: ""},
+
Parent: comments.StrongRef{URI: "", CID: ""},
+
},
+
Content: "Valid content",
+
Langs: []string{"en"},
+
}
+
+
_, err := commentService.CreateComment(ctx, session, commentReq)
+
if err == nil {
+
t.Errorf("Expected error for invalid reply, got nil")
+
} else if err != comments.ErrInvalidReply {
+
t.Errorf("Expected ErrInvalidReply, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: invalid reply returns ErrInvalidReply")
+
}
+
})
+
}
+
+381
tests/integration/community_update_e2e_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
+
"context"
+
"database/sql"
+
"fmt"
+
"net"
+
"net/http"
+
"os"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/gorilla/websocket"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
)
+
+
// TestCommunityUpdateE2E_WithJetstream tests the FULL community update flow with REAL Jetstream
+
// Flow: Service.UpdateCommunity() โ†’ PDS putRecord โ†’ REAL Jetstream Firehose โ†’ Consumer โ†’ AppView DB
+
//
+
// This is a TRUE E2E test - no simulated Jetstream events!
+
func TestCommunityUpdateE2E_WithJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
// Check if PDS is running
+
pdsURL := os.Getenv("PDS_URL")
+
if pdsURL == "" {
+
pdsURL = "http://localhost:3001"
+
}
+
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
// Check if Jetstream is running
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
pdsHostname = strings.Split(pdsHostname, ":")[0]
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname)
+
+
testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
t.Skipf("Jetstream not running at %s: %v. Run 'docker-compose --profile jetstream up' to start.", jetstreamURL, err)
+
}
+
_ = testConn.Close()
+
+
ctx := context.Background()
+
instanceDID := "did:web:coves.social"
+
+
// Setup identity resolver with local PLC
+
plcURL := os.Getenv("PLC_DIRECTORY_URL")
+
if plcURL == "" {
+
plcURL = "http://localhost:3002" // Local PLC directory
+
}
+
identityConfig := identity.DefaultConfig()
+
identityConfig.PLCURL = plcURL
+
identityResolver := identity.NewResolver(db, identityConfig)
+
+
// Setup services
+
communityRepo := postgres.NewCommunityRepository(db)
+
provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL)
+
communityService := communities.NewCommunityService(
+
communityRepo,
+
pdsURL,
+
instanceDID,
+
"coves.social",
+
provisioner,
+
)
+
+
consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver)
+
+
t.Run("update community with real Jetstream indexing", func(t *testing.T) {
+
// First, create a community
+
uniqueName := fmt.Sprintf("upd%d", time.Now().UnixNano()%1000000)
+
creatorDID := "did:plc:jetstream-update-test"
+
+
t.Logf("\n๐Ÿ“ Creating community on PDS...")
+
community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{
+
Name: uniqueName,
+
DisplayName: "Original Display Name",
+
Description: "Original description before update",
+
Visibility: "public",
+
CreatedByDID: creatorDID,
+
HostedByDID: instanceDID,
+
AllowExternalDiscovery: true,
+
})
+
if err != nil {
+
t.Fatalf("Failed to create community: %v", err)
+
}
+
+
t.Logf("โœ… Community created on PDS:")
+
t.Logf(" DID: %s", community.DID)
+
t.Logf(" RecordCID: %s", community.RecordCID)
+
+
// Verify community is indexed (the service indexes it synchronously on create)
+
t.Logf("\n๐Ÿ”„ Checking community is indexed...")
+
indexed, err := communityService.GetCommunity(ctx, community.DID)
+
if err != nil {
+
t.Fatalf("Community not indexed: %v", err)
+
}
+
t.Logf("โœ… Community indexed in AppView: %s", indexed.DisplayName)
+
+
// Now update the community
+
t.Logf("\n๐Ÿ“ Updating community via service...")
+
+
// Start Jetstream subscription for update event BEFORE calling update
+
updateEventChan := make(chan *jetstream.JetstreamEvent, 10)
+
updateErrorChan := make(chan error, 1)
+
updateDone := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForCommunityEvent(ctx, jetstreamURL, community.DID, "update", consumer, updateEventChan, updateDone)
+
if subscribeErr != nil {
+
updateErrorChan <- subscribeErr
+
}
+
}()
+
+
// Give Jetstream a moment to connect
+
time.Sleep(500 * time.Millisecond)
+
+
// Perform the update
+
newDisplayName := "Updated via TRUE E2E Test!"
+
newDescription := "This description was updated and indexed via real Jetstream firehose"
+
newVisibility := "unlisted"
+
+
updated, err := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{
+
CommunityDID: community.DID,
+
UpdatedByDID: creatorDID,
+
DisplayName: &newDisplayName,
+
Description: &newDescription,
+
Visibility: &newVisibility,
+
AllowExternalDiscovery: nil,
+
})
+
if err != nil {
+
t.Fatalf("Failed to update community: %v", err)
+
}
+
+
t.Logf("โœ… Community update written to PDS:")
+
t.Logf(" New RecordCID: %s (was: %s)", updated.RecordCID, community.RecordCID)
+
+
// Wait for update event from real Jetstream
+
t.Logf("\nโณ Waiting for update event from Jetstream (max 30 seconds)...")
+
+
select {
+
case event := <-updateEventChan:
+
t.Logf("โœ… Received REAL update event from Jetstream!")
+
t.Logf(" Event DID: %s", event.Did)
+
t.Logf(" Collection: %s", event.Commit.Collection)
+
t.Logf(" Operation: %s", event.Commit.Operation)
+
t.Logf(" RKey: %s", event.Commit.RKey)
+
+
// Verify operation type
+
if event.Commit.Operation != "update" {
+
t.Errorf("Expected operation 'update', got '%s'", event.Commit.Operation)
+
}
+
+
// Verify the update was indexed in AppView
+
t.Logf("\n๐Ÿ” Verifying update indexed in AppView...")
+
indexedUpdated, err := communityService.GetCommunity(ctx, community.DID)
+
if err != nil {
+
t.Fatalf("Failed to get updated community: %v", err)
+
}
+
+
t.Logf("โœ… Update indexed in AppView:")
+
t.Logf(" DisplayName: %s", indexedUpdated.DisplayName)
+
t.Logf(" Description: %s", indexedUpdated.Description)
+
t.Logf(" Visibility: %s", indexedUpdated.Visibility)
+
+
// Verify the changes
+
if indexedUpdated.DisplayName != newDisplayName {
+
t.Errorf("Expected display name '%s', got '%s'", newDisplayName, indexedUpdated.DisplayName)
+
}
+
if indexedUpdated.Description != newDescription {
+
t.Errorf("Expected description '%s', got '%s'", newDescription, indexedUpdated.Description)
+
}
+
if indexedUpdated.Visibility != newVisibility {
+
t.Errorf("Expected visibility '%s', got '%s'", newVisibility, indexedUpdated.Visibility)
+
}
+
+
close(updateDone)
+
+
case err := <-updateErrorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout: No update event received from Jetstream within 30 seconds")
+
}
+
+
t.Logf("\nโœ… TRUE E2E COMMUNITY UPDATE FLOW COMPLETE:")
+
t.Logf(" Service โ†’ PDS putRecord โ†’ Jetstream Firehose โ†’ Consumer โ†’ AppView โœ“")
+
})
+
+
t.Run("multiple updates with real Jetstream", func(t *testing.T) {
+
// This tests that consecutive updates all flow through Jetstream correctly
+
uniqueName := fmt.Sprintf("multi%d", time.Now().UnixNano()%1000000)
+
creatorDID := "did:plc:multi-update-test"
+
+
t.Logf("\n๐Ÿ“ Creating community for multi-update test...")
+
community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{
+
Name: uniqueName,
+
DisplayName: "Multi-Update Test",
+
Description: "Testing multiple updates",
+
Visibility: "public",
+
CreatedByDID: creatorDID,
+
HostedByDID: instanceDID,
+
AllowExternalDiscovery: true,
+
})
+
if err != nil {
+
t.Fatalf("Failed to create community: %v", err)
+
}
+
+
// Verify create is indexed (service indexes synchronously on create)
+
indexed, err := communityService.GetCommunity(ctx, community.DID)
+
if err != nil {
+
t.Fatalf("Community not indexed after create: %v", err)
+
}
+
t.Logf("โœ… Create indexed: %s", indexed.DisplayName)
+
+
// Perform 3 consecutive updates
+
for i := 1; i <= 3; i++ {
+
t.Logf("\n๐Ÿ“ Update %d of 3...", i)
+
+
updateEventChan := make(chan *jetstream.JetstreamEvent, 10)
+
updateErrorChan := make(chan error, 1)
+
updateDone := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForCommunityEvent(ctx, jetstreamURL, community.DID, "update", consumer, updateEventChan, updateDone)
+
if subscribeErr != nil {
+
updateErrorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(300 * time.Millisecond)
+
+
newDesc := fmt.Sprintf("Update #%d at %s", i, time.Now().Format(time.RFC3339))
+
_, err := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{
+
CommunityDID: community.DID,
+
UpdatedByDID: creatorDID,
+
Description: &newDesc,
+
})
+
if err != nil {
+
t.Fatalf("Update %d failed: %v", i, err)
+
}
+
+
select {
+
case event := <-updateEventChan:
+
if event.Commit.Operation != "update" {
+
t.Errorf("Expected update operation, got %s", event.Commit.Operation)
+
}
+
t.Logf("โœ… Update %d received via Jetstream", i)
+
case err := <-updateErrorChan:
+
t.Fatalf("Jetstream error on update %d: %v", i, err)
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout on update %d", i)
+
}
+
close(updateDone)
+
+
// Verify in AppView
+
indexed, getErr := communityService.GetCommunity(ctx, community.DID)
+
if getErr != nil {
+
t.Fatalf("Update %d: failed to get community: %v", i, getErr)
+
}
+
if indexed.Description != newDesc {
+
t.Errorf("Update %d: expected description '%s', got '%s'", i, newDesc, indexed.Description)
+
}
+
}
+
+
t.Logf("\nโœ… MULTIPLE UPDATES TEST COMPLETE:")
+
t.Logf(" 3 consecutive updates all indexed via real Jetstream โœ“")
+
})
+
}
+
+
// subscribeToJetstreamForCommunityEvent subscribes to real Jetstream for specific community events
+
func subscribeToJetstreamForCommunityEvent(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
operation string, // "create", "update", or "delete"
+
consumer *jetstream.CommunityEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
done <-chan bool,
+
) (returnErr error) {
+
// Recover from websocket panics during shutdown
+
defer func() {
+
if r := recover(); r != nil {
+
// Panic during shutdown is expected, return nil
+
returnErr = nil
+
}
+
}()
+
+
conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() { _ = conn.Close() }()
+
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+
return fmt.Errorf("failed to set read deadline: %w", err)
+
}
+
+
var event jetstream.JetstreamEvent
+
err := conn.ReadJSON(&event)
+
if err != nil {
+
// Check done channel first to handle clean shutdown
+
select {
+
case <-done:
+
return nil
+
default:
+
}
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue
+
}
+
// Check for connection closed errors (happens during shutdown)
+
if strings.Contains(err.Error(), "use of closed network connection") ||
+
strings.Contains(err.Error(), "failed websocket connection") ||
+
strings.Contains(err.Error(), "repeated read on failed websocket") {
+
return nil
+
}
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
// Check if this is the event we're looking for
+
if event.Did == targetDID && event.Kind == "commit" &&
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.profile" &&
+
event.Commit.Operation == operation {
+
+
// Process through consumer to index in AppView
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+1 -1
internal/api/handlers/community/create_test.go
···
}
return &communities.Community{
DID: "did:plc:test123",
-
Handle: "test.community.coves.social",
+
Handle: "c-test.coves.social",
RecordURI: "at://did:plc:test123/social.coves.community.profile/self",
RecordCID: "bafytest123",
DisplayName: req.DisplayName,
+43 -12
internal/atproto/jetstream/community_consumer.go
···
cache, err := lru.New[string, cachedDIDDoc](1000)
if err != nil {
// This should never happen with a valid size, but handle gracefully
-
log.Printf("WARNING: Failed to create DID cache, verification will be slower: %v", err)
+
log.Printf("WARNING: Failed to create DID cache (size=1000), verification will be slower: %v", err)
// Create minimal cache to avoid nil pointer
-
cache, _ = lru.New[string, cachedDIDDoc](1)
+
cache, fallbackErr := lru.New[string, cachedDIDDoc](1)
+
if fallbackErr != nil {
+
// Both attempts failed - this indicates a serious issue with the LRU library
+
log.Printf("CRITICAL: Failed to create fallback DID cache (size=1): %v", fallbackErr)
+
panic(fmt.Sprintf("cannot create LRU cache: primary error=%v, fallback error=%v", err, fallbackErr))
+
}
+
return &CommunityEventConsumer{
+
repo: repo,
+
identityResolver: identityResolver,
+
instanceDID: instanceDID,
+
skipVerification: skipVerification,
+
httpClient: &http.Client{
+
Timeout: 10 * time.Second,
+
Transport: &http.Transport{
+
MaxIdleConns: 100,
+
MaxIdleConnsPerHost: 10,
+
IdleConnTimeout: 90 * time.Second,
+
},
+
},
+
didCache: cache,
+
wellKnownLimiter: rate.NewLimiter(10, 20),
+
}
}
return &CommunityEventConsumer{
···
// Handle description facets (rich text)
if profile.DescriptionFacets != nil {
facetsJSON, marshalErr := json.Marshal(profile.DescriptionFacets)
-
if marshalErr == nil {
+
if marshalErr != nil {
+
log.Printf("WARNING: Failed to marshal description facets for community %s: %v (facets will be omitted)", did, marshalErr)
+
} else {
community.DescriptionFacets = facetsJSON
}
}
···
// extractDomainFromHandle extracts the registrable domain from a community handle
// Handles both formats:
// - Bluesky-style: "!gaming@coves.social" โ†’ "coves.social"
-
// - DNS-style: "gaming.community.coves.social" โ†’ "coves.social"
+
// - DNS-style: "c-gaming.coves.social" โ†’ "coves.social"
//
// Uses golang.org/x/net/publicsuffix to correctly handle multi-part TLDs:
-
// - "gaming.community.coves.co.uk" โ†’ "coves.co.uk" (not "co.uk")
-
// - "gaming.community.example.com.au" โ†’ "example.com.au" (not "com.au")
+
// - "c-gaming.coves.co.uk" โ†’ "coves.co.uk" (not "co.uk")
+
// - "c-gaming.example.com.au" โ†’ "example.com.au" (not "com.au")
func extractDomainFromHandle(handle string) string {
// Remove leading ! if present
handle = strings.TrimPrefix(handle, "!")
···
if err != nil {
// If publicsuffix fails, fall back to returning the full domain part
// This handles edge cases like localhost, IP addresses, etc.
+
log.Printf("DEBUG: publicsuffix failed for @-format handle domain %q, using raw domain: %v", domain, err)
return domain
}
return registrable
···
return ""
}
-
// For DNS-style handles (e.g., "gaming.community.coves.social")
+
// For DNS-style handles (e.g., "c-gaming.coves.social")
// Extract the registrable domain (eTLD+1) using publicsuffix
// This correctly handles multi-part TLDs like .co.uk, .com.au, etc.
registrable, err := publicsuffix.EffectiveTLDPlusOne(handle)
if err != nil {
// If publicsuffix fails (e.g., invalid TLD, localhost, IP address)
// fall back to naive extraction (last 2 parts)
-
// This maintains backward compatibility for edge cases
+
// WARNING: This is incorrect for multi-part TLDs (.co.uk -> would return "co.uk")
+
// but maintains compatibility for localhost/dev environments
parts := strings.Split(handle, ".")
if len(parts) < 2 {
+
log.Printf("DEBUG: Invalid handle format (no dots): %q", handle)
return "" // Invalid handle
}
-
return strings.Join(parts[len(parts)-2:], ".")
+
fallbackDomain := strings.Join(parts[len(parts)-2:], ".")
+
log.Printf("DEBUG: publicsuffix failed for handle %q, using naive fallback: %q (error: %v)", handle, fallbackDomain, err)
+
return fallbackDomain
}
return registrable
···
}
// constructHandleFromProfile constructs a deterministic handle from profile data
-
// Format: {name}.community.{instanceDomain}
-
// Example: gaming.community.coves.social
+
// Format: c-{name}.{instanceDomain}
+
// Example: c-gaming.coves.social
// This is ONLY used in test mode (when identity resolver is nil)
// Production MUST resolve handles from PLC (source of truth)
// Returns empty string if hostedBy is not did:web format (caller will fail validation)
func constructHandleFromProfile(profile *CommunityProfile) string {
if !strings.HasPrefix(profile.HostedBy, "did:web:") {
// hostedBy must be did:web format for handle construction
+
// Log warning since this indicates invalid community data
+
log.Printf("WARNING: constructHandleFromProfile: hostedBy %q is not did:web format, cannot construct handle for community %q",
+
profile.HostedBy, profile.Name)
// Return empty to trigger validation error in repository
return ""
}
instanceDomain := strings.TrimPrefix(profile.HostedBy, "did:web:")
-
return fmt.Sprintf("%s.community.%s", profile.Name, instanceDomain)
+
return fmt.Sprintf("c-%s.%s", profile.Name, instanceDomain)
}
// extractContentVisibility extracts contentVisibility from subscription record with clamping
+6 -6
internal/core/comments/comment_service_test.go
···
author := createTestUser(authorDID, "author.test")
_, _ = userRepo.Create(context.Background(), author)
-
community := createTestCommunity(communityDID, "test.community.coves.social")
+
community := createTestCommunity(communityDID, "c-test.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
···
author := createTestUser(authorDID, "author.test")
_, _ = userRepo.Create(context.Background(), author)
-
community := createTestCommunity(communityDID, "test.community.coves.social")
+
community := createTestCommunity(communityDID, "c-test.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
···
author := createTestUser(authorDID, "author.test")
_, _ = userRepo.Create(context.Background(), author)
-
community := createTestCommunity(communityDID, "test.community.coves.social")
+
community := createTestCommunity(communityDID, "c-test.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
comment1URI := "at://did:plc:commenter123/comment/1"
···
author := createTestUser(authorDID, "author.test")
_, _ = userRepo.Create(context.Background(), author)
-
community := createTestCommunity(communityDID, "test.community.coves.social")
+
community := createTestCommunity(communityDID, "c-test.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
···
author := createTestUser(authorDID, "author.test")
_, _ = userRepo.Create(context.Background(), author)
-
community := createTestCommunity(communityDID, "test.community.coves.social")
+
community := createTestCommunity(communityDID, "c-test.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
···
author := createTestUser(authorDID, "author.test")
_, _ = userRepo.Create(context.Background(), author)
-
community := createTestCommunity(communityDID, "test.community.coves.social")
+
community := createTestCommunity(communityDID, "c-test.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
// Mock repository error
+23 -13
internal/core/communities/community.go
···
import (
"fmt"
+
"log"
"strings"
"time"
)
···
// GetDisplayHandle returns the user-facing display format for a community handle
// Following Bluesky's pattern where client adds @ prefix for users, but for communities we use ! prefix
-
// Example: "gardening.community.coves.social" -> "!gardening@coves.social"
+
// Example: "c-gardening.coves.social" -> "!gardening@coves.social"
//
// Handles various domain formats correctly:
-
// - "gaming.community.coves.social" -> "!gaming@coves.social"
-
// - "gaming.community.coves.co.uk" -> "!gaming@coves.co.uk"
-
// - "test.community.dev.coves.social" -> "!test@dev.coves.social"
+
// - "c-gaming.coves.social" -> "!gaming@coves.social"
+
// - "c-gaming.coves.co.uk" -> "!gaming@coves.co.uk"
+
// - "c-test.dev.coves.social" -> "!test@dev.coves.social"
func (c *Community) GetDisplayHandle() string {
-
// Find the ".community." substring in the handle
-
communityIndex := strings.Index(c.Handle, ".community.")
-
if communityIndex == -1 {
-
// Fallback if format doesn't match expected pattern
+
// Handle format: c-{name}.{instance}
+
if !strings.HasPrefix(c.Handle, "c-") {
+
log.Printf("DEBUG: GetDisplayHandle: handle %q missing c- prefix, returning raw handle", c.Handle)
+
return c.Handle // Fallback for invalid format
+
}
+
+
// Remove "c-" prefix and find first dot
+
afterPrefix := c.Handle[2:]
+
dotIndex := strings.Index(afterPrefix, ".")
+
if dotIndex == -1 {
+
log.Printf("DEBUG: GetDisplayHandle: handle %q has no dot after c- prefix, returning raw handle", c.Handle)
return c.Handle
}
-
// Extract name (everything before ".community.")
-
name := c.Handle[:communityIndex]
+
// Edge case: "c-." would result in empty name
+
if dotIndex == 0 {
+
log.Printf("DEBUG: GetDisplayHandle: handle %q has empty name after c- prefix, returning raw handle", c.Handle)
+
return c.Handle
+
}
-
// Extract instance domain (everything after ".community.")
-
communitySegment := ".community."
-
instanceDomain := c.Handle[communityIndex+len(communitySegment):]
+
name := afterPrefix[:dotIndex]
+
instanceDomain := afterPrefix[dotIndex+1:]
return fmt.Sprintf("!%s@%s", name, instanceDomain)
}
+5 -6
internal/core/communities/pds_provisioning.go
···
}
// 1. Generate unique handle for the community
-
// Format: {name}.community.{instance-domain}
-
// Example: "gaming.community.coves.social"
-
// NOTE: Using SINGULAR "community" to follow atProto lexicon conventions
-
// (all record types use singular: app.bsky.feed.post, app.bsky.graph.follow, etc.)
-
handle := fmt.Sprintf("%s.community.%s", strings.ToLower(communityName), p.instanceDomain)
+
// Format: c-{name}.{instance-domain}
+
// Example: "c-gaming.coves.social"
+
// Uses c- prefix to distinguish from user handles while keeping single-level subdomain
+
handle := fmt.Sprintf("c-%s.%s", strings.ToLower(communityName), p.instanceDomain)
// 2. Generate system email for PDS account management
// This email is used for account operations, not for user communication
-
email := fmt.Sprintf("community-%s@community.%s", strings.ToLower(communityName), p.instanceDomain)
+
email := fmt.Sprintf("c-%s@%s", strings.ToLower(communityName), p.instanceDomain)
// 3. Generate secure random password (32 characters)
// This password is never shown to users - it's for Coves to authenticate as the community
+49
internal/db/migrations/022_migrate_community_handles_to_c_prefix.sql
···
+
-- +goose Up
+
-- +goose StatementBegin
+
+
-- Migration: Change community handles from .community. subdomain to c- prefix
+
-- This simplifies DNS/Caddy configuration (works with *.coves.social wildcard)
+
--
+
-- Examples:
+
-- gardening.community.coves.social -> c-gardening.coves.social
+
-- gaming.community.coves.social -> c-gaming.coves.social
+
--
+
-- Also updates the system email format:
+
-- community-gardening@community.coves.social -> c-gardening@coves.social
+
+
-- Update community handles in the communities table
+
UPDATE communities
+
SET handle = 'c-' || SPLIT_PART(handle, '.community.', 1) || '.' || SPLIT_PART(handle, '.community.', 2)
+
WHERE handle LIKE '%.community.%';
+
+
-- Update email addresses to match new format
+
-- Old: community-{name}@community.{instance}
+
-- New: c-{name}@{instance}
+
UPDATE communities
+
SET pds_email = 'c-' || SUBSTRING(pds_email FROM 11 FOR POSITION('@' IN pds_email) - 11) || '@' || SUBSTRING(pds_email FROM POSITION('@community.' IN pds_email) + 11)
+
WHERE pds_email LIKE 'community-%@community.%';
+
+
-- +goose StatementEnd
+
+
-- +goose Down
+
-- +goose StatementBegin
+
+
-- Rollback: Revert handles from c- prefix back to .community. subdomain
+
-- Parse: c-{name}.{instance} -> {name}.community.{instance}
+
+
UPDATE communities
+
SET handle = SUBSTRING(handle FROM 3 FOR POSITION('.' IN SUBSTRING(handle FROM 3)) - 1)
+
|| '.community.'
+
|| SUBSTRING(handle FROM POSITION('.' IN SUBSTRING(handle FROM 3)) + 3)
+
WHERE handle LIKE 'c-%' AND handle NOT LIKE '%.community.%';
+
+
-- Revert email addresses
+
-- New: c-{name}@{instance}
+
-- Old: community-{name}@community.{instance}
+
UPDATE communities
+
SET pds_email = 'community-' || SUBSTRING(pds_email FROM 3 FOR POSITION('@' IN pds_email) - 3)
+
|| '@community.'
+
|| SUBSTRING(pds_email FROM POSITION('@' IN pds_email) + 1)
+
WHERE pds_email LIKE 'c-%@%' AND pds_email NOT LIKE 'community-%@community.%';
+
+
-- +goose StatementEnd
+2 -2
tests/integration/block_handle_resolution_test.go
···
// To properly test invalid handle โ†’ 404, we'd need to add auth middleware context
// For now, we just verify that the resolution code doesn't crash
reqBody := map[string]string{
-
"community": "nonexistent.community.coves.social",
+
"community": "c-nonexistent.coves.social",
}
reqJSON, _ := json.Marshal(reqBody)
···
t.Run("Unblock with invalid handle", func(t *testing.T) {
// Note: Without auth context, returns 401 before reaching resolution
reqBody := map[string]string{
-
"community": "fake.community.coves.social",
+
"community": "c-fake.coves.social",
}
reqJSON, _ := json.Marshal(reqBody)
+5 -5
tests/integration/community_consumer_test.go
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("test-community-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("update-test-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("delete-test-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("sub-test-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("test-plc-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.social", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.social", communityName)
// Create mock resolver
mockResolver := newMockIdentityResolver()
+5 -5
tests/integration/community_e2e_test.go
···
// Initialize OAuth auth middleware for E2E testing
e2eAuth := NewE2EOAuthMiddleware()
-
// Register the instance user for OAuth authentication
-
token := e2eAuth.AddUser(instanceDID)
+
// Register the instance user with their REAL PDS access token for write-forward operations
+
token := e2eAuth.AddUserWithPDSToken(instanceDID, accessToken, pdsURL)
// V2.0: Extract instance domain for community provisioning
var instanceDomain string
···
// ====================================================================================
t.Run("1. Write-Forward to PDS", func(t *testing.T) {
// Use shorter names to avoid "Handle too long" errors
-
// atProto handles max: 63 chars, format: name.community.coves.social
+
// atProto handles max: 63 chars, format: c-name.coves.social
communityName := fmt.Sprintf("e2e-%d", time.Now().Unix())
createReq := communities.CreateCommunityRequest{
···
// V2: Verify PDS account was created for the community
t.Logf("\n๐Ÿ” V2: Verifying community PDS account exists...")
-
expectedHandle := fmt.Sprintf("%s.community.%s", communityName, instanceDomain)
+
expectedHandle := fmt.Sprintf("c-%s.%s", communityName, instanceDomain)
t.Logf(" Expected handle: %s", expectedHandle)
-
t.Logf(" (Using subdomain: *.community.%s)", instanceDomain)
+
t.Logf(" (Using subdomain: c-*.%s)", instanceDomain)
accountDID, accountHandle, err := queryPDSAccount(pdsURL, expectedHandle)
if err != nil {
+9 -9
tests/integration/community_provisioning_test.go
···
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("test-encryption-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-test-encryption-%s.test.local", uniqueSuffix),
Name: "test-encryption",
DisplayName: "Test Encryption",
Description: "Testing password encryption",
···
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("test-empty-pass-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-test-empty-pass-%s.test.local", uniqueSuffix),
Name: "test-empty-pass",
DisplayName: "Test Empty Password",
Description: "Testing empty password handling",
···
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("pwd-unique-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-pwd-unique-%s.test.local", uniqueSuffix),
Name: fmt.Sprintf("pwd-unique-%s", uniqueSuffix),
DisplayName: fmt.Sprintf("Password Unique Test %d", i),
Description: "Testing password uniqueness",
···
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("test-pwd-len-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-test-pwd-len-%s.test.local", uniqueSuffix),
Name: "test-pwd-len",
DisplayName: "Test Password Length",
Description: "Testing password length requirements",
···
uniqueSuffix := fmt.Sprintf("%d-%d", time.Now().UnixNano(), idx)
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("%s.community.test.local", sameName),
+
Handle: fmt.Sprintf("c-%s.test.local", sameName),
Name: sameName,
DisplayName: "Concurrent Test",
Description: "Testing concurrent creation",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("read-test-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-read-test-%s.test.local", uniqueSuffix),
Name: "read-test",
DisplayName: "Read Test",
Description: "Testing concurrent reads",
···
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("token-test-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-token-test-%s.test.local", uniqueSuffix),
Name: "token-test",
DisplayName: "Token Test",
Description: "Testing token storage",
···
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("empty-token-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-empty-token-%s.test.local", uniqueSuffix),
Name: "empty-token",
DisplayName: "Empty Token Test",
Description: "Testing empty token handling",
···
community := &communities.Community{
DID: generateTestDID(uniqueSuffix),
-
Handle: fmt.Sprintf("encrypted-token-%s.community.test.local", uniqueSuffix),
+
Handle: fmt.Sprintf("c-encrypted-token-%s.test.local", uniqueSuffix),
Name: "encrypted-token",
DisplayName: "Encrypted Token Test",
Description: "Testing token encryption",
+2 -2
tests/integration/community_service_integration_test.go
···
t.Run("creates community with real PDS provisioning", func(t *testing.T) {
// Create provisioner and service (production code path)
-
// Use coves.social domain (configured in PDS_SERVICE_HANDLE_DOMAINS as .community.coves.social)
+
// Use coves.social domain (configured in PDS_SERVICE_HANDLE_DOMAINS as c-{name}.coves.social)
provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL)
service := communities.NewCommunityService(
repo,
···
t.Logf("โœ… Real DID generated: %s", community.DID)
// Verify handle format
-
expectedHandle := fmt.Sprintf("%s.community.coves.social", uniqueName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.social", uniqueName)
if community.Handle != expectedHandle {
t.Errorf("Expected handle %s, got %s", expectedHandle, community.Handle)
}
+8 -8
tests/integration/community_v2_validation_test.go
···
// Use unique DID and handle to avoid conflicts with other test runs
timestamp := time.Now().UnixNano()
testDID := fmt.Sprintf("did:plc:testv2rkey%d", timestamp)
-
testHandle := fmt.Sprintf("testv2rkey%d.community.coves.social", timestamp)
+
testHandle := fmt.Sprintf("c-testv2rkey%d.coves.social", timestamp)
event := &jetstream.JetstreamEvent{
Did: testDID,
···
CID: "bafyreiv1community",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "v1community.community.coves.social",
+
"handle": "c-v1community.coves.social",
"name": "v1community",
"createdBy": "did:plc:user456",
"hostedBy": "did:web:coves.social",
···
CID: "bafyreicustom",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "custom.community.coves.social",
+
"handle": "c-custom.coves.social",
"name": "custom",
"createdBy": "did:plc:user789",
"hostedBy": "did:web:coves.social",
···
CID: "bafyreiupdate1",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "updatetest.community.coves.social",
+
"handle": "c-updatetest.coves.social",
"name": "updatetest",
"createdBy": "did:plc:userUpdate",
"hostedBy": "did:web:coves.social",
···
CID: "bafyreiupdate2",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "updatetest.community.coves.social",
+
"handle": "c-updatetest.coves.social",
"name": "updatetest",
"displayName": "Updated Name",
"createdBy": "did:plc:userUpdate",
···
CID: "bafyreihandle",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "gamingtest.community.coves.social", // atProto handle (DNS-resolvable)
+
"handle": "c-gamingtest.coves.social", // atProto handle (DNS-resolvable)
"name": "gamingtest", // Short name for !mentions
"createdBy": "did:plc:user123",
"hostedBy": "did:web:coves.social",
···
}
// Verify the atProto handle is stored
-
if community.Handle != "gamingtest.community.coves.social" {
-
t.Errorf("Expected handle gamingtest.community.coves.social, got %s", community.Handle)
+
if community.Handle != "c-gamingtest.coves.social" {
+
t.Errorf("Expected handle c-gamingtest.coves.social, got %s", community.Handle)
}
// Note: The DID is the authoritative identifier for atProto resolution
+8 -7
tests/integration/post_creation_test.go
···
// Setup: Create test community (insert directly to DB for speed)
testCommunity := &communities.Community{
DID: generateTestDID("testcommunity"),
-
Handle: "testcommunity.community.test.coves.social", // Canonical atProto handle (no ! prefix, .community. format)
+
Handle: "c-testcommunity.test.coves.social", // Canonical atProto handle (no ! prefix, c- format)
Name: "testcommunity",
DisplayName: "Test Community",
Description: "A community for testing posts",
···
t.Run("Create text post with ! prefix handle", func(t *testing.T) {
// Test that we can also use ! prefix with scoped format: !name@instance
-
// This is Coves-specific UX shorthand for name.community.instance
+
// This is Coves-specific UX shorthand for c-name.instance
content := "Post using !-prefixed handle"
title := "Prefixed Handle Test"
-
// Extract name from handle: "gardening.community.coves.social" -> "gardening"
+
// Extract name from handle: "c-gardening.coves.social" -> "gardening"
// Scoped format: !gardening@coves.social
handleParts := strings.Split(testCommunity.Handle, ".")
-
communityName := handleParts[0]
-
instanceDomain := strings.Join(handleParts[2:], ".") // Skip ".community."
+
communityNameWithPrefix := handleParts[0] // "c-gardening"
+
communityName := strings.TrimPrefix(communityNameWithPrefix, "c-") // "gardening"
+
instanceDomain := strings.Join(handleParts[1:], ".") // "coves.social"
scopedHandle := fmt.Sprintf("!%s@%s", communityName, instanceDomain)
req := posts.CreatePostRequest{
···
content := "Post with non-existent handle"
req := posts.CreatePostRequest{
-
Community: "nonexistent.community.test.coves.social", // Valid canonical handle format, but doesn't exist
+
Community: "c-nonexistent.test.coves.social", // Valid canonical handle format, but doesn't exist
Content: &content,
AuthorDID: testUserDID,
}
···
testCommunityDID := generateTestDID("testcommunity2")
_, err = communityRepo.Create(ctx, &communities.Community{
DID: testCommunityDID,
-
Handle: "testcommunity2.community.test.coves.social", // Canonical format (no ! prefix)
+
Handle: "c-testcommunity2.test.coves.social", // Canonical format (no ! prefix)
Name: "testcommunity2",
Visibility: "public",
CreatedByDID: testUserDID,
+7 -7
tests/integration/post_handler_test.go
···
})
t.Run("Accept valid scoped handle format", func(t *testing.T) {
-
// Scoped format: !name@instance (gets converted to name.community.instance internally)
+
// Scoped format: !name@instance (gets converted to c-name.instance internally)
validScopedHandles := []string{
"!mycommunity@bsky.social", // Scoped format
"!gaming@test.coves.social", // Scoped format
···
})
t.Run("Accept valid canonical handle format", func(t *testing.T) {
-
// Canonical format: name.community.instance (DNS-resolvable atProto handle)
+
// Canonical format: c-name.instance (DNS-resolvable atProto handle)
validCanonicalHandles := []string{
-
"gaming.community.test.coves.social",
-
"books.community.bsky.social",
+
"c-gaming.test.coves.social",
+
"c-books.bsky.social",
}
for _, validHandle := range validCanonicalHandles {
···
})
t.Run("Accept valid @-prefixed handle format", func(t *testing.T) {
-
// @-prefixed format: @name.community.instance (atProto standard, @ gets stripped)
+
// @-prefixed format: @c-name.instance (atProto standard, @ gets stripped)
validAtHandles := []string{
-
"@gaming.community.test.coves.social",
-
"@books.community.bsky.social",
+
"@c-gaming.test.coves.social",
+
"@c-books.bsky.social",
}
for _, validHandle := range validAtHandles {
+5 -5
tests/integration/post_unfurl_test.go
···
// Create test community
testCommunity := &communities.Community{
DID: generateTestDID("unfurlcommunity"),
-
Handle: "unfurlcommunity.community.test.coves.social",
+
Handle: "c-unfurlcommunity.test.coves.social",
Name: "unfurlcommunity",
DisplayName: "Unfurl Test Community",
Description: "A community for testing unfurl",
···
// Create test community
testCommunity := &communities.Community{
DID: generateTestDID("unsupportedcommunity"),
-
Handle: "unsupportedcommunity.community.test.coves.social",
+
Handle: "c-unsupportedcommunity.test.coves.social",
Name: "unsupportedcommunity",
DisplayName: "Unsupported URL Test",
Visibility: "public",
···
testCommunity := &communities.Community{
DID: generateTestDID("metadatacommunity"),
-
Handle: "metadatacommunity.community.test.coves.social",
+
Handle: "c-metadatacommunity.test.coves.social",
Name: "metadatacommunity",
DisplayName: "Metadata Test",
Visibility: "public",
···
testCommunity := &communities.Community{
DID: generateTestDID("noembedcommunity"),
-
Handle: "noembedcommunity.community.test.coves.social",
+
Handle: "c-noembedcommunity.test.coves.social",
Name: "noembedcommunity",
DisplayName: "No Embed Test",
Visibility: "public",
···
testCommunityDID := generateTestDID("e2eunfurlcommunity")
community := &communities.Community{
DID: testCommunityDID,
-
Handle: "e2eunfurlcommunity.community.test.coves.social",
+
Handle: "c-e2eunfurlcommunity.test.coves.social",
Name: "e2eunfurlcommunity",
DisplayName: "E2E Unfurl Test",
OwnerDID: testCommunityDID,
+2 -2
tests/integration/token_refresh_test.go
···
// Create a test community first
community := &communities.Community{
DID: "did:plc:test123",
-
Handle: "test.community.coves.social",
+
Handle: "c-test.coves.social",
Name: "test",
OwnerDID: "did:plc:test123",
CreatedByDID: "did:plc:creator",
···
community := &communities.Community{
DID: "did:plc:expiring123",
-
Handle: "expiring.community.coves.social",
+
Handle: "c-expiring.coves.social",
Name: "expiring",
OwnerDID: "did:plc:expiring123",
CreatedByDID: "did:plc:creator",
+1 -1
internal/atproto/lexicon/social/coves/actor/getProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.getprofile",
+
"id": "social.coves.actor.getProfile",
"defs": {
"main": {
"type": "query",
+1 -1
internal/atproto/lexicon/social/coves/actor/updateProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.updateprofile",
+
"id": "social.coves.actor.updateProfile",
"defs": {
"main": {
"type": "procedure",
+2 -1
internal/atproto/oauth/handlers_test.go
···
require.NoError(t, err)
// Validate metadata
-
assert.Equal(t, "https://coves.social", metadata.ClientID)
+
// Per atproto OAuth spec, client_id for public clients is the client metadata URL
+
assert.Equal(t, "https://coves.social/oauth/client-metadata.json", metadata.ClientID)
assert.Contains(t, metadata.RedirectURIs, "https://coves.social/oauth/callback")
assert.Contains(t, metadata.GrantTypes, "authorization_code")
assert.Contains(t, metadata.GrantTypes, "refresh_token")
+2
internal/atproto/oauth/store_test.go
···
func TestPostgresOAuthStore_CleanupExpiredSessions(t *testing.T) {
db := setupTestDB(t)
defer func() { _ = db.Close() }()
+
// Clean up before AND after to ensure test isolation
+
cleanupOAuth(t, db)
defer cleanupOAuth(t, db)
storeInterface := NewPostgresOAuthStore(db, 0) // Use default TTL
+5 -5
internal/db/postgres/vote_repo_test.go
···
err = repo.Delete(ctx, vote.URI)
assert.NoError(t, err)
-
// Verify vote is soft-deleted (still exists but has deleted_at)
-
retrieved, err := repo.GetByURI(ctx, vote.URI)
-
assert.NoError(t, err)
-
assert.NotNil(t, retrieved.DeletedAt, "DeletedAt should be set after deletion")
+
// Verify vote is soft-deleted by checking it's no longer retrievable
+
// GetByURI excludes deleted votes (returns ErrVoteNotFound)
+
_, err = repo.GetByURI(ctx, vote.URI)
+
assert.ErrorIs(t, err, votes.ErrVoteNotFound, "GetByURI should not return deleted votes")
-
// GetByVoterAndSubject should not find deleted votes
+
// GetByVoterAndSubject should also not find deleted votes
_, err = repo.GetByVoterAndSubject(ctx, voterDID, vote.SubjectURI)
assert.ErrorIs(t, err, votes.ErrVoteNotFound, "GetByVoterAndSubject should not return deleted votes")
}
+26 -3
tests/integration/oauth_e2e_test.go
···
assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound for expired session")
// Test cleanup of expired sessions
-
cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredSessions(ctx)
+
// Need to access the underlying PostgresOAuthStore through the wrapper
+
var pgStore *oauth.PostgresOAuthStore
+
if wrapper, ok := store.(*oauth.MobileAwareStoreWrapper); ok {
+
pgStore, _ = wrapper.ClientAuthStore.(*oauth.PostgresOAuthStore)
+
} else {
+
pgStore, _ = store.(*oauth.PostgresOAuthStore)
+
}
+
require.NotNil(t, pgStore, "Should be able to access PostgresOAuthStore")
+
+
cleaned, err := pgStore.CleanupExpiredSessions(ctx)
require.NoError(t, err, "Cleanup should succeed")
assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one session")
···
// Test cleanup of expired auth requests
// Create an auth request and manually set created_at to the past
+
// Use unique state to avoid conflicts with previous test runs
+
oldState := fmt.Sprintf("old-state-%d", time.Now().UnixNano())
oldAuthRequest := oauthlib.AuthRequestData{
-
State: "old-state-12345",
+
State: oldState,
PKCEVerifier: "old-verifier",
AuthServerURL: "http://localhost:3001",
Scopes: []string{"atproto"},
}
+
// Clean up any existing state first
+
_ = store.DeleteAuthRequestInfo(ctx, oldState)
+
err = store.SaveAuthRequestInfo(ctx, oldAuthRequest)
require.NoError(t, err)
···
require.NoError(t, err)
// Cleanup expired requests
-
cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredAuthRequests(ctx)
+
// Need to access the underlying PostgresOAuthStore through the wrapper
+
var pgStore *oauth.PostgresOAuthStore
+
if wrapper, ok := store.(*oauth.MobileAwareStoreWrapper); ok {
+
pgStore, _ = wrapper.ClientAuthStore.(*oauth.PostgresOAuthStore)
+
} else {
+
pgStore, _ = store.(*oauth.PostgresOAuthStore)
+
}
+
require.NotNil(t, pgStore, "Should be able to access PostgresOAuthStore")
+
+
cleaned, err := pgStore.CleanupExpiredAuthRequests(ctx)
require.NoError(t, err, "Cleanup should succeed")
assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one auth request")
+5 -5
tests/integration/vote_e2e_test.go
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user on PDS
-
testUserHandle := fmt.Sprintf("voter-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("vot%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("voter-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user
-
testUserHandle := fmt.Sprintf("toggle-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("tog%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("toggle-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user
-
testUserHandle := fmt.Sprintf("flip-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("flp%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("flip-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user
-
testUserHandle := fmt.Sprintf("delete-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("dlt%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("delete-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteRepo := postgres.NewVoteRepository(db)
// Create test user on PDS
-
testUserHandle := fmt.Sprintf("jetstream-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("jet%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("jetstream-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
+1 -1
cmd/server/main.go
···
routes.RegisterDiscoverRoutes(r, discoverService, voteService, authMiddleware)
log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)")
-
routes.RegisterAggregatorRoutes(r, aggregatorService, userService, identityResolver)
+
routes.RegisterAggregatorRoutes(r, aggregatorService, communityService, userService, identityResolver)
log.Println("Aggregator XRPC endpoints registered (query endpoints public, registration endpoint public)")
// Comment query API - supports optional authentication for viewer state
+7
internal/api/handlers/aggregator/errors.go
···
import (
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
···
}
// handleServiceError maps service errors to HTTP responses
+
// Handles errors from both aggregators and communities packages
func handleServiceError(w http.ResponseWriter, err error) {
if err == nil {
return
}
// Map domain errors to HTTP status codes
+
// Check community errors first (for ResolveCommunityIdentifier calls)
switch {
+
case communities.IsNotFound(err):
+
writeError(w, http.StatusNotFound, "CommunityNotFound", err.Error())
+
case communities.IsValidationError(err):
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
case aggregators.IsNotFound(err):
writeError(w, http.StatusNotFound, "NotFound", err.Error())
case aggregators.IsValidationError(err):
+13 -6
internal/api/handlers/aggregator/list_for_community.go
···
import (
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
···
// ListForCommunityHandler handles listing aggregators for a community
type ListForCommunityHandler struct {
-
service aggregators.Service
+
service aggregators.Service
+
communityService communities.Service
}
// NewListForCommunityHandler creates a new list for community handler
-
func NewListForCommunityHandler(service aggregators.Service) *ListForCommunityHandler {
+
func NewListForCommunityHandler(service aggregators.Service, communityService communities.Service) *ListForCommunityHandler {
return &ListForCommunityHandler{
-
service: service,
+
service: service,
+
communityService: communityService,
}
}
···
return
}
-
// Resolve community identifier to DID (handles both DIDs and handles)
-
// TODO: Implement identifier resolution service - for now, assume it's a DID
-
req.CommunityDID = communityIdentifier
+
// Resolve community identifier to DID (supports DIDs, handles, scoped identifiers)
+
communityDID, err := h.communityService.ResolveCommunityIdentifier(r.Context(), communityIdentifier)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
req.CommunityDID = communityDID
// Get authorizations from service
// Note: Community handle/name fields will be empty until we integrate with communities service
+16 -21
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
//
-
// Request body: { "community": "did:plc:xxx", "contentVisibility": 3 }
-
// Note: Per lexicon spec, only DIDs are accepted for the "subject" field (not handles).
+
// Request body: { "community": "<identifier>", "contentVisibility": 3 }
+
// Where <identifier> can be:
+
// - DID: did:plc:xxx
+
// - Canonical handle: c-name.coves.social
+
// - Scoped identifier: !name@coves.social
+
// - At-identifier: @c-name.coves.social
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"` // DID only (per lexicon)
+
Community string `json:"community"` // DID, handle, or scoped identifier
ContentVisibility int `json:"contentVisibility"` // Optional: 1-5 scale, defaults to 3
}
···
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
-
}
-
// Extract authenticated user DID and access token from request context (injected by auth middleware)
// Note: contentVisibility defaults and clamping handled by service layer
userDID := middleware.GetUserDID(r)
···
}
// Subscribe via service (write-forward to PDS)
+
// Service handles identifier resolution (DIDs, handles, scoped identifiers)
subscription, err := h.service.SubscribeToCommunity(r.Context(), userDID, userAccessToken, req.Community, req.ContentVisibility)
if err != nil {
handleServiceError(w, err)
···
// HandleUnsubscribe unsubscribes a user from a community
// POST /xrpc/social.coves.community.unsubscribe
//
-
// Request body: { "community": "did:plc:xxx" }
-
// Note: Per lexicon spec, only DIDs are accepted (not handles).
+
// Request body: { "community": "<identifier>" }
+
// Where <identifier> can be:
+
// - DID: did:plc:xxx
+
// - Canonical handle: c-name.coves.social
+
// - Scoped identifier: !name@coves.social
+
// - At-identifier: @c-name.coves.social
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"` // DID only (per lexicon)
+
Community string `json:"community"` // DID, handle, or scoped identifier
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
···
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
-
}
-
// Extract authenticated user DID and access token from request context (injected by auth middleware)
userDID := middleware.GetUserDID(r)
if userDID == "" {
···
}
// Unsubscribe via service (delete record on PDS)
+
// Service handles identifier resolution (DIDs, handles, scoped identifiers)
err := h.service.UnsubscribeFromCommunity(r.Context(), userDID, userAccessToken, req.Community)
if err != nil {
handleServiceError(w, err)
+496
internal/api/handlers/community/subscribe_test.go
···
+
package community
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"bytes"
+
"context"
+
"encoding/json"
+
"errors"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
)
+
+
// subscribeTestService implements communities.Service for subscribe handler tests
+
type subscribeTestService struct {
+
subscribeFunc func(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error)
+
unsubscribeFunc func(ctx context.Context, userDID, accessToken, communityIdentifier string) error
+
}
+
+
func (m *subscribeTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
+
return nil, 0, nil
+
}
+
+
func (m *subscribeTestService) SubscribeToCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {
+
if m.subscribeFunc != nil {
+
return m.subscribeFunc(ctx, userDID, accessToken, communityIdentifier, contentVisibility)
+
}
+
return &communities.Subscription{
+
UserDID: userDID,
+
CommunityDID: "did:plc:community123",
+
RecordURI: "at://did:plc:user/social.coves.community.subscription/abc123",
+
RecordCID: "bafytest123",
+
SubscribedAt: time.Now(),
+
}, nil
+
}
+
+
func (m *subscribeTestService) UnsubscribeFromCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error {
+
if m.unsubscribeFunc != nil {
+
return m.unsubscribeFunc(ctx, userDID, accessToken, communityIdentifier)
+
}
+
return nil
+
}
+
+
func (m *subscribeTestService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) BlockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) (*communities.CommunityBlock, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) UnblockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error {
+
return nil
+
}
+
+
func (m *subscribeTestService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) {
+
return false, nil
+
}
+
+
func (m *subscribeTestService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) {
+
return nil, nil
+
}
+
+
func (m *subscribeTestService) ValidateHandle(handle string) error {
+
return nil
+
}
+
+
func (m *subscribeTestService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
+
return identifier, nil
+
}
+
+
func (m *subscribeTestService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) {
+
return community, nil
+
}
+
+
func (m *subscribeTestService) GetByDID(ctx context.Context, did string) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func TestSubscribeHandler_Subscribe_Success(t *testing.T) {
+
tests := []struct {
+
name string
+
community string
+
contentVisibility int
+
expectedCommunity string
+
}{
+
{
+
name: "subscribe with DID",
+
community: "did:plc:community123",
+
contentVisibility: 3,
+
expectedCommunity: "did:plc:community123",
+
},
+
{
+
name: "subscribe with canonical handle",
+
community: "c-worldnews.coves.social",
+
contentVisibility: 5,
+
expectedCommunity: "c-worldnews.coves.social",
+
},
+
{
+
name: "subscribe with scoped identifier",
+
community: "!worldnews@coves.social",
+
contentVisibility: 1,
+
expectedCommunity: "!worldnews@coves.social",
+
},
+
{
+
name: "subscribe with at-identifier",
+
community: "@c-tech.coves.social",
+
contentVisibility: 4,
+
expectedCommunity: "@c-tech.coves.social",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
var receivedIdentifier string
+
mockService := &subscribeTestService{
+
subscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {
+
receivedIdentifier = communityIdentifier
+
return &communities.Subscription{
+
UserDID: userDID,
+
CommunityDID: "did:plc:resolved",
+
RecordURI: "at://did:plc:user/social.coves.community.subscription/abc123",
+
RecordCID: "bafytest123",
+
SubscribedAt: time.Now(),
+
}, nil
+
},
+
}
+
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": tc.community,
+
"contentVisibility": tc.contentVisibility,
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject auth context
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser")
+
ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token")
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleSubscribe(w, req)
+
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Verify the community identifier was passed through correctly
+
if receivedIdentifier != tc.expectedCommunity {
+
t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier)
+
}
+
+
// Verify response structure
+
var resp struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
Existing bool `json:"existing"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
if resp.URI == "" || resp.CID == "" {
+
t.Errorf("Expected uri and cid in response, got %+v", resp)
+
}
+
})
+
}
+
}
+
+
func TestSubscribeHandler_Subscribe_RequiresAuth(t *testing.T) {
+
mockService := &subscribeTestService{}
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
"contentVisibility": 3,
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
// No auth context
+
+
w := httptest.NewRecorder()
+
handler.HandleSubscribe(w, req)
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d", w.Code)
+
}
+
+
var errResp struct {
+
Error string `json:"error"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "AuthRequired" {
+
t.Errorf("Expected error AuthRequired, got %s", errResp.Error)
+
}
+
}
+
+
func TestSubscribeHandler_Subscribe_RequiresCommunity(t *testing.T) {
+
mockService := &subscribeTestService{}
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"contentVisibility": 3,
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser")
+
ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token")
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleSubscribe(w, req)
+
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", w.Code)
+
}
+
}
+
+
func TestSubscribeHandler_Subscribe_ServiceErrors(t *testing.T) {
+
tests := []struct {
+
name string
+
serviceErr error
+
expectedStatus int
+
expectedError string
+
}{
+
{
+
name: "community not found",
+
serviceErr: communities.ErrCommunityNotFound,
+
expectedStatus: http.StatusNotFound,
+
expectedError: "NotFound",
+
},
+
{
+
name: "validation error",
+
serviceErr: communities.NewValidationError("community", "invalid format"),
+
expectedStatus: http.StatusBadRequest,
+
expectedError: "InvalidRequest",
+
},
+
{
+
name: "unauthorized",
+
serviceErr: communities.ErrUnauthorized,
+
expectedStatus: http.StatusForbidden,
+
expectedError: "Forbidden",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
mockService := &subscribeTestService{
+
subscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {
+
return nil, tc.serviceErr
+
},
+
}
+
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
"contentVisibility": 3,
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser")
+
ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token")
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleSubscribe(w, req)
+
+
if w.Code != tc.expectedStatus {
+
t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, w.Code, w.Body.String())
+
}
+
+
var errResp struct {
+
Error string `json:"error"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != tc.expectedError {
+
t.Errorf("Expected error %s, got %s", tc.expectedError, errResp.Error)
+
}
+
})
+
}
+
}
+
+
func TestSubscribeHandler_Unsubscribe_Success(t *testing.T) {
+
tests := []struct {
+
name string
+
community string
+
expectedCommunity string
+
}{
+
{
+
name: "unsubscribe with DID",
+
community: "did:plc:community123",
+
expectedCommunity: "did:plc:community123",
+
},
+
{
+
name: "unsubscribe with canonical handle",
+
community: "c-worldnews.coves.social",
+
expectedCommunity: "c-worldnews.coves.social",
+
},
+
{
+
name: "unsubscribe with scoped identifier",
+
community: "!worldnews@coves.social",
+
expectedCommunity: "!worldnews@coves.social",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
var receivedIdentifier string
+
mockService := &subscribeTestService{
+
unsubscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string) error {
+
receivedIdentifier = communityIdentifier
+
return nil
+
},
+
}
+
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": tc.community,
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unsubscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser")
+
ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token")
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleUnsubscribe(w, req)
+
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
if receivedIdentifier != tc.expectedCommunity {
+
t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier)
+
}
+
+
var resp struct {
+
Success bool `json:"success"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
if !resp.Success {
+
t.Errorf("Expected success: true in response")
+
}
+
})
+
}
+
}
+
+
func TestSubscribeHandler_Unsubscribe_SubscriptionNotFound(t *testing.T) {
+
mockService := &subscribeTestService{
+
unsubscribeFunc: func(ctx context.Context, userDID, accessToken, communityIdentifier string) error {
+
return communities.ErrSubscriptionNotFound
+
},
+
}
+
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unsubscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser")
+
ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token")
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleUnsubscribe(w, req)
+
+
if w.Code != http.StatusNotFound {
+
t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
}
+
+
func TestSubscribeHandler_MethodNotAllowed(t *testing.T) {
+
mockService := &subscribeTestService{}
+
handler := NewSubscribeHandler(mockService)
+
+
// Test GET on subscribe endpoint
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.subscribe", nil)
+
w := httptest.NewRecorder()
+
handler.HandleSubscribe(w, req)
+
+
if w.Code != http.StatusMethodNotAllowed {
+
t.Errorf("Expected status 405, got %d", w.Code)
+
}
+
+
// Test GET on unsubscribe endpoint
+
req = httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.unsubscribe", nil)
+
w = httptest.NewRecorder()
+
handler.HandleUnsubscribe(w, req)
+
+
if w.Code != http.StatusMethodNotAllowed {
+
t.Errorf("Expected status 405, got %d", w.Code)
+
}
+
}
+
+
func TestSubscribeHandler_InvalidJSON(t *testing.T) {
+
mockService := &subscribeTestService{}
+
handler := NewSubscribeHandler(mockService)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBufferString("invalid json"))
+
req.Header.Set("Content-Type", "application/json")
+
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser")
+
ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token")
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleSubscribe(w, req)
+
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", w.Code)
+
}
+
}
+
+
func TestSubscribeHandler_RequiresAccessToken(t *testing.T) {
+
mockService := &subscribeTestService{}
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.subscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// User DID but no access token
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:testuser")
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleSubscribe(w, req)
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d", w.Code)
+
}
+
}
+
+
// Ensure unused import is used
+
var _ = errors.New
+3 -1
internal/api/routes/aggregator.go
···
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
"Coves/internal/core/users"
"net/http"
"time"
···
func RegisterAggregatorRoutes(
r chi.Router,
aggregatorService aggregators.Service,
+
communityService communities.Service,
userService users.UserService,
identityResolver identity.Resolver,
) {
// Create query handlers
getServicesHandler := aggregator.NewGetServicesHandler(aggregatorService)
getAuthorizationsHandler := aggregator.NewGetAuthorizationsHandler(aggregatorService)
-
listForCommunityHandler := aggregator.NewListForCommunityHandler(aggregatorService)
+
listForCommunityHandler := aggregator.NewListForCommunityHandler(aggregatorService, communityService)
// Create registration handler
registerHandler := aggregator.NewRegisterHandler(userService, identityResolver)